Skip to main content

[PRD] Enable Team Owner Field & Team Permission in CDP

Supersedes: [PRD] Enable Team Owner Field and Team Permission in CDP (page 51184435700). Why v2.0: Validated against contact-service, qontak-customer-fe, qontak.com legacy CRM, qontak-launchpad. Several assumptions did not hold. This version is grounded in code (see Appendix A). v2.3 change: Team Owner is now a multi-select field (team_owner_ids — an array), and auto-fill populates ALL of the creator's teams (not a single earliest team). v2.4 change: TEAM ONLY scoping now also covers Contact Notes (customers_customernotes_view / _manage / _delete) across API, web, and mobile (mobile-qontak-crm). Notes inherit their parent contact's team scope. v2.5 change: TEAM ONLY logic refined to match production/legacy: (1) an empty team_owner_ids (Unassigned) is visible to ALL Team Only users; (2) hierarchy traversal — a viewer also sees contacts owned by the descendant (child) teams of their teams. Migration source corrected to crm_team_hierarchy_id. v2.6 change (reformat): Restructured to the canonical Qontak template — sections reordered + renumbered; the 17 prose stories + 9 negative scenarios converted to the native Section-10 story table (per-story TEAM-S0X IDs, strict Gherkin); Frontend/Mobile change sections folded into Feature Changes; added API & Webhook Behavior (§9), Launch Plan & Stage Gates (§14), and a Dependency Graph. No content meaning changed — see Changelog.


✅ Reformat Complete — No Flags

All mandatory sections were preserved and reshaped to the canonical template; no content gaps were introduced. The only pending item is Figma (Header Figma Master = TBD) — design for the Team Owner multi-select + Team Only permission radio is not yet linked.


HEADER BLOCK

FieldValue
PMZhelia Alifa
PRD Version2.7
StatusDRAFT
PRD TypeADJUSTMENT
EpicTF-3185
SquadCDP Squad
RFC LinkN/A — to be created
Figma MasterTBD — Team Owner multi-select + Team Only permission radio
AnchorNo — standalone adjustment under PRD 25Q4 — Permission Completeness and Adjustment for CDP
Labelsepic:qontak-cdp | module:customers | feature:team-permission
Last Updated2026-06-30

Table of Contents


2. Adjustment Context

FieldDetail
Parent Anchor PRDPRD 25Q4 — Permission Completeness and Adjustment for CDP
This adjustment covers(1) A new Team Owner field (multi-select) on the CDP contact, (2) meaningful enforcement of the TEAM ONLY access level for the four CDP customer permission actions (customers_customers_view, customers_customers_manage, customers_customers_delete, customers_customers_searchassoc), and (3) TEAM ONLY for Contact Notescustomers_customernotes_view, customers_customernotes_manage, customers_customernotes_delete — which inherit their parent contact's team scope (CHG-006, Stories 12–17).
Parent PRD still ownsAll other CDP module permissions and the existing own / everything levels.
Reason for adjustmentThe team permission level already exists in the backend (const.go:41) but is treated as full-org access — permission_service.go:86-89 returns true for every contact regardless of team. Teams need a real intermediate level. The Team Owner field (a set of team IDs) provides the stable scoping key.
Scope boundaryLaunchpad permission/team APIs, contact-service query + enforcement (MongoDB), contact-service write/migration path, customer-fe UI, and mobile-qontak-crm (Flutter — CDP/Contact360 notes). Note: the Role & Permission settings UI is Launchpad-owned, not customer-fe — see CHG-001 and §15.

3. One-liner + Problem

One-liner: Add a multi-select Team Owner to CDP contacts and enforce TEAM ONLY scoping, so agents see every contact owned by any of their teams.

Problem: CDP exposes own, team, everything (const.go:40-42) — but team is unscoped: returns true for every company contact, identical to everything (permission_service.go:86-89). Admins have two unusable choices: over-restrict (own) or over-expose (everything). CDP also has no concept of which team(s) own a contact. Legacy CRM solves this with a single crm_team_id per person and a "Team Only" scope — but CDP needs multiple owning teams (collaborative hand-off across teams), and today has neither the field nor the filter.

Example: Zhelia ∈ {Product Management, Qontak Product}. On create, Team Owner auto-fills to both teams → team_owner_ids = [Product Management, Qontak Product]. With customers_customers_view = team, she sees every contact whose team_owner_ids includes either of her teams — plus contacts owned by any descendant (child) team of those teams (hierarchy traversal), plus any Unassigned contact (empty team_owner_ids).

  • Case 1 — remove all team_owner_ids → empty. An empty/Unassigned owner is visible to every Team Only user; and via hierarchy traversal, managers in higher-level teams also see records from their subordinate (child) teams.
  • Case 2 — change to only Qontak Product. Only Qontak Product owns it; Product-Management-only members lose visibility — unless they qualify via owner/assignee (own records) or hierarchy traversal (a manager whose team is a parent of Qontak Product still sees it).
  • Case 3 — change the owner. Customer X is owned by Zhelia with team_owner_ids = {Product Management, Qontak Product}. When the owner is reassigned to Rara (∈ {Product Management, Qontak Designer}), team_owner_ids auto-re-derives to {Product Management, Qontak Designer} (Rara's teams) — team ownership follows the owner (see TEAM-S18).

4. What Happens If We Don't Build This

  • Admins keep an unusable middle option. team silently behaves like everything, so every tenant that wants team-scoped visibility must over-expose (everything) or over-restrict (own) — there is no correct setting.
  • Cross-team data exposure persists. Agents set to team today can see every contact in the company, a privacy/segregation gap for multi-team orgs and regulated accounts.
  • CRM-migrating clients lose a capability they had. Legacy CRM has a working "Team Only" scope (crm_team_hierarchy_id + descendant traversal); without this, clients moving to CDP regress to all-or-nothing visibility.
  • No ownership signal on contacts. CDP cannot express which team(s) own a contact, blocking collaborative hand-off and any future team-based reporting/routing.

5. Target Users + Persona Context

PersonaRoleGoalPain (today)Workaround
Primary — Org Admin / SupervisorConfigures Role & PermissionsScope agents to team contacts without blocking collaborationteam silently behaves like everythingSet ALL ACCESS + informal naming conventions
Secondary — CS / Sales AgentHandles contacts within their team(s)View/act on contacts owned by any of their teamsUnder own, cannot see any contact they don't personally ownAsk supervisor to manually reassign contacts

6. Non-Goals

  1. No custom permission groups beyond the three standard levels (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 and membership are owned by Launchpad.
  4. Downward hierarchy only. TEAM ONLY resolves the user's directly-assigned teams and their descendant (child) teams — managers see sub-team records, but a child-team member does not automatically see parent-team-owned records (traversal is downward only). (v2.5: reverses the v2.4 flat-only model — grounded in legacy descendant_ids + Launchpad teams.parent_id.)
  5. No bulk re-assignment tool for Team Owner in v1 (single-record edit only).
  6. No change to export/audit permission rules beyond CHG-002.
  7. customers_customers_profileview and customers_customers_profilemanage permissions are not in scope for TEAM ONLY (see OQ-6). (Contact Notes — customers_customernotes_* — ARE now in scope as of v2.4; see CHG-006 and Stories 12–17.)

Scope Changes

Engineering surfaces this PRD touches (controlled vocab). Kept in sync with the scope_changes frontmatter above.

  • Backendcontact-service: new team_owner_ids array field + multikey index, TEAM ONLY filter (empty-visible + downward hierarchy), EvaluatePermissions extension, notes-scope enforcement (CHG-006), legacy backfill; qontak-launchpad: new teams-for-user(+descendants) API and the team permission-level option.
  • Frontendqontak-customer-fe: Team Owner multi-select (MpInputTag), reads permission level (not just is_enabled), /customers/teams route, notes view/add/edit/delete gating.
  • Mobilemobile-qontak-crm: render the Team Owner field and read per-note permission flags for CDP/Contact360 notes.
  • Design — Figma for the Team Owner multi-select field and the Team Only permission radio (currently TBD).

7. Constraints

ConstraintValue
PlatformTEAM ONLY enforcement and Team Owner field apply to web (qontak-customer-fe) and mobile (mobile-qontak-crm, Flutter CDP/Contact360). Enforcement is server-side in contact-service; clients are UX only. Contact Notes scoping (CHG-006) applies on all three layers.
Datastorecontact-service stores contacts in MongoDB (BSON), not SQL. Team Owner is an array field team_owner_ids []string. TEAM ONLY is a BSON $or of: (a) multikey $in team_owner_ids: {$in: <viewer's teams + their descendant teams>}; (b) Unassigned team_owner_ids empty/missing ({$in: [null]} / {$size: 0} / {$exists: false}) → visible to all; (c) owner_id == user; (d) assignee_id == user. Backed by compound multikey index (company_sso_id, is_deleted, team_owner_ids).
PerformanceTEAM ONLY contact list ≤ 2s P95. Filter uses stored team_owner_ids (indexed multikey $in). Viewer's team IDs + descendant team IDs resolved via ≤1 cached Launchpad call (hierarchy expansion via teams.parent_id; cache the expanded set, short TTL).
Feature flagcdp_team_permission_enabled | default: OFF, enabled per company by Ops. (Not GetIsPermissionUsmanEnabled — that flag is company-wide and covers all 12 permissions; reusing it would change own/everything behavior for tenants with it already ON. A dedicated CDP-scoped flag is required — see CHG-005.)
Backward compatibilityBehaviour change risk: roles currently at level team see the whole org today; after this change they narrow to team scope. Must be gated by the per-tenant flag + migration/comms plan (CHG-005).
Plan scopePlans with CDP Role & Permissions — Growth and Enterprise. Not Starter.
Implementation scope (contact-service)team_owner_ids (array) must be added to 4 specific files: (1) internal/app/repository/contact/base.goTeamOwnerIDs []string \bson:"team_owner_ids,omitempty"`(mirror the existingTags []stringatbase.go:75); (2) internal/app/payload/search_contact_request.go — add DTO field with **bson:"-"** (to bypass the generic primitive.A$orexpansion at lines 138-157) + an explicitToFilters()branchfilters["team_owner_ids"] = bson.M{"$in": req.TeamOwnerIDs}; (3) internal/app/payload/contact_sync_request.go(write path, array); (4)internal/app/service/permission_service.go (EvaluatePermissions extended with **teamOwnerIDs []string** + **viewerTeamIDs []string** — BREAKING CHANGE). Additionally, **6 handler branches** in contact_handler.go(lines ~256, 466, 543, 648, 760, 956) that currently check onlyOwnerPermissionKeyneed correspondingTeamPermissionKey branches doing array-intersection (viewerTeamIDs ∩ contact.TeamOwnerIDs ≠ ∅`).
Implementation scope (notes)Contact Notes have no team owner of their ownContactNote (contact_notes/base.go:26-36) holds only contact_id, owner_id, note, company_sso_id. Notes inherit the parent contact's team_owner_ids. Enforcement is net-new in the notes service: today the notes handler does NOT call EvaluatePermissions, the middleware is a binary gate only, and ValidateContactExists returns a bool (no scope fields). See CHG-006.

8. Feature Changes

CHG-001 — Add team (TEAM ONLY) to the Role & Permission settings UI

FieldDetail
Change TypeModified — Role & Permission settings (CDP Customers section)
Codebase ownerLaunchpad Squad (or Admin Portal Squad — confirm which codebase hosts the permission settings UI). Not customer-fe. Level-update endpoint: PUT /iag/v1/users/{sso_id}/permissions/{permission_name}/level (Launchpad rest_router.go:172).
BeforeEach of the four actions shows only {Owned Only, All Access}.
AfterEach action shows {Owned Only, Team Only, All Access}, persisting own/team/everything via the existing level-update endpoint.

CHG-002 — Enforce TEAM ONLY in contact-service (MongoDB)

FieldDetail
Change TypeModified backend — query scoping + single-record authorization + service signature extension
DatastoreMongoDB / BSON
Affected endpointsGET /cdp/customers, GET /cdp/customers/{id} (+ by email/phone), DELETE /cdp/customers/{id}, GET /cdp/customers/search-assoc

Filter semantics per level:

LevelServer-side filter
ownowner_id == user OR assignee_id == user — unchanged.
team (NEW)$or of: (a) team_owner_ids: {$in: <viewer's teams **+ descendant teams**>} (multikey intersection — matches if any owning team is one of the viewer's teams or a child team beneath them); (b) Unassignedteam_owner_ids empty/missing → visible to all Team Only users; (c) owner_id == user; (d) assignee_id == user. Viewer's expanded team set from the new Launchpad teams-for-user(+descendants) API (cached, short TTL). Viewer with no teams → still sees Unassigned + own/assigned records.
everythingCompany scope only — unchanged.
disabledNo access; direct URL/API → 403.

Implementation scope (all must be changed):

  • search_contact_request.go — add TeamOwnerIDs []string to the DTO with bson:"-" (so it bypasses the generic primitive.A$or expansion at lines 138-157), and an explicit ToFilters() branch: if len(req.TeamOwnerIDs) > 0 { filters["team_owner_ids"] = bson.M{"$in": req.TeamOwnerIDs} }. This is the first $in in this file; the idiom is proven elsewhere (segmented_filter_request.go:211, field_properties_search.go:66).
  • contact_handler.go6 branches at lines ~256, 466, 543, 648, 760, 956 (all currently check OwnerPermissionKey only); each needs a TeamPermissionKey branch that allows when contact.TeamOwnerIDs is empty (Unassigned) OR expandedViewerTeamIDs ∩ contact.TeamOwnerIDs ≠ ∅ OR owner/assignee == user (else 403). expandedViewerTeamIDs = the viewer's teams plus their descendant teams.
  • permission_service.goEvaluatePermissions signature extended to (permissions, userSSOID, ownerID, assigneeID, teamOwnerIDs []string, viewerTeamIDs []string) where viewerTeamIDs is the expanded set (teams + descendants); the team branch (currently return true at lines 86-89) now returns true if teamOwnerIDs is empty (Unassigned) OR intersects viewerTeamIDs OR owner/assignee match — BREAKING CHANGE.
  • get_contact.go:22-26 — single-get guard must add the team-intersection check alongside the company check.
  • Emit cdp_team_permission_denied / cdp_team_permission_fallback events on denial or Launchpad-unavailable fallback.

CHG-003 — New Team Owner field on the CDP contact (Web + Mobile) — MULTI-SELECT

FieldDetail
Change TypeNew field — contact detail + create form
4-file schema change(1) internal/app/repository/contact/base.goTeamOwnerIDs []string \bson:"team_owner_ids,omitempty"`(mirrorTags []stringatbase.go:75); (2) internal/app/payload/search_contact_request.go(DTObson:"-"+ToFilters() $inbranch); (3)internal/app/payload/contact_sync_request.go(write path, array); (4) MongoDB compound **multikey** index(company_sso_id, is_deleted, team_owner_ids)— new migration032, declared like any scalar index (MongoDB makes it multikey automatically; precedent: phone/name_search` arrays indexed plainly).
EditabilityEditable, MULTI-SELECT. Options = the user's assigned teams (flat "My Teams" list, from the new teams-for-user API). Can be emptied entirely → Unassigned.
Default on createAuto-fill = ALL of the creator's teams. No team → empty array (Unassigned).
PersistenceStored as-is on manual edit. Exception — owner-change sync: when the contact's owner_id changes, team_owner_ids is auto-re-derived to the new owner's full team set (TEAM-S18, mirrors the create-time auto-fill in D-4), replacing the prior value. Not re-derived on unrelated saves or on the current owner later joining/leaving teams (OQ-2).
ValidationServer-side: every submitted team_owner_ids element must be a team the acting user belongs to; empty array allowed. Applies to web, mobile, OpenAPI. Reject with 422 if any element is outside the user's teams.
DisplayShows team-name chips; empty array → "Unassigned" (not blank).
API/sync writesSame rule; no team → empty array (Unassigned).

CHG-004 — Migration of existing contacts

StepDetail
1. Identify sourceUse crm_people.crm_team_hierarchy_id — the editable multi-team owner CSV (e.g. "3,7"), parsed by crm_team_hierarchy (person.rb:234-245). Corrected (v2.5): this is the real owner list, NOT crm_team_id (which is only the creator's auto-stamped home team).
2. Splitcrm_team_hierarchy_id.split(',') → list of legacy team integer IDs (one or many per contact).
3. ReconcileFor each distinct legacy team ID, map to its Launchpad team UUID by team name within company. IDs are incompatible (integer vs UUID); map only.
4. Map & setSet team_owner_ids = [mapped_uuid, ...] — a multi-element array preserving every owning team (legacy was already multi-team).
5. Fallbackscrm_team_hierarchy_id null/empty or no name match → team_owner_ids = [] (Unassigned → visible to all under TEAM ONLY, per D-13).
6. ReportCoverage + Unassigned count + unmatched teams before enabling enforcement.

CHG-005 — Backward-compatibility + dedicated flag

FieldDetail
Riskteam maps to full-org access today (permission_service.go:86-89). Any role at level team will silently narrow on enforcement.
Dedicated flagUse cdp_team_permission_enabled | default: OFFnot GetIsPermissionUsmanEnabled. The existing flag is company-wide; reusing it changes own/everything behavior for tenants with it already ON (require_permission_middleware.go:21).
MitigationAudit existing team-level roles per tenant before enabling; gate behind dedicated flag; communicate change; enable progressively with monitoring.

CHG-006 — Enforce TEAM ONLY for Contact Notes (customers_customernotes_view / _manage / _delete)

FieldDetail
Change TypeModified backend + web + mobile — notes inherit parent-contact team scope
DatastoreMongoDB / BSON
Affected permission keyscustomers_customernotes_view, customers_customernotes_manage, customers_customernotes_delete (const.go:34-37). (Note creation customers_customernotes_add is governed separately and inherits the parent-contact visibility gate — see OQ-10.)
Affected endpointsGET /contacts/{contact_id}/notes (list), GET .../{id}, PUT .../{id}, DELETE .../{id} — plus the deprecated duplicate group /contacts/notes/... (rest_router.go:150-158 + 161-168; both share handlers).
Core principleNotes have no team owner of their own. A note's only team-relevant link is contact_id → parent contact. So customers_customernotes_* = team ⇒ "can the viewer see the parent contact under TEAM ONLY" — i.e. the full v2.5 rule applies transitively: parent team_owner_ids empty (Unassigned → all see) OR intersects {viewer's teams ∪ descendants} OR viewer is the contact's owner_id/assignee_id. Because notes are always fetched by a single contact_id, list/view scoping collapses to one parent-contact gate (no per-note filtering, no N+1).

Implementation scope (contact-service):

  • New parent-contact scope lookup. ValidateContactExists (contact_notes/read.go) returns only a bool. Add a method returning the contact's owner_id, assignee_id, team_owner_ids (FindOne projection); add it to ContactNoteInterface (contact_notes/base.go:70-78); regenerate mocks.
  • Enforce in the notes SERVICE, not just the handler. Today the handler (contact_notes_handler.go) does NOT call EvaluatePermissions; the middleware (require_permission_middleware.go:78-98) is a binary gate (Level != disabled) that stores the level in context but enforces no scoping. Add scope evaluation in contact_notes_service.go for list / get / update / delete: everything → allow; team → allow if parent team_owner_ids is empty (Unassigned) OR intersects {viewer's teams ∪ descendants} OR viewer owns/assigned the parent contact; own → allow if viewer owns/assigned the parent contact.
  • Reuse the extended EvaluatePermissions (CHG-002, D-10) — now taking teamOwnerIDs []string + viewerTeamIDs []string.
  • Fix resolveNotePermission (contact_notes_handler.go:143-166) — it currently honors only everything/own and silently drops team. Add the team branch so the per-note Update/Delete flags returned to clients reflect team scope (this is the seam both web and mobile read).
  • Plumb the permission level + viewer team IDs into the service (handler already reads usmanPermission + userSSOID from context).
  • Both route groups share handlers → one service change covers both.

Behavior change (confirm — OQ-11): Note update/delete are owner-only today regardless of level (contact_notes_service.go enforces note.owner_id == actor). Under team/everything, a team member / everything-level user should be able to manage/delete others' notes on an in-scope contact. This broadens current behavior — confirm product intent.

CHG-007 — Frontend (customer-fe) implementation (folded from the former "Frontend Changes" section)

#ChangeSpecific files to modify
FE-1Source the user's OWN teams for the picker + auto-fill. getTeams() is already wired (DetailPage.vue:139 onMounted), but it hits GET /v1/teams?...&per_page=10 (IAG Launchpad) — all company teams, capped at 10 — the wrong source. Add a store action that calls the new teams-for-user API (GET /iag/v1/users/me/teams) and use it for both auto-fill and the multi-select options.features/customers/store/UserStore.ts (add getMyTeams() action + myTeams ref); features/customers/detail/views/DetailPage.vue:139
FE-2Render the editable Team Owner multi-select on contact detail/create using MpInputTag (Pixel multi-tag). Fork common/components/field/MultipleSelect.vue into a TeamOwnerSelect.vue, sourcing options from myTeams and emitting team IDs (the existing MultipleSelect.vue emits names at lines 147-148 — the fork must emit item.id). Wire into the dynamic form in CustomerDetails.vue (field block ~106-127), not the legacy Profile.vue.features/customers/detail/components/CustomerDetails.vue (field block ~106-127), new common/components/field/TeamOwnerSelect.vue, features/customers/store/CustomerStore.ts (add team_owner_ids?: string[] to Contact after line 304)
FE-3Surface the permission level (not just is_enabled). Update ListPage.vue:531-532 and DetailPage.vue:130-131 (currently perm?.is_enabled === true) to also read perm?.level. Update UserStore.ts hasAssociatedAccess() to return { enabled, level }.features/customers/views/ListPage.vue:531-532, features/customers/detail/views/DetailPage.vue:130-131, features/customers/store/UserStore.ts:156
FE-4Add /customers/teams route to sidebar; wire filterByRoute === 'teams' in ListPage.vue:190,367 to inject the team_owner_ids filter (same pattern as 'owned-by-me'owner_id, 'assigned-to-me'assignee_id).components/Sidebar/SidebarChildCustomer.vue, features/customers/views/ListPage.vue:190,367, features/customers/views/components/Breadcrumb.vue
FE-5Pre-mount route guard instead of post-mount redirect.middleware/authenticated.global.ts, pages/customers/add.vue
FE-6Notes (CHG-006): view/add gates read permission level, not just is_enabled. canViewNotes (CustomerActivityV2.vue:56-70) and canAddNotes (Notes.vue:83-97) currently check is_enabled only — same gap as FE-3. Per-note edit/delete already render the server-computed note.permission.update/.delete booleans (NotesList.vue:91-101) — so once the API computes team scope into those flags (CHG-006), web edit/delete follow automatically with no FE change. Add customers_customernotes_manage/_delete constants to Notes/constants.ts (only _add/_view exist today).features/customers/detail/components/CustomerActivityV2/CustomerActivityV2.vue:56-70, .../Notes/Notes.vue:83-97, .../Notes/NotesList/NotesList.vue:91-101, .../Notes/constants.ts:27-28

(All FE gating is UX only; the security boundary is CHG-002 + CHG-006 server-side.)

CHG-008 — Mobile (mobile-qontak-crm) implementation (folded from the former "Mobile Changes" section)

Flutter / Dart (Melos monorepo, Clean Architecture + BLoC). CDP notes live in the crm_note package behind feature flags flag_contact_360 + flag_note_cdp.

#ChangeSpecific files to modify
MB-1Read each note's own permission, not the contact-level flag. The CDP API already returns per-note permission: {update, delete} and mobile parses it into NotePermissionbut the UI ignores it, gating add/edit/delete on the contact-level permission.update flag (other_tab.dart:108). Change the note list/detail to gate on note.permission.update/.delete (already parsed). Once the API computes TEAM ONLY into those per-note booleans (CHG-006), no level enum is needed on mobile — it just works.features/crm_note/.../presentation/screens/note/note_screen.dart:290, .../detail_note/detail_note_screen.dart:629, features/crm_contact/.../tab/other_tab.dart:108,146
MB-2(If notes view must be team-gated as a section) plumb customers_customernotes_view — currently ABSENT on mobile; the Notes section visibility is gated only by feature flags.features/crm_contact/.../tab/other_tab.dart:146; CDP permission plumbing
MB-3Render the Team Owner multi-select field on the CDP contact detail/create screen (same server-side validation as web).features/crm_contact/... contact detail/create form

(All mobile gating is UX only; the security boundary is CHG-002 + CHG-006 server-side. Endpoints are the same contact-service /v1/contacts/{id}/notes as web.)


9. API & Webhook Behavior

Server-side enforcement lives in contact-service; clients are UX only. The viewer's expanded team set (their teams + descendant teams) is resolved per request via the cached Launchpad teams-for-user(+descendants) API. "In team scope" below means: team_owner_ids empty (Unassigned) OR team_owner_ids ∩ expandedViewerTeamIDs ≠ ∅ OR owner_id/assignee_id == viewer.

#BehaviorEntity AffectedTriggered ByExpected BehaviorFailure Behavior
1Persist a permission levelcrs_permissions (Launchpad)Admin saves a level in Role & Permission settingsPUT /iag/v1/users/{sso_id}/permissions/{permission_name}/level persists own/team/everything; settings page reflects it on reload5xx → inline error "Could not save permissions"; previous level remains active
2List contacts (scoped)CDP contacts (read)GET /cdp/customers with customers_customers_view = teamResolve expanded viewer teams (cached); apply BSON $or: Unassigned OR team_owner_ids {$in: teams+descendants} OR owner/assignee == viewer; return the scoped listLaunchpad teams API timeout (>500ms)/5xx → fall back to OWNED ONLY, banner shown, cdp_team_permission_fallback logged
3Get / delete a single contact (scoped)CDP contact (read/delete)GET /cdp/customers/{id} (+ by email/phone), DELETE /cdp/customers/{id} under view/delete = teamSingle-record guard (get_contact.go): return/allow only if the contact is in team scope; else 403. EvaluatePermissions extended with teamOwnerIDs + viewerTeamIDs (D-10)Out-of-scope → 403 + cdp_team_permission_denied; Launchpad unavailable → OWNED ONLY fallback + cdp_team_permission_fallback
4Association search (scoped)CDP contacts (read, association)GET /cdp/customers/search-assoc from Deal/Task/Ticket/Company under searchassoc = teamReturns only in-team-scope contacts (same $or filter); disabled → component hidden + API 403Same fallback as #2
5Write Team Owner on create/editCDP contact (team_owner_ids)POST/PATCH /cdp/customers/{id} with team_owner_ids (web/mobile/OpenAPI)Validate every element is one of the acting user's teams (empty allowed → Unassigned); persist the array; emit cdp_team_owner_set. On owner change: if the request changes owner_id, team_owner_ids is re-derived server-side to the new owner's full team set (TEAM-S18) — overriding any submitted/prior value — and cdp_team_owner_set is emitted with source = owner_changeAny element outside the user's teams → 422 + cdp_team_owner_validation_rejected; field reverts on the client. New owner's teams-for-user lookup unavailable → owner change still commits; re-derivation retried async (OQ-2)
6List / get / update / delete contact notes (scoped)CDP ContactNote (read/write)GET/PUT/DELETE /contacts/{contact_id}/notes[/{id}] under customers_customernotes_{view,manage,delete} = teamNotes inherit the parent contact's scope (CHG-006): allow only if the parent contact is in team scope; service computes per-note permission.update/.delete returned to clientsParent out-of-scope → 403; Launchpad unavailable → same OWNED ONLY fallback as #2

10. System Flow + User Stories + ACs

10.1 System Flow

Flow A — TEAM ONLY permission configuration + enforcement (11 steps)

  1. Admin opens Settings → Roles → [Role Name] → Permissions → CDP Customers section.
  2. Admin sees four permission actions (View, Manage, Delete, Search Assoc) each with three radio options.
  3. Admin selects Team Only for desired action(s) and saves.
  4. System calls PUT /iag/v1/users/{sso_id}/permissions/{permission_name}/level (Launchpad) to persist team level.
  5. Settings page shows Team Only as the current value on reload.
  6. Agent with this role loads the Customers list (web or mobile).
  7. contact-service calls the Launchpad teams-for-user(+descendants) API (cached, short TTL) to resolve the agent's team IDs and their descendant team IDs.
  8. System applies BSON $or filter: team_owner_ids empty (Unassigned) OR team_owner_ids: {$in: {teams + descendants}} OR owner_id == user OR assignee_id == user.
  9. Agent sees team-scoped + sub-team + Unassigned contacts on web and mobile.
  10. Agent opens a specific contact → same check applied on single-get (and on its notes — CHG-006).
  11. Failure branch: If Launchpad teams-for-user API unavailable (timeout > 500ms or 5xx) → fall back to OWNED ONLY; show banner; log cdp_team_permission_fallback.

Flow B — Team Owner field on contact create

  1. Agent opens "Create Contact."
  2. FE calls the teams-for-user API; the multi-select shows "My Teams."
  3. Team Owner auto-fills to ALL of the creator's teams; creator with no team → empty (Unassigned).
  4. Agent optionally adds/removes teams or clears all to Unassigned.
  5. On save, contact-service validates: every team_owner_ids element must be one of the user's teams (empty allowed). Stores the array.

📊 System Flow — TEAM ONLY Enforcement

sequenceDiagram
participant Admin
participant LP as Launchpad (permissions)
participant Agent
participant FE as customer-fe
participant CS as contact-service
participant LPT as Launchpad (teams-for-user API)
Admin->>LP: PUT /permissions/{name}/level {level: "team"}
LP-->>Admin: 200 saved
Agent->>FE: open Customers list
FE->>CS: GET /cdp/customers (IAG token)
CS->>LPT: GET /iag/v1/users/me/teams (+descendants, cached)
alt Launchpad unavailable
LPT-->>CS: timeout / 5xx
CS->>CS: fallback to own filter
CS-->>FE: own-scoped results
FE-->>Agent: banner "team filter temporarily unavailable"
else teams resolved
LPT-->>CS: teams + descendant team ids
CS->>CS: BSON $or: empty(Unassigned) OR team_owner_ids $in {teams+descendants} OR owner/assignee == user
CS-->>FE: filtered contact list
FE-->>Agent: team + sub-team + Unassigned contacts
end

Shared definition — access levels. TEAM ONLY: a contact is in scope if ANY holds — (a) team_owner_ids is empty/Unassigned (visible to all Team Only users); (b) team_owner_ids ∩ ({viewer's teams} ∪ {descendant teams of viewer's teams}) ≠ ∅; (c) owner_id == viewer; (d) assignee_id == viewer. OWNED ONLY: owner_id == viewer OR assignee_id == viewer. ALL ACCESS: company scope only. DISABLED: no access; direct URL/API → 403. NOTES (CHG-006): a note's scope = its parent contact's scope (so empty + hierarchy rules apply transitively).

10.2 User Stories

User StoryImportanceMockupTechnical NotesAcceptance Criteria
[TEAM-S01] — Team Only radio available on all CDP permission actions

As an Org Admin, I want every in-scope CDP permission action to expose a Team Only option (alongside Owned Only and All Access), so that I can scope any CDP customer/notes permission to team level.
Must HaveFigma: TBD — Launchpad Role & Permission settings (Team Only radio)Data Fields:
permission_name (string) — any in-scope CDP customer/notes action key
level (enum: own/team/everything)

Before-After Behavior: Before: CDP permission actions show only {Owned Only, All Access}. After: all in-scope CDP permission actions show {Owned Only, Team Only, All Access}; the chosen level is persisted to Launchpad.
— Happy Path —
• AC-1: Given the admin opens the CDP permission settings, when the page loads, then every in-scope CDP permission action displays the Team Only radio option — the four customer actions (customers_customers_view, customers_customers_manage, customers_customers_delete, customers_customers_searchassoc) and the three contact-notes actions (customers_customernotes_view, customers_customernotes_manage, customers_customernotes_delete) — each showing Owned Only | Team Only | All Access.
• AC-2: Given any of these actions, when the admin selects Team Only and saves, then PUT /permissions/{name}/level is called with level = "team" and the page shows Team Only as the current value on reload.
• AC-3: Given an action set to Team Only, when the admin selects Owned Only and saves, then the level updates to own.
• AC-4 (out of scope): Given a CDP permission not in TEAM ONLY scope (e.g. customers_customers_profileview / customers_customers_profilemanage — Non-Goal 7), when the settings render, then it continues to show only its existing options — no Team Only radio is added.

— Error / Unhappy Path —
• ERR-1: Given the admin saves Team Only, when Launchpad returns 5xx, then the save fails with inline error "Could not save permissions. Please try again." and the previous level remains active.

— Permission Model —
• CAN: Org Admin and Owner roles.
• CANNOT: CS/Sales Agent, Supervisor — no access to permission settings page.
• Unauthorized: Settings page not rendered; direct URL → 403.

— UI States —
• Loading: radio group skeleton while levels load.
• Empty: N/A (fixed action list).
• Error: inline "Could not save permissions. Please try again."
• Success: Team Only shown as the selected radio on save + reload.
[TEAM-S02] — Enforce customers_customers_view (Website)

As a System, I want to enforce customers_customers_view on web, so that contact visibility is correctly scoped by level.
Must HaveFigma: N/A — server-side enforcementData Fields:
customers_customers_view level (from crs_permissions)
team_owner_ids (array of UUIDs, on contact doc)

Before-After Behavior: Before: web supports OWNED ONLY and ALL ACCESS only. After: TEAM ONLY added — contact index + detail filtered server-side by team_owner_ids intersection (+ descendants + Unassigned).
— Happy Path —
• AC-1: Given customers_customers_view = team, when the user loads the Customer Index on web, then only contacts where team_owner_ids intersects the user's teams or their descendant teams, OR the contact is Unassigned, OR owner_id/assignee_id == user are returned.
• AC-2: Given view = own, when they load the Index, then only owner_id == user OR assignee_id == user contacts are returned.
• AC-3: Given view = everything, when they load the Index, then all company contacts are returned.
• AC-4: Given view = team and a contact opened by direct URL, when contact-service evaluates, then it is returned only if in team scope (intersect/descendant/Unassigned/owner/assignee); otherwise 403.
• AC-5: Given view = team and the user belongs to no team, when they load the Index, then they see Unassigned contacts plus contacts they own/are assigned to (no other-team leakage).
• AC-6 (hierarchy): Given the user belongs to a parent team and a contact's team_owner_ids includes a descendant team, when they load under view = team, then the contact is returned (downward traversal; child-team member does NOT see parent-team records).
• AC-7 (Unassigned): Given a contact has empty team_owner_ids, when any view = team user loads the list, then the contact is returned.

— Error / Unhappy Path —
• ERR-1: Given view = team, when the Launchpad teams-for-user API is unavailable (timeout > 500ms or 5xx), then contact-service falls back to OWNED ONLY, the agent sees banner "Showing only your contacts — team filter temporarily unavailable," and cdp_team_permission_fallback is logged with platform: web.

— Permission Model —
• CAN: Any active agent/supervisor with customers_customers_view ≠ disabled.
• CANNOT: Users with view = disabled.
• Unauthorized: Index entry point not rendered; direct URL to detail → 403.

— UI States —
• Loading: list skeleton.
• Empty: "No contacts in your team yet."
• Error: fallback banner + own-scoped results.
• Success: team-scoped list.
[TEAM-S03] — Enforce customers_customers_view (CRM Mobile App)

As a System, I want to enforce customers_customers_view on CRM Mobile, so that contact visibility is identical to web for the same role settings.
Should HaveFigma: N/A — server-side enforcement (mobile renders results)Data Fields: same as TEAM-S02 (view level; contact team_owner_ids).

Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — same server-side filter as web; DISABLED hides the customer-list tab.
— Happy Path —
• AC-1: Given view = team on mobile, when the user opens the Customer List screen, then only in-team-scope contacts are returned — identical server-side filter to web.
• AC-2: Given view = disabled, when the user navigates to the Customers menu, then the customer-list tab is hidden entirely.
• AC-3: Given view = disabled and the user opens a contact via deep link, when the app resolves the URL, then 403 is returned.

— Error / Unhappy Path —
• ERR-1: Given view = team, when the teams-for-user API is unavailable on mobile, then contact-service falls back to OWNED ONLY, the agent sees inline banner "Showing only your contacts — team filter temporarily unavailable," and cdp_team_permission_fallback is logged with platform: mobile.

— Permission Model —
• CAN: Active agents/supervisors with view ≠ disabled.
• CANNOT: Users with view = disabled.
• Unauthorized: Customer list tab hidden; deep link → 403.

— UI States —
• Loading: list skeleton.
• Empty: "No contacts in your team yet."
• Error: fallback banner.
• Success: team-scoped list.
[TEAM-S04] — Enforce customers_customers_manage (Website)

As a System, I want to enforce customers_customers_manage on web, so that agents can only edit contacts within their permitted scope.
Must HaveFigma: N/A — server-side enforcementData Fields: customers_customers_manage level; contact team_owner_ids; submitted team_owner_ids (on edit).

Before-After Behavior: Before: web supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — agents edit only contacts within team scope; edit entry on the Customer Details page.
— Happy Path —
• AC-1: Given manage = team, when the user opens a contact in team scope (intersect/descendant/Unassigned/owner/assignee), then the "Edit Customer" entry point is visible and the update action is permitted.
• AC-2: Given manage = team, when they attempt to edit an out-of-scope contact via the API, then 403 is returned.
• AC-3: Given manage = team editing the Team Owner field, when they submit a team_owner_ids array containing any team that is not one of their assigned teams, then the server rejects with 422 and the field reverts.

— Error / Unhappy Path —
• ERR-1: Given manage = disabled, when the agent views any contact, then all edit-related buttons are hidden and edit via OpenAPI returns 403.

— Permission Model —
• CAN: Users with manage = team or everything, within their scope.
• CANNOT: manage = disabled; team users for out-of-scope contacts.
• Unauthorized: Edit button not rendered; API → 403.

— UI States —
• Loading: edit form.
• Empty: N/A.
• Error: "Could not save. Please try again."
• Success: updated, toast shown.
[TEAM-S05] — Enforce customers_customers_manage (CRM Mobile App)

As a System, I want to enforce customers_customers_manage on CRM Mobile, so that edit access is scoped to the agent's team regardless of platform.
Should HaveFigma: N/A — server-side enforcementData Fields: same as TEAM-S04.

Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — edit entry via Customer index (⋮) and details (⋮).
— Happy Path —
• AC-1: Given manage = team on mobile, when the user opens a contact within team scope, then (⋮) shows "Edit" and the action is permitted.
• AC-2: Given manage = team, when they attempt to edit an out-of-scope contact via the API, then 403 is returned — same server enforcement as web.
• AC-3: Given manage = disabled, when the user views any contact, then all edit entry points (⋮ on index, ⋮ on details) are hidden.

— Permission Model —
• CAN: manage = team or everything, within scope.
• CANNOT: manage = disabled; team users for out-of-scope.
• Unauthorized: Edit option hidden; API → 403.

— UI States —
• Loading: edit form.
• Error: "Could not save."
• Success: toast confirmation.
[TEAM-S06] — Enforce customers_customers_delete (Website)

As a System, I want to enforce customers_customers_delete on web, so that agents can only delete contacts within their permitted scope.
Must HaveFigma: N/A — server-side enforcementData Fields: customers_customers_delete level; contact team_owner_ids.

Before-After Behavior: Before: web supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — delete entry points on Customer Index + Customer Details.
— Happy Path —
• AC-1: Given delete = team, when the user views a contact within team scope, then the Delete button is visible and delete succeeds.
• AC-2: Given delete = team, when they attempt to delete an out-of-scope contact via the API, then 403 is returned.
• AC-3: Given delete = disabled, when the user views any contact, then the Delete button is hidden (index + details) and delete via OpenAPI returns 403.

— Permission Model —
• CAN: delete = team or everything, within scope.
• CANNOT: delete = disabled; team users for out-of-scope.
• Unauthorized: Delete button not rendered; API → 403.

— UI States —
• Loading: confirmation modal.
• Error: "Could not delete."
• Success: contact removed, list refreshed.
[TEAM-S07] — Enforce customers_customers_delete (CRM Mobile App)

As a System, I want to enforce customers_customers_delete on CRM Mobile, so that delete access is scoped to the agent's team regardless of platform.
Should HaveFigma: N/A — server-side enforcementData Fields: same as TEAM-S06.

Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — delete entry via Customer Index (⋮) and Details (⋮).
— Happy Path —
• AC-1: Given delete = team on mobile, when the user views a contact within scope, then the Delete option in (⋮) is shown and the action succeeds.
• AC-2: Given delete = disabled, when the user views any contact on mobile, then Delete is hidden from both index (⋮) and details (⋮), and delete via API returns 403.

— Permission Model —
• CAN: delete = team or everything, within scope.
• CANNOT: delete = disabled; team out-of-scope.
• Unauthorized: Delete hidden; API → 403.

— UI States —
• Loading: confirmation modal.
• Error: "Could not delete."
• Success: contact removed.
[TEAM-S08] — Enforce customers_customers_searchassoc (Website)

As a System, I want to enforce customers_customers_searchassoc on web, so that contact-association search results are correctly scoped when creating/editing Deals, Tasks, Tickets, and Companies.
Must HaveFigma: N/A — server-side enforcementData Fields: customers_customers_searchassoc level; contact team_owner_ids.

Before-After Behavior: Before: web supports OWNED ONLY + ALL ACCESS across Deal/Task/Ticket/Company. After: TEAM ONLY added — association search returns only team-scoped contacts.
— Happy Path (applies to Deal, Task, Ticket, Company) —
• AC-1: Given searchassoc = team, when the user searches for a customer association (Create Deal, Edit Task, Create Ticket, Edit Company), then results return only contacts where team_owner_ids intersects the user's teams or descendant teams, OR the contact is Unassigned, OR owner_id/assignee_id == user.
• AC-2: Given searchassoc = disabled, when the user opens a create/edit form with a customer-association field, then the association search component is hidden and the "contact association" filter is removed from the module index.
• AC-3: Given searchassoc = disabled, when an attempt is made via OpenAPI to associate a contact, then 403 is returned.

— Permission Model —
• CAN: searchassoc = team or everything, within scope.
• CANNOT: searchassoc = disabled.
• Unauthorized: Association search component hidden; API → 403.

— UI States —
• Loading: typeahead spinner.
• Empty: "No contacts found in your team."
• Error: "Could not load contacts."
• Success: filtered results.
[TEAM-S09] — Enforce customers_customers_searchassoc (CRM Mobile App)

As a System, I want to enforce customers_customers_searchassoc on CRM Mobile, so that association results are scoped identically to web.
Should HaveFigma: N/A — server-side enforcementData Fields: same as TEAM-S08.

Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — association search in Deal/Task/Ticket contexts returns only team-scoped contacts.
— Happy Path —
• AC-1: Given searchassoc = team on mobile, when the user searches for a customer association in any module, then only team-scoped contacts are returned — same server-side filter as web.
• AC-2: Given searchassoc = disabled, when the user opens a create/edit form with customer association, then the association search component is hidden.

— Permission Model —
• CAN: searchassoc = team or everything, within scope.
• CANNOT: searchassoc = disabled.
• Unauthorized: Component hidden; API → 403.

— UI States —
• Loading / Empty / Error / Success.
[TEAM-S10] — Set Team Owner on a contact (Website) — MULTI-SELECT

As a CS Agent, I want to assign one or more Team Owners (or clear them) on a customer from the detail page, so that my teams' ownership is formally recorded and contacts can be co-owned and handed off collaboratively.
Must HaveFigma: Team Owner multi-select (MpInputTag) — detail / create / editData Fields:
team_owner_ids (array of UUIDs, may be empty) — the user's teams from the teams-for-user API; auto-fill = ALL of the creator's teams

Before-After Behavior: Before: no Team Owner field exists on the customer detail page. After: a "Team's Owner" multi-select appears; agent adds/removes any of their assigned teams or clears all to Unassigned.
— Happy Path —
• AC-1: Given the agent clicks the Team's Owner field, when the multi-select opens, then a flat "My Teams" list is shown (MpInputTag suggestions) with current value(s) pre-selected as chips and the ability to remove any chip.
• AC-2: Given a new contact is being created, when the form renders, then team_owner_ids auto-fills to ALL of the creator's teams; creator with no team → empty array (Unassigned).
• AC-3: Given the agent selects one or more teams and saves, when PATCH /cdp/customers/{id} is called with team_owner_ids, then the record is updated and the field displays team-name chips.
• AC-4: Given the agent removes all teams and saves, when submitted with team_owner_ids = [], then the field saves as an empty array and displays "Unassigned."
• AC-5: Given the agent submits a team_owner_ids array containing any team that is not one of their assigned teams, when the server validates, then the request is rejected with 422 and the field reverts.
• AC-6: Given a contact has no Team Owner set, when the agent views the detail page, then the field shows "Unassigned" (not blank).

— Error / Unhappy Path —
• ERR-1: Given the teams-for-user API is unavailable, when the multi-select opens, then it shows "Could not load teams. Try again." and saving Team Owner is blocked until teams load.
• ERR-2: Given the agent saves Team Owner, when PATCH returns 5xx, then the field reverts and a toast shows "Could not save Team Owner. Try again."

— Permission Model —
• CAN: Agents/supervisors with customers_customers_manage ≠ disabled on contacts within their scope.
• CANNOT: manage = disabled; users attempting to add a team they don't belong to.
• Unauthorized: Team Owner field is read-only for users without manage permission.

— UI States —
• Loading: spinner.
• Empty: "No teams assigned" + Unassigned state.
• Error: "Could not load teams."
• Success: team-name chips displayed.
[TEAM-S11] — Set Team Owner on a contact (CRM Mobile App) — MULTI-SELECT

As a CS Agent on Qontak One Mobile, I want to set or clear one or more Team Owners on a contact from my phone, so that team assignments don't require switching to desktop.
Should HaveFigma: Team Owner multi-select (mobile) — detail / createData Fields: same as TEAM-S10 (team_owner_ids array; auto-fill = ALL of the creator's teams).

Before-After Behavior: Before: no Team Owner field on the mobile contact detail screen. After: a Team Owner multi-select appears; agent selects one or more teams from a flat searchable list or clears all to Unassigned.
— Happy Path —
• AC-1: Given the agent opens the Team's Owner field on mobile, when the picker opens, then a flat searchable multi-select of their assigned teams is shown with current values pre-selected as chips.
• AC-2 / AC-3 / AC-4 / AC-5 / AC-6: Same auto-fill-all-teams, save, clear-to-Unassigned, per-element membership validation, and "Unassigned" display rules as TEAM-S10.
• AC-7: Given the mobile device is offline, when the agent opens the Team Owner picker, then it shows "Team list unavailable — check your connection."

— Permission Model — Same as TEAM-S10.

— UI States —
• Loading: spinner.
• Empty: Unassigned state.
• Error: offline / API error.
• Success: team-name chips shown.
[TEAM-S12] — Enforce customers_customernotes_view (Website)

As a System, I want to enforce customers_customernotes_view on web, so that note visibility follows the parent contact's team scope.
Must HaveFigma: N/A — server-side enforcementData Fields: customers_customernotes_view level; parent contact.team_owner_ids; viewer team IDs.

Before-After Behavior: Before: a contact's notes are visible to any company user who passes the binary view gate — no team scoping. After: TEAM ONLY added — a user sees a contact's notes only if they can see the parent contact under team scope.
— Happy Path —
• AC-1: Given customernotes_view = team and the user opens a contact whose team_owner_ids intersects their team IDs (or they own/are assigned it), when the Notes tab loads, then the contact's notes are returned.
• AC-2: Given customernotes_view = team and the user opens (by direct URL/API) a contact outside their team scope, when they request its notes, then 403 is returned — notes inherit the parent contact's scope.
• AC-3: Given customernotes_view = everything, when they load any contact's notes, then all notes on that contact are returned.
• AC-4: Given customernotes_view = own, when they load notes on a contact they neither own nor are assigned, then 403 is returned.

— Error / Unhappy Path —
• ERR-1: Given view = team, when the teams-for-user API is unavailable, then the notes request falls back to the same OWNED-ONLY rule as CHG-002 and logs cdp_team_permission_fallback.

— Permission Model —
• CAN: Users with customernotes_view ≠ disabled, for notes on in-scope contacts.
• CANNOT: disabled; team users for notes on out-of-scope contacts.
• Unauthorized: Notes tab not populated; direct notes API → 403.

— UI States —
• Loading: notes skeleton.
• Empty: "No notes yet."
• Error: "Could not load notes."
• Success: notes list.
[TEAM-S13] — Enforce customers_customernotes_view (CRM Mobile App)

As a System, I want to enforce customers_customernotes_view on mobile, so that note visibility matches web for the same role.
Should HaveFigma: N/A — server-side enforcementData Fields: same as TEAM-S12.

Before-After Behavior: Before: CDP notes (behind flag_contact_360 + flag_note_cdp) shown without team scoping. After: TEAM ONLY added — same server-side parent-contact scope as web.
— Happy Path —
• AC-1: Given customernotes_view = team on mobile, when the user opens a contact's Notes section, then notes are returned only if the parent contact is in their team scope — identical server-side rule to web.
• AC-2: Given the parent contact is out of team scope, when the mobile app requests its notes, then 403 is returned.

— Permission Model — Same as TEAM-S12.

— UI States —
• Loading: skeleton.
• Empty: "No notes yet."
• Error: inline error.
• Success: notes list.
[TEAM-S14] — Enforce customers_customernotes_manage (Website)

As a System, I want to enforce customers_customernotes_manage on web, so that note editing follows team scope.
Must HaveFigma: N/A — server-side enforcement (per-note permission.update flag)Data Fields: customers_customernotes_manage level; parent contact.team_owner_ids; per-note permission.update.

Before-After Behavior: Before: note edit is owner-only regardless of permission level. After: TEAM ONLY added — a team/everything user can edit notes on in-scope contacts; the API computes per-note permission.update.
— Happy Path —
• AC-1: Given customernotes_manage = team and a note on a contact in the user's team scope, when the API returns the note, then note.permission.update = true and the Edit action is available (NotesList.vue reads this flag).
• AC-2: Given manage = team and a note on a contact outside team scope, when they attempt to edit via the API, then 403 is returned and note.permission.update = false.
• AC-3: Given manage = own, when the user edits a note on a contact they don't own/aren't assigned, then 403 is returned.

— Error / Unhappy Path —
• ERR-1: Given manage = disabled, when viewing any note, then edit is hidden and edit via API → 403.

— Permission Model —
• CAN: manage = team/everything on in-scope contacts (broadened from owner-only — see OQ-11).
• CANNOT: disabled; team on out-of-scope contacts.
• Unauthorized: Edit hidden (server sets note.permission.update = false); API → 403.

— UI States —
• Loading: editor.
• Error: "Could not save note."
• Success: note updated.
[TEAM-S15] — Enforce customers_customernotes_manage (CRM Mobile App)

As a System, I want to enforce customers_customernotes_manage on mobile, so that note editing matches web.
Should HaveFigma: N/A — server-side enforcementData Fields: same as TEAM-S14.

Before-After Behavior: Before: edit gated by the contact-level permission.update flag; per-note permission parsed but ignored. After: TEAM ONLY added — mobile reads each note's note.permission.update (computed by API) instead of the contact-level flag.
— Happy Path —
• AC-1: Given manage = team on mobile and a note on an in-scope contact, when the note list/detail renders, then the Edit/Save action is gated by note.permission.update (not the contact-level hasEditPermission).
• AC-2: Given a note on an out-of-scope contact, when the user attempts to edit via the API, then 403 is returned.

— Permission Model — Same as TEAM-S14.

— UI States —
• Loading: editor.
• Error: "Could not save."
• Success: note updated.
[TEAM-S16] — Enforce customers_customernotes_delete (Website)

As a System, I want to enforce customers_customernotes_delete on web, so that note deletion follows team scope.
Must HaveFigma: N/A — server-side enforcement (per-note permission.delete flag)Data Fields: customers_customernotes_delete level; parent contact.team_owner_ids; per-note permission.delete.

Before-After Behavior: Before: note delete is owner-only regardless of level. After: TEAM ONLY added — team/everything users can delete notes on in-scope contacts; API computes per-note permission.delete.
— Happy Path —
• AC-1: Given customernotes_delete = team and a note on an in-scope contact, when the API returns the note, then note.permission.delete = true and the Delete action is available.
• AC-2: Given delete = team and a note on an out-of-scope contact, when they attempt to delete via the API, then 403 is returned and note.permission.delete = false.
• AC-3: Given delete = disabled, when viewing any note, then Delete is hidden and delete via API → 403.

— Permission Model —
• CAN: delete = team/everything on in-scope contacts.
• CANNOT: disabled; team on out-of-scope contacts.
• Unauthorized: Delete hidden; API → 403.

— UI States —
• Loading: confirmation modal.
• Error: "Could not delete note."
• Success: note removed.
[TEAM-S17] — Enforce customers_customernotes_delete (CRM Mobile App)

As a System, I want to enforce customers_customernotes_delete on mobile, so that note deletion matches web.
Should HaveFigma: N/A — server-side enforcementData Fields: same as TEAM-S16.

Before-After Behavior: Before: delete gated by contact-level flag; per-note permission ignored. After: TEAM ONLY added — mobile reads note.permission.delete.
— Happy Path —
• AC-1: Given delete = team on mobile and a note on an in-scope contact, when the note renders, then the Delete action is gated by note.permission.delete.
• AC-2: Given a note on an out-of-scope contact, when the user attempts to delete via the API, then 403 is returned.

— Permission Model — Same as TEAM-S16.

— UI States —
• Loading: confirmation modal.
• Error: "Could not delete."
• Success: note removed.
[TEAM-S18] — Team Owner auto-syncs to the new owner's teams when contact ownership changes

As a System, when a contact's owner_id is reassigned to a different user, I want team_owner_ids to automatically re-derive to the new owner's teams, so that team ownership stays consistent with who owns the contact (web + mobile + API).
Must HaveFigma: N/A — server-side derivation (Team Owner chips re-render with the new value)Data Fields:
owner_id (the contact's owner being changed)
team_owner_ids (re-derived = ALL teams of the new owner, from the teams-for-user API)

Before-After Behavior: Before: team_owner_ids was set at create (auto-fill = creator's teams) and only changed by manual edit — an owner change left the old team owners in place. After: changing the owner re-derives team_owner_ids to the new owner's full team set (mirrors the create-time auto-fill, D-4); the previous value is replaced.
— Happy Path —
• AC-1: Given customer X has owner_id = Zhelia and team_owner_ids = {Product Management, Qontak Product} (Zhelia ∈ {Product Management, Qontak Product}), when Zhelia changes the owner of X to Rara (Rara ∈ {Product Management, Qontak Designer}), then team_owner_ids is automatically set to {Product Management, Qontak Designer} (Rara's teams) — replacing the prior value.
• AC-2: Given an owner change to a user who belongs to no team, when the owner is saved, then team_owner_ids becomes empty (Unassigned).
• AC-3: Given the owner is changed and then changed again, when each save commits, then team_owner_ids always reflects the current owner's teams.
• AC-4: Given the re-derivation completes, then cdp_team_owner_set is emitted with source = owner_change (distinct from manual edit / migration).
• AC-5 (scope-shift side effect): Given X's team_owner_ids changes from {Product Management, Qontak Product} to {Product Management, Qontak Designer}, when a Qontak-Product-only member (not owner/assignee) next loads the list under TEAM ONLY, then X no longer appears for them; Qontak-Designer members now see it.

— Error / Unhappy Path —
• ERR-1: Given the owner change is saved, when the teams-for-user lookup for the new owner is unavailable (timeout/5xx), then the owner change still commits but the team_owner_ids re-derivation is retried async (it is not silently left as the prior owner's teams); cdp_team_permission_fallback is logged with source = owner_change. (Retry-vs-defer policy — OQ-2.)

— Permission Model —
• CAN: any user permitted to change the contact's owner (customers_customers_manage within scope). The team-owner re-derivation is system-driven, not a manual field edit.
• CANNOT: a manual team_owner_ids value does not survive an owner change — the owner-change derivation takes precedence (OQ-2 edge).

— UI States —
• Loading: owner-field saving spinner.
• Success: Team Owner chips update to the new owner's teams.
• Error: owner saved; Team Owner shows the prior value until re-derivation completes/retries.
[TEAM-S19-NEG] — TEAM ONLY guard rails (no leakage / correct hierarchy + Unassigned) (Guard Rail — from Non-Goals)

As the System, I must not leak cross-team contacts, must traverse hierarchy downward only, and must treat Unassigned as visible-to-all.
Guard Rail• NEG-1: Given Zhelia ∈ {Product Management, Qontak Product}, when she creates a contact, then team_owner_ids auto-fills to both teams; and on the TEAM ONLY list she sees every contact whose team_owner_ids includes either team or any descendant team, plus Unassigned, plus her own owned/assigned — contacts owned only by unrelated teams do not appear (web + mobile).
• NEG-2: Given view = team and the user belongs to no team, when they load the list, then they see Unassigned + their own owned/assigned records only (no other-team leakage); direct URL to a team-owned contact they don't qualify for → 403.
• NEG-3: Given a TEAM ONLY user whose teams (and sub-teams) own no contacts and there are no Unassigned contacts, when they load the list, then "No contacts in your team yet." is shown — not an error.
• NEG-4: Given a user is removed from a team in Launchpad, when they next load the list (real-time resolution, no long-term cache), then they no longer see that team's contacts.
• NEG-5: Given view = own and a user opens a teammate's contact by direct URL, then 403 — TEAM ONLY is not implied by OWNED ONLY.
• NEG-6: Given manage = team and a team_owner_ids array including a team they don't belong to via OpenAPI, then 422 (per-element validation).
• NEG-7: Given a note on a contact owned only by a team Zhelia does NOT belong to, when Zhelia (customernotes_view = team) opens that contact's notes by direct URL/API, then 403 — notes never widen their parent contact's scope (web + mobile).
• NEG-8 (hierarchy): Given a manager in a parent team and a contact owned only by a descendant team, when the manager loads under view = team, then the contact appears (downward traversal); but a user in only the child team does not see a parent-team-owned contact (no upward traversal) unless owner/assignee.
• NEG-9 (Unassigned): Given a contact has empty team_owner_ids, when any TEAM ONLY user (including teamless) loads the list, then the contact appears — Unassigned is visible to all (D-13).

Dependencies: TEAM-S01 → CHG-001 (Launchpad settings UI), applied to all in-scope CDP customer + notes permission keys; TEAM-S02–S09 → CHG-002 (enforcement) + the teams-for-user(+descendants) API; TEAM-S10–S11 → CHG-003 (Team Owner field) + per-element validation; TEAM-S12–S17 → CHG-006 (notes scope) + the extended EvaluatePermissions (D-10); TEAM-S18 (owner-change auto-sync) → CHG-003 persistence rule + the teams-for-user API (re-derive the new owner's teams). Mobile stories (S03/S05/S07/S09/S11/S13/S15/S17) reuse the same server-side filter as their web counterparts.


11. Rollout

FieldDetail
Flagcdp_team_permission_enabled | default: OFF — dedicated per-company flag (not GetIsPermissionUsmanEnabled).
Stage 0Schema + multikey index added (additive, no behaviour change). team_owner_ids written on new records; reads ignore it.
Stage 1Migration of existing contacts (CHG-004) on internal QA tenant; verify coverage and Unassigned counts. Migrated records carry multi-element arrays — split from the legacy crm_team_hierarchy_id CSV (see CHG-004).
Stage 2Enable TEAM ONLY enforcement behind the flag on QA, then beta tenants. Monitor denial/fallback logs.
GAProgressive per-tenant enable for Growth + Enterprise.
Migration ownerDecision needed (OQ-1): CDP backfill job vs Bifrost sync (Postgres crm_people.crm_team_hierarchy_id CSV → split(',') → name-map → Mongo team_owner_ids multi-element array).

11.1 Migration Transition Window

During Stage 1–2, migrated and not-yet-migrated contacts coexist. A contact not yet carrying team_owner_ids is treated as Unassigned (empty) → visible to all Team Only users (no contact is hidden by migration lag). Enforcement (Stage 2) is only enabled per tenant after the CHG-004 coverage report confirms ≥95% coverage; until then the tenant stays on its current level behaviour behind the OFF flag.


12. Observability

Events (emitted by contact-service during TEAM ONLY enforcement):

EventTriggerPropertiesAlert
cdp_team_permission_fallbackLaunchpad teams-for-user API times out (>500ms) or returns 5xx → request falls back to OWNED ONLYcompany_sso_id, user_sso_id, platform (web/mobile), permission_name, latency_ms> 1% of team-level requests in any 5-min window → page CDP on-call
cdp_team_permission_deniedSingle-get / single-record action where viewerTeamIDs ∩ contact.team_owner_ids = ∅ and user is not owner/assignee → 403company_sso_id, user_sso_id, contact_id, permission_name, viewer_team_countSpike > 3× 7-day baseline → notify CDP Squad (possible mis-scoped role)
cdp_team_owner_validation_rejectedWrite rejected (422) because a submitted team_owner_ids element is not one of the user's teamscompany_sso_id, user_sso_id, rejected_team_ids— (info only; informs UX)
cdp_team_owner_setteam_owner_ids successfully written on create/edit or auto-re-derived on owner change (TEAM-S18)company_sso_id, contact_id, team_owner_count, source (web/mobile/openapi/migration/owner_change)
FieldDetail
Dashboard ownerCDP Squad (eng on-call rotation). Dashboard tracks fallback rate, denial rate, validation-rejection rate, and coverage (% contacts with non-empty team_owner_ids).
Monitoring cadenceWeekly review for the first month post-GA, then monthly. Investigate if fallback rate > 1% sustained over 24h, or if coverage drops below the 95% target after migration.

13. Success Metrics

MetricDefinitionBaselineTarget
⭐ Adoption — TEAM ONLY configured% of Growth+Enterprise tenants with ≥1 action set to team within 60 daysN/A≥ 30% of beta tenants within 30 days of GA
Correctness — cross-team leakageAudited cross-team records visible under TEAM ONLYN/A0 confirmed leaks
Performance — list latencyP95 contact-list load under TEAM ONLYCapture everything P95 before build≤ 2s P95, ≤ 20% over baseline
Reliability — permission errors% permission-check calls returning 5xx under TEAM ONLYN/A≤ 0.5% within 30 days of GA
Data quality — Team Owner coverage% contacts with non-empty team_owner_ids post-migration0%≥ 95%

14. Launch Plan & Stage Gates

StageAudienceDurationSuccess GateOwner
Stage 0 — Schema (additive)Engineering only1 sprintteam_owner_ids field + compound multikey index (company_sso_id, is_deleted, team_owner_ids) live (migration 032); new records write the field; reads ignore it; zero behaviour change for existing levels.CDP Eng
Stage 1 — Migration (QA tenant)Internal QA tenant1 weekCHG-004 backfill run; coverage report shows ≥95% contacts with non-empty team_owner_ids; Unassigned + unmatched-team counts reviewed; multi-element arrays verified from crm_team_hierarchy_id CSV.CDP Eng + Platform
Stage 2 — Enforcement (QA → beta)QA tenant, then 3–5 beta tenants (Growth/Enterprise)2 weeksTEAM ONLY enforced behind cdp_team_permission_enabled; 0 confirmed cross-team leaks in audit; fallback rate < 1%; P95 list latency ≤ 2s; existing team-level roles audited + comms sent before enable.PM + CDP Eng
GAProgressive per-tenant (Growth + Enterprise)OngoingPer-tenant enable only after that tenant's migration coverage ≥95% and audit clean; permission-error rate ≤ 0.5%.PM + Ops

15. Dependencies

DependencyOwnerDeliverableBlocking?
"Teams for a user (+ descendants)" API (NEW — does not exist today)LaunchpadNew endpoint (e.g. GET /iag/v1/users/me/teams) returning the teams the user belongs to and their descendant (child) team IDs (hierarchy traversal): [{id, name, parent_id}] or an expanded_team_ids set. Net-new query (ListTeamsByUserID) joined with hierarchy expansion — Launchpad already has teams.parent_id (schema.sql:180-187) and traversal queries (ListTeamHierarchy, TraverseTeamLeaf, TraverseTeamRoot, TeamIsParent), so descendants can be resolved server-side. Must return empty array (not error) for teamless users. Decision (OQ-3a): expand descendants in Launchpad vs in contact-service.YES
CHG-001 Role & Permission settings UI (Launchpad-owned — NEW)Launchpad Squad (or Admin Portal Squad — confirm)Expose team as a selectable radio option for the four CDP customer permission actions. The level-update endpoint is PUT /users/{sso_id}/permissions/{permission_name}/level (Launchpad rest_router.go:172) — not customer-fe. Confirm the exact settings UI codebase.YES
crs_permissions provides the per-action levelLaunchpadAlready exists; carries no team data.YES
Legacy → new team name-mapping referenceLaunchpad + CDPCrosswalk from legacy team integer IDs (from the crm_team_hierarchy_id CSV) to Launchpad team UUIDs by team name within company (see CHG-004).YES (migration)
team_owner_ids field + multikey index + 4-file schema changeCDP BackendSee §7 Constraints for the 4 files. Compound multikey index (company_sso_id, is_deleted, team_owner_ids).YES
Qontak One Mobile — CDP moduleMobile SquadSame server-side filter + render Team Owner multi-select field.YES
Primary Team in Launchpad (is_primary)LaunchpadNo longer required. Auto-fill now selects ALL of the user's teams, so there is no need to disambiguate a single primary team. (Was a v2.2 future dependency; dropped in v2.3.)No

📊 Dependency Graph

graph LR
F[Team Owner + TEAM ONLY] -->|BLOCKING| LPT[Launchpad teams-for-user +descendants API]
F -->|BLOCKING| CFG[Launchpad Role and Permission settings UI - team radio]
F -->|BLOCKING| SCH[contact-service: team_owner_ids + multikey index + 4-file change]
F -->|BLOCKING - migration| MAP[Legacy team ID to Launchpad UUID name-map]
F -->|BLOCKING| MOB[mobile-qontak-crm: filter + Team Owner field]
F -.->|non-blocking| CRS[crs_permissions per-action level - exists]

16. Key Decisions + Alternatives Rejected

16a — Decisions Made

Decisions re-grounded 2026-06-12 (multi-select change); earlier decisions dated 2026-06-03.

IDDecisionRationale (grounded)
D-1Filter on the stored team_owner_ids, not on live membership resolution.Mirrors proven legacy CRM design (search_parameter.rb:2453-2467). Single indexed multikey $in; stable when a team's members change.
D-2TEAM ONLY = team_owner_ids empty (Unassigned → visible to all) OR team_owner_ids ∩ ({viewer's teams} ∪ {descendant teams}) ≠ ∅ OR viewer is owner_id/assignee_id.v2.5. Mirrors legacy search_parameter.rb:2453-2468 exactly: crm_team_ids << nil (empty → visible to all) + crm_teams.map(&:descendant_ids) (downward hierarchy). Multi-team many-to-many in Launchpad (models.go:191) + hierarchy via teams.parent_id. OR owner/assignee == self ensures a user always sees their own records.
D-3Team Owner is an editable, MULTI-SELECT field (team_owner_ids array); can be emptied to Unassigned.v2.3 change. A contact may be collaboratively owned by several teams (e.g. Sales + CS). Rendered with MpInputTag (the Pixel multi-tag component), forking the existing MultipleSelect.vue.
D-4Auto-fill = ALL of the creator's teams (every team the user belongs to). Creator with no team → empty array (Unassigned).v2.3 change. Matches the requested behaviour: "Zhelia ∈ {PM Qontak, Product Management} → both auto-fill." Removes the v2.2 earliest-created_at/tie-break rule entirely — taking all teams needs no ordering or primary-team disambiguation. The same derivation also runs when a contact's owner_id changesteam_owner_ids re-syncs to the new owner's full team set (TEAM-S18).
D-5Creator with no team → Team Owner = empty array (Unassigned).Teamless users are real: TeamIDs not required (user_request.go:20), SSO-invite/migrate flows assign no team.
D-6Migrate from crm_people.crm_team_hierarchy_id (the user-editable multi-team owner list — a CSV of team IDs) → team_owner_ids array via split(',').Corrected (v2.5). Production grounding proved crm_team_hierarchy_id is the actual editable multi-team "Team's Owner" (form edit.html.erb:1202 multiple: true; permitted as array leads_controller.rb:1844; written CSV "3,7" at leads_controller.rb:1261-1263; read via crm_team_hierarchy person.rb:234-245). It maps directly to the new team_owner_ids array (multi-element, no longer single). The scalar crm_team_id is only the creator's auto-stamped home team (set_crm_team_id) — NOT the owner field; do not migrate from it.
D-7Hierarchy traversal IS supported (downward). A viewer sees records owned by their teams and descendant (child) teams; a child-team member does not see parent-team records.Reversed in v2.5. Grounded in legacy descendant_ids traversal (search_parameter.rb:2465) + Launchpad hierarchy (teams.parent_id schema.sql:180-187; ListTeamHierarchy/TraverseTeamLeaf). Resolves OQ-3.
D-13Empty team_owner_ids (Unassigned) is visible to ALL Team Only users.v2.5. Matches legacy crm_team_ids << nil (search_parameter.rb:2463) — unassigned records are not orphaned/hidden. Resolves OQ-4 (chosen: visible to all).
D-8Use dedicated flag cdp_team_permission_enabled, not GetIsPermissionUsmanEnabled.GetIsPermissionUsmanEnabled is company-wide (all 12 permissions, require_permission_middleware.go:21); reusing it changes behavior for tenants with it already ON.
D-9CHG-001 is Launchpad-owned, not customer-fe.Level-update endpoint is PUT /users/{sso_id}/permissions/{permission_name}/level in Launchpad (rest_router.go:172). No permission settings UI exists in customer-fe.
D-10EvaluatePermissions signature must be extended to accept teamOwnerIDs []string and viewerTeamIDs []string.Current signature (permission_service.go:41-46) has no team params. Per-record TEAM ONLY evaluation (single-get guard) requires the record's owning teams and the viewer's teams to compute array intersection. BREAKING CHANGE affecting all callers.
D-11Multi-select options = the user's OWN teams (from the new teams-for-user API), not all company teams.The existing FE getTeams() hits GET /v1/teams (all company teams, capped per_page=10) — wrong source. A user may only assign teams they belong to (validation rule), so the picker must be sourced from the user's teams. See CHG-007 (FE-1).

16b — Alternatives Rejected

Rejections dated 2026-06-03, with v2.3 additions 2026-06-12.

AlternativeWhy Rejected
Single-select Team Owner (v2.2 model)Cannot express collaborative cross-team ownership. Requested behaviour is multi-team. Replaced by multi-select team_owner_ids array (D-3).
Auto-fill earliest team / by "Primary Team"Superseded: auto-fill now takes ALL teams (D-4), so neither created_at ordering nor an is_primary flag is needed. No is_primary exists in Launchpad team_users anyway.
Filter by live owner/assignee/created_by team membership1+N Launchpad calls per request; volatile key; contacts vanish when membership changes. Contradicts proven legacy design.
Hierarchical/ancestor pickerAncestors ≠ user's teams. Real UI scopes to flat team memberships.
Read-only auto-derived Team Owner (legacy model)Production UI is editable; users pick among their teams.
Source picker from getTeams() (all company teams)Returns all company teams capped at per_page=10; not the user's teams. A user can only assign their own teams. Use the new teams-for-user API (D-11).
Client-side filteringBypassable; not a security boundary.
Reuse GetIsPermissionUsmanEnabledCompany-wide; would affect all 12 permissions for tenants with it already ON.
Migrate from crm_team_id (single → single-element array)Reversed in v2.5. crm_team_id is only the creator's auto-stamped home team, not the owner field. The real multi-team owner is crm_team_hierarchy_id (CSV) — see D-6.

17. Open Questions

#TypeQuestionOwnerDeadline
OQ-1DecisionMigration ownership & mechanism: CDP backfill job vs Bifrost sync? (Maps crm_team_hierarchy_id CSV → multi-element team_owner_ids — see CHG-004/D-6.)CDP Eng + Platform2026-06-17
OQ-2DecisionAuto-fill freshness: team_owner_ids now re-derives to the new owner's teams on owner change (TEAM-S18 — resolved). Still open: (a) should it also re-sync when the current owner later joins/leaves teams without an owner change? (Proposed: no — snapshot otherwise.) (b) On owner change, if the new owner's teams-for-user lookup fails — retry async vs defer? (Proposed: retry async; never silently keep the prior owner's teams.)PM + CDP Eng2026-06-17
OQ-3✅ Resolved (v2.5)TEAM ONLY does traverse team hierarchy (downward — viewer sees descendant/child-team records). See D-7.PMResolved
OQ-3aDecisionResolve descendant team IDs in Launchpad (return expanded set from the teams-for-user API) or in contact-service (call hierarchy + expand)? Affects caching + API contract.CDP Eng + Launchpad2026-06-17
OQ-4✅ Resolved (v2.5)Empty team_owner_ids (Unassigned) → visible to all Team Only users (legacy-aligned via crm_team_ids << nil). See D-13.PMResolved
OQ-5DecisionSource of truth: CDP customers_customers_view vs legacy crm_view? Same contact in both systems may yield different visibility.PM + Architecture2026-06-17
OQ-6Scope(Partially resolved v2.4) customers_customernotes_* are now IN scope (CHG-006, Stories 12–17). Still open: do customers_customers_profileview / profilemanage need TEAM ONLY?PM2026-06-17
OQ-7BehaviourCan a TEAM ONLY user with manage add a team_owner_id for a team they don't belong to? (Proposed: no — every element must be one of the user's teams.)PM2026-06-17
OQ-8DecisionShould cdp_team_permission_enabled be a single per-tenant boolean, or per-permission-key (so view can be staged before manage)?CDP Eng + PM2026-06-17
OQ-9BehaviourIs there a max cap on the number of team owners per contact? (Proposed: cap at a sane limit, e.g. the user's team count; no hard product cap in v1.)PM2026-06-17
OQ-10ScopeNote creation (customers_customernotes_add) is governed separately from view/manage/delete. Does it need explicit TEAM ONLY, or does the parent-contact visibility gate suffice (you cannot open a contact you can't see)? (Proposed: parent-contact gate suffices.)PM + CDP Eng2026-06-17
OQ-11BehaviourNote update/delete are owner-only today regardless of level. Under team/everything, should a team/everything user be able to edit/delete others' notes on an in-scope contact? (Proposed: yes — consistent with contact-level scoping.)PM2026-06-17

Appendix A — Grounded Code References

A. contact-service (Go, MongoDB)

  • Permission levels disabled/own/team/everythinginternal/pkg/consts/const.go:40-42.
  • team currently = full access (return true, no scoping) — internal/app/service/permission_service.go:86-89.
  • EvaluatePermissions current signature (needs teamOwnerIDs []string + viewerTeamIDs []string) — permission_service.go:41-46.
  • 6 handler branches check only OwnerPermissionKeycontact_handler.go:~256,466,543,648,760,956.
  • Single-get guard checks company only — internal/app/service/get_contact.go:22-26.
  • GetIsPermissionUsmanEnabled is company-wide — internal/pkg/middleware/require_permission_middleware.go:21.
  • team_owner* absent repo-wide (net-new field). Array-field precedent for declaration: Tags []string \bson:"tags,omitempty"`contact/base.go:75`.
  • Multikey $in precedent (array field intersects a list): segmented_filter_request.go:211, field_properties_search.go:66. Not yet present in search_contact_request.go — its ToFilters() (lines 119-248) explodes array fields into $or equality (lines 138-157), so the new team_owner_ids DTO field needs bson:"-" plus an explicit $in branch.
  • Multikey index needs no special syntax — declare like a scalar index; Mongo makes it multikey automatically (precedent: phone/name_search/name_tokenized arrays indexed plainly in 011, 028). A compound index may contain at most one array field (company_sso_id, is_deleted are scalar — OK). Next migration number: 032.
  • Notes permission keys: customers_customernotes_add/view/manage/deleteconst.go:34-37. ContactNote has only contact_id, owner_id, note, company_sso_id (no team_owner_ids) — contact_notes/base.go:26-36; notes scope via the parent contact.
  • Notes routes: rest_router.go:150-158 (canonical /contacts/{contact_id}/notes) + deprecated duplicate 161-168 — both share handlers. Middleware (require_permission_middleware.go:78-98) is a binary gate only; stores the level but enforces no own/team/everything scoping.
  • Notes enforcement gap: handler does NOT call EvaluatePermissions; resolveNotePermission (contact_notes_handler.go:143-166) sets cosmetic per-note Update/Delete flags honoring only everything/ownteam is dropped. contact_notes_service.go list/get filter is company+contact only (no team); update/delete are owner-only. ValidateContactExists (contact_notes/read.go) returns a bool — no scope fields, so a new parent-contact projection is required.

B. qontak-customer-fe (Nuxt 3)

  • Correction (v2.3): getTeams() is NOT dead code — it is called in features/customers/detail/views/DetailPage.vue:139 (onMounted). It hits GET /v1/teams?order_by=name&order_direction=asc&page=1&per_page=10 (IAG_LAUNCHPAD_URL) → all company teams, capped at 10 — wrong source for "the user's teams." UserStore.ts:62-81.
  • Multi-select component: MpInputTag (from @mekari/pixel3). MpSelect is single-value (no multiple prop). Real usage: features/customers/views/components/segment/FilterMultiselect.vue:3-13.
  • Strongest fork target: common/components/field/MultipleSelect.vue (existing multi-select on MpInputTag, already reads a store). Note: it emits selected names (:147-148), not IDs — the Team Owner fork must emit item.id.
  • Correction (v2.3): active detail form is features/customers/detail/components/CustomerDetails.vue (dynamic component map, field block ~106-127), not the legacy Profile.vue. multiple_select field_type already supported in the form pipeline (CustomerDetails.vue:835-836,868).
  • Contact type: add team_owner_ids?: string[] after assignee_name (CustomerStore.ts:304).
  • FE reads perm?.is_enabled only, never perm?.levelListPage.vue:531-532; DetailPage.vue:130-131.
  • filterByRoute drives list scope by URL path — ListPage.vue:190,367; no /customers/teams route exists.
  • Notes (web): components in features/customers/detail/components/Notes/ (Notes.vue, NoteInput.vue, NotesList.vue). Only customers_customernotes_add/_view constants exist (Notes/constants.ts:27-28) — no _manage/_delete. canViewNotes (CustomerActivityV2.vue:56-70) and canAddNotes (Notes.vue:83-97) read is_enabled only (same gap as CHG-007 FE-3). Per-note edit/delete already render server-computed note.permission.update/.delete (NotesList.vue:91-101) — the backend-authorized enforcement seam. Notes APIs: GET /v1/contacts/{id}/notes, POST /v1/contacts/notes/{id}, PUT/DELETE /v1/contacts/notes/{id}/{noteId}.

C. qontak.com legacy CRM (Rails) — reference model (corrected v2.5)

  • crm_team_hierarchy_id is the real "Team's Owner" — an editable MULTI-team list stored as a CSV of team IDs (e.g. "3,7"), NOT an ancestor chain. Form: f.input :crm_team_hierarchy_id, multiple: true (edit.html.erb:1202, label "Team's Owner" edit.html.erb:1196 / en.yml:2448); options = current_user.crm_teams (crm_referencable.rb:437-444); permitted as array (leads_controller.rb:1844); array→CSV on save (leads_controller.rb:1261-1263, :484-485); read via crm_team_hierarchy split (person.rb:234-245), names via crm_team_hierarchy_names (person.rb:890-895). → This is the migration source (D-6/CHG-004).
  • crm_team_id = single integer FK, auto-stamped from the creator's primary team on every save (before_save :set_crm_team_idcreator&.crm_team&.id, person.rb:19,734-736; User#crm_team user.rb:373-382). It is not the owner field and not user-editable (not in lead_params, leads_controller.rb:1849) — do not migrate from it.
  • "Team Only" filter (search_parameter.rb:2453-2468): matches crm_team_hierarchy_id against [nil] + user.crm_teams.ids + user.crm_teams.descendant_ids → confirms (a) empty/nil visible to all (crm_team_ids << nil, :2463) and (b) downward hierarchy traversal (descendant_ids, :2465). This is the exact behavior v2.5 replicates (D-2/D-7/D-13).

D. qontak-launchpad (Go, Postgres)

  • team_users many-to-many, no is_primary; created_at NOT NULL DEFAULT NOW()models.go:191-197, db/schema.sql:189-195. Unique index (user_id, team_id)schema.sql:283.
  • Permission level update endpoint — internal/server/rest_router.go:172: PUT /users/{sso_id}/permissions/{permission_name}/level.
  • No "teams for a user" endpoint and no ListTeamsByUserID query — every path is teams→members (ListTeamMember, db/query/team.sql:128-140), never user→teams. /users/me (UserInfoResponse) does not embed teams. Net-new: new query + service + handler + route. Because there is no is_primary, the join naturally returns all of the user's teams (no ordering/primary needed) — exactly what the new auto-fill requires.
  • Team hierarchy EXISTSteams.parent_id UUID (db/schema.sql:180-187) + traversal queries ListTeamHierarchy, TraverseTeamLeaf, TraverseTeamRoot, TeamIsParent. So the teams-for-user(+descendants) API (v2.5 D-7) can expand a user's teams to their descendant team IDs server-side. (Note: legacy Crm::Team has_ancestry; Launchpad uses parent_id adjacency + the above traversal queries.)

E. mobile-qontak-crm (Flutter / Dart) — notes on mobile

  • Flutter, Melos monorepo, Clean Architecture + BLoC. CDP notes live in the crm_note package behind feature flags flag_contact_360 + flag_note_cdp (crm_core/.../feature_flag_constant.dart:52,115); CDP path = note_cdp_*.
  • CDP API returns per-note permission: {update, delete} → parsed into NotePermission (note_cdp_data_response.dart:43-52, note_permission.dart) — but the UI ignores it, gating add/edit/delete on the contact-level permission.update flag (other_tab.dart:108; note_screen.dart:290; detail_note_screen.dart:629).
  • customers_customernotes_* keys are ABSENT on mobile; mobile reads no permission level. Endpoints are the same contact-service /v1/contacts/{id}/notes[/{noteId}] as web (crm_note/.../endpoint.dart).
  • Fix (CHG-008 MB-1): read each note's note.permission.update/.delete (already parsed) instead of the contact-level flag. If the API computes TEAM ONLY into those booleans (CHG-006), no level enum is needed on mobile.

PRD CHANGELOG

VersionDateBySectionTypeSummary
2.72026-06-30Story revisions — Team Only radio on all CDP actions + owner-change team-owner sync§3 Example, §8 CHG-003, §9 API#5, §10 (TEAM-S01 + new TEAM-S18, guard rail → S19-NEG), §12 Observability, §16 D-4, §17 OQ-2UPDATEDTEAM-S01 refocused: every in-scope CDP permission action (4 customer + 3 notes) must expose the Team Only radio; AC-4 confirms out-of-scope permissions (profileview/profilemanage) are left untouched. Added TEAM-S18 — when a contact's owner_id changes, team_owner_ids auto-re-derives to the new owner's full team set (e.g. owner Zhelia {Product Management, Qontak Product} → Rara → team owner becomes {Product Management, Qontak Designer}); guard rail renumbered S18-NEG → S19-NEG. Threaded through CHG-003 persistence rule, D-4, API behavior #5 + Observability (source = owner_change), OQ-2 (owner-change resolved; current-owner membership drift + lookup-failure retry still open), and the §3 Example (Case 3). Also restored the Figma frame links on the TEAM-S10 (web — detail/create/edit) and TEAM-S11 (mobile — detail/create) Mockup cells.
2.62026-06-30Reformat to canonical templateAll (structure only)REFORMATTEDRestructured to the canonical Qontak ADJUSTMENT template with no change to content meaning: sections reordered + renumbered (Feature Changes → API → User Stories → Rollout → Observability → Metrics → Launch Plan → Dependencies → Decisions → Open Questions); the 17 prose ### Story blocks + 9 negative scenarios converted to the native Section-10 story table with per-story TEAM-S0X composite IDs and strict Gherkin ACs; the "Frontend Changes" + "Mobile Changes" sections folded into Feature Changes as CHG-007 / CHG-008; added §9 API & Webhook Behavior (promoted from CHG-002), §14 Launch Plan & Stage Gates (derived from Rollout stages), a Dependency Graph, and a What Happens If We Don't Build This section. Header/version synced to 2.6.
2.52026-06-16TEAM ONLY logic: empty-visible-to-all + hierarchy traversal; migration source corrected (grounded)CONTEXT, S1, S3, S4, S5, S7, CHG-002, CHG-004, CHG-006, S13 (Flow A, Story 2 AC-6/7, NEG-1/2/3/8/9), D-2/6/7/13, OQ-3/3a/4, App.C/DUPDATEDRefined TEAM ONLY to match production/legacy: (1) empty team_owner_ids (Unassigned) is visible to ALL Team Only users (D-13, resolves OQ-4); (2) hierarchy traversal — viewer also sees contacts owned by descendant (child) teams of their teams (D-7, resolves OQ-3, reverses v2.4 flat model). Filter = $or(empty OR $in {teams+descendants} OR owner/assignee). Migration source corrected from crm_team_hierarchy_id → multi-element team_owner_ids (D-6, CHG-004, Appendix C).
2.42026-06-16Notes TEAM ONLY + mobile in scope (grounded)CONTEXT, S3, S4, CHG-006, FE-6, S12 (mobile), S13 (Stories 12–17, NEG-7), OQ-6/10/11, App.AUPDATEDExtends TEAM ONLY to Contact Notes (customers_customernotes_view/_manage/_delete) — notes inherit parent-contact team scope (CHG-006). Adds mobile-qontak-crm (Flutter CDP/Contact360) as a grounded platform. 6 new stories (12–17; web Must / mobile Should) + NEG-7. Reverses v2.3 Non-Goal #7 for notes (resolves OQ-6 partially); adds OQ-10 / OQ-11.
2.32026-06-12Multi-select + all-teams change (re-grounded) + score fixesS1, S4, S5, S6.5, S7, S8, S9, S10, S11, S13, App.AUPDATEDTeam Owner is now multi-select (team_owner_ids array) and auto-fill = ALL of the creator's teams (D-3, D-4). Filter → multikey $in array intersection. Multikey index, migration 032. Per-element validation. is_primary dependency dropped. MoSCoW Priority added to all stories; Observability section added; one-liner trimmed to ≤25 words.
2.22026-06-03Score fixes (10 grounded gaps)S4, S7, S8a, S8b, S10, S11, S13, S14, S15UPDATEDStrict Gherkin rewrite of all 11 stories; permission model block on every story; mermaid sequenceDiagram for Flow A. CHG-001 re-assigned to Launchpad; 4-file scope; 6 handler branches; EvaluatePermissions breaking change (D-10); dedicated cdp_team_permission_enabled flag (D-8 + CHG-005).
2.12026-06-02Section 13 expansionS13MODIFIEDPer-permission-key stories split by Website/Mobile with IF/THEN AC format.
2.02026-06-02Grounded rewriteAllREWRITEFull rewrite grounded in contact-service, qontak-customer-fe, qontak.com, qontak-launchpad.