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 — reasonare 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
| Field | Value | Notes |
|---|---|---|
| Status | RFC (IDEA) | Human label; YAML status: carries the remapped linter enum draft |
| DRI | Zhelia Alifa | RFC owner (frontmatter dri) |
| Team | cdp | Advisory squad slug from the initiative README |
| Author(s) | Zhelia Alifa | Derived from PRD v2.5 |
| Reviewers | CDP Backend Lead, CDP Frontend Lead, Mobile Squad Lead, Launchpad Squad Lead | Tech reviewers across affected squads (FE + BE + Mobile + Launchpad) |
| Approver(s) | CDP Tech Lead, InfoSec Approver | This RFC changes an authorization boundary — InfoSec sign-off is required |
| Submitted Date | 2026-06-18 | ISO-8601 |
| Last Updated | 2026-06-18 | ISO-8601 |
| Target Release | 2026-Q3 | Progressive per-tenant enable (Growth + Enterprise) |
| Target Quarter | 2026-Q3 | From 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
- Overview (§1 Design References — FE half; §1 PRD-to-Schema Derivation — BE half; traceability; per-story change map)
- Technical Design (Repo Reading Guide both layers → architecture → sequence → DDL/index → APIs → integrity / cross-layer contract)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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:
- Correctness — zero cross-team leakage. 0 confirmed cross-team records visible under TEAM ONLY (audited).
- Performance. TEAM ONLY contact-list load ≤ 2s P95, ≤ 20% over the
captured
everythingbaseline. - Reliability. ≤ 0.5% of permission-check calls return 5xx under TEAM ONLY within 30 days of GA.
- Adoption. ≥ 30% of beta tenants set ≥1 action to
teamwithin 30 days of GA. - Data quality. ≥ 95% of contacts have non-empty
team_owner_idsafter migration (the remainder are intentional Unassigned).
Out of Scope
From PRD §3 Non-Goals:
- No custom permission groups beyond
own/team/everything. - No change to other CDP module permissions (Companies, Deals, Tickets,
Conversations) — except
searchassoc. - No team-management UI inside CDP (teams/membership are Launchpad-owned).
- Downward hierarchy only — a viewer sees their teams and descendant teams; a child-team member does not see parent-team-owned records.
- No bulk re-assignment tool for Team Owner in v1 (single-record edit only).
- No change to export/audit rules beyond CHG-002.
customers_customers_profileview/_profilemanageare not in TEAM ONLY scope (OQ-6 still open). Contact Notes are in scope (CHG-006).- 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.
Related Documents
- 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.comCRMsearch_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
- Launchpad delivers two net-new capabilities before BE enforcement GA: a
teams-for-user(+descendants) API and a
teamradio 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. - Enforcement is server-side in
contact-service; web and mobile clients are UX-only and are not a security boundary. team_owner_idsis 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).- The viewer's expanded team set (teams + descendants) is resolvable in ≤ 1 cached Launchpad call per request.
- Legacy team integer IDs map to Launchpad team UUIDs by team name within a company (migration crosswalk, CHG-004).
Dependencies
| Dependency | Owner | Deliverable | Availability | Blocking? |
|---|---|---|---|---|
| Teams-for-user(+descendants) API (NEW) | Launchpad | GET …/users/me/teams (or equivalent) returning the user's teams and descendant team ids; empty array (not error) for teamless users | needs-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 endpoint | needs-building | YES |
| Level-update endpoint | Launchpad | PUT /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 closed | YES |
| Team hierarchy traversal | Launchpad | teams.parent_id + TraverseTeamLeaf / TraverseTeamRoot / ListTeamHierarchy / TeamIsParent (verified in db/query/team.sql) | exists — descendants can be expanded server-side | YES |
crs_permissions per-action level | Launchpad | Already returned to contact-service; carries no team data | exists | YES |
| Legacy → Launchpad team name crosswalk | Launchpad + CDP | Map legacy integer team ids (from crm_team_hierarchy_id CSV) → Launchpad UUIDs by name within company | needs-building (migration) | YES (migration) |
| Mobile CDP module | Mobile Squad | Render Team Owner field + read per-note permission flags | in this RFC (MB-1/MB-2) | YES |
Feature flag cdp_team_permission_enabled | CDP / Ops | Dedicated per-company flag, default OFF | needs-building (CHG-005) | YES |
Design References (frontend half — required)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| Team Owner multi-select (contact detail/create — web) | reuse — no net-new visual | n/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 visual | n/a | Flutter crm_design package | not required | Existing searchable multi-select field pattern |
| Team Only permission radio (CHG-001) | n/a — Launchpad-owned | n/a | Launchpad-owned UI | Launchpad/Admin-Portal | Out of CDP FE scope — owned by Launchpad squad |
/customers/teams sidebar entry (web) | reuse — no net-new visual | n/a | @mekari/pixel3 | not required | Mirrors 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 viaMpInputTag, visual contract set byFilterMultiselect.vue), and the/customers/teamsentry 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 / rule | Persisted as | Exposed via | Enforced where | Source (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/sync | contact/base.go struct field; validated in handler | §10 CHG-003, D-3 |
| Auto-fill = ALL of the creator's teams; teamless → empty | written value on create (snapshot) | create/sync write path | FE/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 users | empty array / missing field | list 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/assignee | n/a (query-time) | list $or filter; per-record guard | search_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/a | resolved per request | Launchpad 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/a | write path 422 | handler validation against viewer teams | §10 CHG-003, NEG-6 |
| Notes inherit the parent contact's team scope | none on note; parent contacts.team_owner_ids | notes list/get/update/delete | new parent-contact scope lookup + service-layer evaluation | §10 CHG-006 |
Per-note permission.{update,delete} must reflect team scope | computed, not stored | notes responses | resolveNotePermission team branch (new) | §10 CHG-006, FE-6, MB-1 |
| Behaviour is gated per-tenant; default OFF | feature flag value | config | cdp_team_permission_enabled check before applying TEAM ONLY | §5, CHG-005 |
| Compound multikey index to keep TEAM ONLY list ≤ 2s P95 | Mongo index (company_sso_id, is_deleted, team_owner_ids) | n/a | migration 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 id | FE section / component | BE section / endpoint |
|---|---|---|
TEAM-S01/AC-1..3, ERR-1 | Launchpad settings UI (cross-squad, CHG-001) | Launchpad PUT /private/users/{sso}/permissions/{name}/level |
TEAM-S02/AC-1..7, ERR-1 | ListPage.vue, DetailPage.vue (level read) | §2.4 GET /cdp/customers (+detail) filter; permission_service.go |
TEAM-S03/AC-1..3, ERR-1 | mobile list screen (UX only) | same server filter as S02 |
TEAM-S04/AC-1..3, ERR-1 | edit entry gating (web) | PATCH /cdp/customers/{id} guard + write validation |
TEAM-S05/AC-1..3 | mobile edit gating | same guard |
TEAM-S06/AC-1..3 | delete gating (web) | DELETE /cdp/customers/{id} guard |
TEAM-S07/AC-1..2 | mobile delete gating | same guard |
TEAM-S08/AC-1..3 | assoc search component (web) | GET /cdp/customers/search-assoc filter |
TEAM-S09/AC-1..2 | mobile assoc search | same filter |
TEAM-S10/AC-1..6, ERR-1..2 | TeamOwnerSelect.vue, CustomerDetails.vue | team_owner_ids write + validation |
TEAM-S11/AC-1..7 | mobile team-owner picker | same write + validation |
TEAM-S12/AC-1..4, ERR-1 | CustomerActivityV2.vue level read | notes list/get scope (CHG-006) |
TEAM-S13/AC-1..2 | mobile notes section | same notes scope |
TEAM-S14/AC-1..3, ERR-1 | NotesList.vue (reads note.permission.update) | resolveNotePermission team branch + update guard |
TEAM-S15/AC-1..2 | mobile note edit (read per-note flag) | same |
TEAM-S16/AC-1..3 | NotesList.vue (reads note.permission.delete) | delete guard + per-note flag |
TEAM-S17/AC-1..2 | mobile note delete | same |
Reverse (RFC → PRD AC):
| New FE component / BE endpoint / dependency | PRD composite AC id it serves |
|---|---|
contacts.team_owner_ids array field + multikey index | TEAM-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.go | TEAM-S02/AC-1, TEAM-S08/AC-1 |
| Teams-for-user(+descendants) Launchpad API | TEAM-S02/AC-6 (hierarchy), TEAM-S10/AC-1 |
resolveNotePermission team branch | TEAM-S14/AC-1, TEAM-S16/AC-1 |
| Parent-contact scope lookup for notes | TEAM-S12/AC-2, NEG-7 |
TeamOwnerSelect.vue (fork of MultipleSelect.vue, emits ids) | TEAM-S10/AC-1..6 |
/customers/teams route + filterByRoute branch | TEAM-S02/AC-1 (team list view) |
cdp_team_permission_enabled flag | CHG-005, all enforcement stories |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
| Customer Index/List | web | GET /cdp/customers | — | ListPage.vue | filtered list (team scope applied server-side) |
| Customer Details | web | GET /cdp/customers/{id} (+ by email/phone) | PATCH /cdp/customers/{id} | DetailPage.vue, CustomerDetails.vue | team_owner_ids chips / "Unassigned" |
| Team Owner multi-select | web | teams-for-user API | PATCH /cdp/customers/{id} (team_owner_ids) | TeamOwnerSelect.vue (new) | selected team chips |
/customers/teams view | web | GET /cdp/customers?team_owner_ids=… | — | SidebarChildCustomer.vue, ListPage.vue | team-scoped list |
| Association search (Deal/Task/Ticket/Company) | web | GET /cdp/customers/search-assoc | — | assoc search component | filtered typeahead |
| Notes tab | web | GET /v1/contacts/{id}/notes | POST/PUT/DELETE /v1/contacts/notes/{id}[/{noteId}] | Notes.vue, NotesList.vue | per-note permission.{update,delete} |
| Customer list/detail | mobile | same BE endpoints | same | crm_contact screens | team-scoped list |
| Team Owner picker | mobile | teams-for-user API | same write | mobile field | team chips |
| Notes section | mobile | GET /contacts/{id}/notes | PUT/DELETE /contacts/{id}/notes/{noteId} | crm_note screens | per-note permission.{update,delete} |
| Permission settings (Team Only radio) | web (Launchpad/Admin Portal) | crs permissions | PUT /private/users/{sso}/permissions/{name}/level | Launchpad-owned | radio = current level |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| Org Admin / Owner | IAG token + role; admin scope | level-update (Launchpad) | permission settings page | no (own company) | Launchpad permission change log |
CS / Sales Agent (team) | IAG token + crs_permissions level=team | GET/PATCH/DELETE /cdp/customers*, notes — scoped | list/detail/notes within team scope | no | cdp_team_permission_denied event on 403 |
Agent (own) | level=own | same endpoints, owner/assignee scope | only own records | no | — |
Agent/Admin (everything) | level=everything | same endpoints, company scope | all company records | no | — |
Disabled (disabled) | level=disabled | 403 on all gated endpoints | entry points hidden | no | middleware 403 |
PRD Section Coverage
| PRD section # | Title | Where covered |
|---|---|---|
| Header / Adjustment Context | — | §1 Overview, Metadata |
| 1 | One-liner + Problem | §1 Overview |
| 2 | Target Users + Persona | §1 Role Coverage |
| Scope Changes | — | frontmatter scope_changes + §1 |
| 3 | Non-Goals | §1 Out of Scope |
| 4 | Constraints | §2 Technical Decisions, §2.3, §3 Performance |
| 5 | Rollout | §4 Rollout Strategy |
| 6 | Success Metrics | §1 Success Criteria |
| 6.5 | Observability | §3 Monitoring & Alerting |
| 7 | Dependencies | §1 Dependencies, §2.F.1 |
| 8 | Key Decisions + Alternatives | §1 Detail 1.B + §2 Technical Decisions |
| 9 | Open Questions | §5 |
| 10 | Feature Changes (CHG-001..006) | §2 Architecture, §2.3, §2.4, §4.D |
| 11 | Frontend Changes (FE-1..6) | §2.A UI Contract, Detail 1.C, §4.D |
| 12 | Mobile Changes (MB-1..2) | Detail 1.C, §2.A, §4.D |
| 13 | System Flow + Stories + AC | §2.2 Sequence, Detail 1.A/1.C, §4.D |
| Appendix A–E | Grounded references | §2.0 Source Verification |
| Changelog | — | n/a — PRD-internal history |
Detail 1.B — Decisions Closed (cross-layer)
| Decision | Chosen option | Alternatives rejected | Why rejected | Layer | §2 block |
|---|---|---|---|---|---|
| Scope key | Stored team_owner_ids array | Live owner/assignee/creator membership resolution | 1+N Launchpad calls/request; volatile; contacts vanish on membership change | BE | Decision 1 |
| TEAM ONLY rule | empty OR $in {teams ∪ descendants} OR owner/assignee | flat-only (no hierarchy); Unassigned hidden | Contradicts legacy descendant_ids + crm_team_ids << nil; orphans Unassigned | BE | Decision 2 |
| Field shape | Multi-select array | Single-select | Cannot express collaborative cross-team ownership | both | Decision 3 |
| Descendant expansion | Launchpad expands and returns the set (OQ-3a — proposed) | contact-service expands | Keeps hierarchy logic in the team-owning service; simpler cache key | BE | Decision 4 |
| Filter operator | bson.M{"$in": ids} via bson:"-" DTO field | Generic primitive.A → $or expansion (existing) | $or equality explodes per element; $in is one multikey op | BE | Decision 5 |
| Index | Compound multikey (company_sso_id, is_deleted, team_owner_ids) | No index / separate index | TEAM ONLY list must be ≤ 2s P95; one array field allowed in compound index | BE | Decision 6 |
EvaluatePermissions signature | Extend with teamOwnerIDs []string, viewerTeamIDs []string (BREAKING) | New parallel function | One evaluation path; avoids drift between list filter and per-record guard | BE | Decision 7 |
| Notes scoping | Inherit parent contact scope (one gate, no per-note filter) | Per-note team owner | Notes have no team owner; fetched by contact_id → single parent gate | BE | Decision 8 |
| Feature flag | Dedicated cdp_team_permission_enabled (default OFF) | Reuse GetIsPermissionUsmanEnabled | Reuse is company-wide across 12 perms; changes own/everything for tenants already ON | BE/Config | Decision 9 |
| Multi-select options source | User's OWN teams (teams-for-user API) | getTeams() (all company teams, capped 10) | Wrong source; user may only assign teams they belong to | FE | Decision 10 |
| Owner field emits | Team ids | Team names (existing MultipleSelect.vue behavior) | Persisted value is UUIDs; name is display only | FE | Decision 3 |
| Fallback on Launchpad outage | Degrade to OWNED ONLY + banner + event | Fail request (5xx) / fall back to everything | Fail-closed-ish: never widen scope; keep usable | BE | Decision 2 |
| Per-status lifecycle | n/a — no status enum introduced | — | team_owner_ids is a membership array, not a lifecycle state; soft-delete handled by existing is_deleted | both | — |
| Soft vs hard delete | Reuse existing contact is_deleted soft-delete; notes existing behavior | — | No new entity; no change to delete semantics beyond authz scope | BE | — |
| Inbound webhook ownership | n/a — no new inbound webhook | — | TEAM ONLY adds no async callback; sync write path reused | BE | — |
| Reuse vs new (endpoints) | All four customer endpoints + notes endpoints extended (no new routes); Launchpad teams-for-user is new | New CDP endpoints | Existing routes already serve these surfaces; only filter/guard logic changes | both | Decision 7/8 |
Detail 1.C — Per-Story Change Map
Layer scope ∈ {
FE-only,BE-only,FE + BE,Runtime / behavior,Config,Cross-squad}.RFC anchorcites where the detail lives.
| Story id | Story title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
| TEAM-S01 | Admin enables TEAM ONLY in settings | Cross-squad (Launchpad) | n/a — Launchpad/Admin-Portal owned | n/a — Launchpad owns level-update; CDP only reads the level | TEAM-S01/AC-1..3, ERR-1 | Level-update call persists team; reload shows Team Only | §1 Deps · §2.F.1 · CHG-001 |
| TEAM-S02 | customers_customers_view — web | FE + BE | ListPage.vue:532 + DetailPage.vue:131 read perm.level; /customers/teams route; team filter inject | GET /cdp/customers (+detail) $or filter; permission_service.go team branch; 6 handler guards | TEAM-S02/AC-1..7, ERR-1 | Go 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-S03 | customers_customers_view — mobile | FE + BE | mobile list reads server-filtered results; disabled hides tab | same filter as S02 (no new BE) | TEAM-S03/AC-1..3, ERR-1 | Mobile list == web for same role; deep link out-of-scope → 403 | §2.2 · §4.D ch 9 |
| TEAM-S04 | customers_customers_manage — web | FE + BE | edit entry gated on level+scope | PATCH guard (handler branch); write validation | TEAM-S04/AC-1..3, ERR-1 | Go test: in-scope edit ok; out-of-scope edit → 403; non-member team in body → 422 | §2.4 · §4.D ch 4–6 |
| TEAM-S05 | customers_customers_manage — mobile | FE + BE | mobile (⋮) edit gating | same guard | TEAM-S05/AC-1..3 | Mobile edit scoped == web | §4.D ch 9 |
| TEAM-S06 | customers_customers_delete — web | FE + BE | delete button gating | DELETE guard branch | TEAM-S06/AC-1..3 | Go test: in-scope delete ok; out-of-scope → 403 | §2.4 · §4.D ch 4 |
| TEAM-S07 | customers_customers_delete — mobile | FE + BE | mobile (⋮) delete gating | same guard | TEAM-S07/AC-1..2 | Mobile delete scoped == web | §4.D ch 9 |
| TEAM-S08 | customers_customers_searchassoc — web | FE + BE | assoc search component visibility | GET /cdp/customers/search-assoc filter (handler branch) | TEAM-S08/AC-1..3 | Go test: assoc results team-scoped; disabled → component hidden + 403 | §2.4 · §4.D ch 4 |
| TEAM-S09 | customers_customers_searchassoc — mobile | FE + BE | mobile assoc search | same filter | TEAM-S09/AC-1..2 | Mobile assoc scoped == web | §4.D ch 9 |
| TEAM-S10 | Set Team Owner — web | FE + BE | TeamOwnerSelect.vue (new, emits ids); CustomerDetails.vue field block; CustomerStore.ts Contact type | team_owner_ids write in sync/patch path; per-element validation 422 | TEAM-S10/AC-1..6, ERR-1..2 | Vitest: 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-S11 | Set Team Owner — mobile | FE + BE | mobile picker (searchable multi-select) | same write+validation | TEAM-S11/AC-1..7 | Mobile create auto-fills all teams; offline → error; 422 parity | §2.A · §4.D ch 9 |
| TEAM-S12 | customers_customernotes_view — web | FE + BE | CustomerActivityV2.vue:69 read level | notes list/get scope via parent contact (CHG-006) | TEAM-S12/AC-1..4, ERR-1 | Go test: notes on out-of-scope contact → 403; in-scope → returned | §2.4 notes · §4.D ch 7 |
| TEAM-S13 | customers_customernotes_view — mobile | FE + BE | mobile notes section | same scope | TEAM-S13/AC-1..2 | Mobile notes scope == web | §4.D ch 9 |
| TEAM-S14 | customers_customernotes_manage — web | FE + BE | NotesList.vue:91 reads note.permission.update; add _manage const | resolveNotePermission team branch; update guard | TEAM-S14/AC-1..3, ERR-1 | Go test: permission.update true in-scope/false out; PUT out-of-scope → 403 | §2.4 · §4.D ch 7,8 |
| TEAM-S15 | customers_customernotes_manage — mobile | FE + BE | mobile gates on per-note permission.update (was contact-level hasUpdatePermission) | same | TEAM-S15/AC-1..2 | Mobile edit gated by note.permission.update | §4.D ch 9 |
| TEAM-S16 | customers_customernotes_delete — web | FE + BE | NotesList.vue:91-101 reads note.permission.delete; add _delete const | resolveNotePermission team branch; delete guard | TEAM-S16/AC-1..3 | Go test: permission.delete true in-scope/false out; DELETE out-of-scope → 403 | §2.4 · §4.D ch 7,8 |
| TEAM-S17 | customers_customernotes_delete — mobile | FE + BE | mobile gates on per-note permission.delete (already wired note_screen.dart:312) | same | TEAM-S17/AC-1..2 | Mobile delete gated by note.permission.delete | §4.D ch 9 |
Cross-layer rule satisfied: every
FE + BErow has both halves filled. TEAM-S01 is the onlyCross-squadrow (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_idsarray 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.
- Pros: single indexed multikey
- 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.
- Pros: matches legacy (
- 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.vueto emit ids.- Pros: expresses co-ownership; reuses an in-repo
MpInputTagcomponent. - Cons: the existing component emits names (
MultipleSelect.vue:147-148) — the fork must change the emit toitem.id.
- Pros: expresses co-ownership; reuses an in-repo
- 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 (
TraverseTeamLeafalready exists); contact-service caches one flat set; simple cache key. - Cons: new API must accept "include descendants".
- Pros: hierarchy lives with the team-owning service (
- 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$inbranch.- Pros: one multikey
$inop; idiom proven atsegmented_filter_request.go:211andfield_properties_search.go:66. - Cons: must remember the
bson:"-"to bypass the generic expansion.
- Pros: one multikey
- Option B — let the generic
$orexpansion handle it. Pros: no special case. Cons: N$orequality 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_searcharrays indexed plainly in migrations011/028).- Pros: single index serves the hot query; one array field is allowed in a
compound index (
company_sso_id/is_deletedare scalar — OK). - Cons: index write cost on array updates (small arrays).
- Pros: single index serves the hot query; one array field is allowed in a
compound index (
- 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
EvaluateTeamPermissionsalongside 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
ValidateContactExistsreturns only a bool) + service-layer evaluation (today the notes handler never callsEvaluatePermissions).
- 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/everythingbehavior for tenants who already have it ON.
- Pros: no new flag. Cons: company-wide across all 12 permissions; flipping it
changes
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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE | internal/pkg/consts/const.go | Permission keys + notes keys live here | const block style; team/own keys |
| BE | internal/app/service/permission_service.go | team returns true today; signature to extend | PermissionResult evaluation shape |
| BE | internal/app/handler/contact_handler.go | 6 OwnerPermissionKey branches to mirror for team | per-record guard pattern (owner/assignee → 403) |
| BE | internal/app/service/get_contact.go | Single-get company-only guard | where to add team-intersection check |
| BE | internal/app/payload/search_contact_request.go | ToFilters() array→$or explosion | why team_owner_ids needs bson:"-" + explicit $in |
| BE | internal/app/payload/segmented_filter_request.go | Proven $in idiom | bson.M{"$in": value} |
| BE | internal/app/repository/contact/base.go | Tags []string array field declaration | bson array field tag mirror for TeamOwnerIDs |
| BE | internal/app/payload/contact_sync_request.go | Write/sync DTO shape | where to add the array on the write path |
| BE | internal/pkg/middleware/require_permission_middleware.go | Binary gate + Usman flag | flag check; level stored in context |
| BE | internal/app/repository/contact_notes/base.go | ContactNote + ContactNoteInterface + ValidateContactExists (bool) | add parent-scope projection method |
| BE | internal/app/handler/contact_notes_handler.go | resolveNotePermission (drops team) | add team branch |
| BE | internal/app/service/contact_notes/contact_notes_service.go | list/get company+contact only; update/delete owner-only | where to add scope evaluation |
| BE | internal/server/rest_router.go | canonical + deprecated notes routes (both share handlers) | one service change covers both |
| BE | db/migrations/032_create_customer_segments.up.json | Latest migration; JSON index format | index migration shape; next number = 033 |
| FE | features/customers/store/UserStore.ts | getTeams() (wrong source) + hasAssociatedAccess() | add getMyTeams(); return {enabled, level} |
| FE | common/components/field/MultipleSelect.vue | Fork target; emits names | change emit to item.id |
| FE | features/customers/views/components/segment/FilterMultiselect.vue | MpInputTag usage | multi-tag component contract |
| FE | features/customers/views/ListPage.vue | is_enabled-only read; filterByRoute | add level read; /customers/teams branch |
| FE | features/customers/detail/components/CustomerDetails.vue | dynamic form; multiple_select field_type | wire the field block |
| FE | features/customers/store/CustomerStore.ts | Contact type; notes API client | add team_owner_ids?: string[] |
| FE | features/customers/detail/components/Notes/{constants.ts,Notes.vue,components/NotesList/NotesList.vue} | notes gating; per-note flags | add _manage/_delete consts; read level |
| MB | features/crm_note/.../note_cdp_data_response/note_cdp_data_response.dart | parses per-note permission | NoteCdpPermissionResponse |
| MB | features/crm_note/.../screens/note/note_screen.dart | delete reads per-note; add gates on contact-level | switch add/edit gating to per-note |
| MB | features/crm_contact/.../tab/other_tab.dart | contact-level hasEditPermission | where the contact-level flag leaks into notes |
Existing Contracts to Reuse, Extend, or Replace (BE)
| Contract | Status | Justification | Owner |
|---|---|---|---|
GET /cdp/customers (list) | extended | add team_owner_ids $or filter; no new route | CDP |
GET /cdp/customers/{id} (+ by email/phone) | extended | single-get team guard | CDP |
PATCH /cdp/customers/{id} | extended | write team_owner_ids + validation | CDP |
DELETE /cdp/customers/{id} | extended | delete guard | CDP |
GET /cdp/customers/search-assoc | extended | assoc filter | CDP |
GET/POST/PUT/DELETE /v1/contacts/{id}/notes* (+ deprecated /notes/...) | extended | scope via parent contact; both groups share handlers | CDP |
EvaluatePermissions(...) | extended (breaking signature) | add teamOwnerIDs, viewerTeamIDs | CDP |
crs_permissions read | reused | already returns the level | Launchpad |
PUT /private/users/{sso}/permissions/{name}/level | reused | already persists the level | Launchpad |
| Teams-for-user(+descendants) API | new-with-justification | no user→teams endpoint exists (/users/me has no teams; only teams→members like ListTeamMember); TEAM ONLY needs the viewer's expanded team set per request | Launchpad |
Patterns to Follow (and where to find them)
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| BE | HTTP handler guard | owner/assignee check → ErrForbidden() | contact_handler.go:256 | none — add team branch beside it |
| BE | Repository / DB filter | bson.M{"$in": value} | segmented_filter_request.go:211 | none — first $in in search_contact_request.go |
| BE | Array field declaration | Tags []string \bson:"tags,omitempty"`` | contact/base.go:76 | none — mirror for TeamOwnerIDs |
| BE | Index migration | JSON key/name index doc | db/migrations/011_*.up.json, 028 | none — declare plainly; Mongo makes it multikey |
| BE | Logging / events | slog.*Context(ctx, "module_action", slog.Any(...)) | contact_handler.go:91 | none — new events follow cdp_team_permission_* naming |
| BE | Error shape | myhttp.ErrForbidden() / myhttp.ResponseBody | contact_handler.go | none |
| FE | State management | Pinia store + storeToRefs | UserStore.ts, CustomerStore.ts | none — add getMyTeams() + myTeams |
| FE | Multi-select | MpInputTag | FilterMultiselect.vue:3-13 | fork MultipleSelect.vue, emit ids |
| FE | Permission read | perm?.is_enabled | ListPage.vue:532, DetailPage.vue:131 | deviation: also read perm?.level |
| FE | Notes per-note gating | note.permission?.update ?? false | NotesList.vue:91-101 | none — server fills the flag |
| MB | Per-note gating | noteItem.permission?.delete ?? false | note_screen.dart:312 | deviation: extend to add/edit too |
| Cross | API casing | snake_case API ↔ TS interface fields snake_case | CustomerStore.ts Contact type | none — team_owner_ids stays snake_case both sides |
Reading Order for the Agent
internal/pkg/consts/const.go— permission + notes keys (44-47,35-38).internal/app/service/permission_service.go—teamreturnstrue(86-88); signature (41-46).internal/app/handler/contact_handler.go— the 6 owner branches (256, 466, 543, 648, 760, 956).internal/app/payload/search_contact_request.go—ToFilters()array→$or(137-157).internal/app/repository/contact/base.go—Tagsarray (76).internal/app/repository/contact_notes/{base.go,read.go}+contact_notes_handler.go+contact_notes_service.go— notes gap.internal/server/rest_router.go— notes routes (151-159+162-169).db/migrations/032_create_customer_segments.up.json— index migration shape.features/customers/store/{UserStore.ts,CustomerStore.ts}+views/ListPage.vue+detail/.../CustomerDetails.vue.features/crm_note/.../note_screen.dart+features/crm_contact/.../tab/other_tab.dart(mobile gating).
Source Verification (anti-hallucination)
| Layer | Anchor / contract | Verified by | Evidence |
|---|---|---|---|
| BE | const.go permission keys | read | OwnerPermissionKey="own", TeamPermissionKey="team", EverythingPermissionKey="everything" at L44-47 (PRD said 40-42) |
| BE | const.go notes keys | read | CustomersCustomerNotes{Add,View,Manage,Delete}Key at L35-38 (PRD said 34-37) |
| BE | team returns true | read | if permission.Level == consts.TeamPermissionKey { return true } at permission_service.go:86-88 |
| BE | EvaluatePermissions signature | read | func (s *PermissionService) EvaluatePermissions(permissions []api.LaunchpadPermission, userSSOID, ownerID, assigneeID string) PermissionResult at permission_service.go:41-46 |
| BE | 6 owner branches | read | if authPermissionLevel == consts.OwnerPermissionKey {…ErrForbidden()} at contact_handler.go:256,466,543,648,760,956 (all confirmed) |
| BE | single-get guard | read | company_sso_id mismatch reset at get_contact.go:24-28 (PRD said 22-26) |
| BE | Usman flag | read | if !configService.GetIsPermissionUsmanEnabled() {…} at require_permission_middleware.go:21; binary gate Level != disabled at ~78-91 |
| BE | Tags array field | read | Tags []string \json:"tags,omitempty" bson:"tags,omitempty"`atcontact/base.go:76; grep team_owner` → NOT FOUND |
| BE | $in precedent | read | filters[filter.Name] = bson.M{"$in": filter.Value} segmented_filter_request.go:211; field_properties_search.go:66 |
| BE | ToFilters() array→$or | read | case primitive.A: … AppendConditions(&filters,"$or",orConditions) at search_contact_request.go:137-157; no $in in file |
| BE | sync DTO | read | contact_sync_request.go exists; has Tags []string (L~38), no team_owner_ids |
| BE | ContactNote fields | read | contact_id, company_sso_id, note, owner_id, … contact_notes/base.go:26-36; no team owner; ValidateContactExists returns bool (base.go:76) |
| BE | notes routes | read | canonical /{contact_id}/notes rest_router.go:151-159; deprecated /notes 162-169; share handlers |
| BE | resolveNotePermission drops team | read | only EverythingPermissionKey/OwnerPermissionKey checked, contact_notes_handler.go:143-166; no EvaluatePermissions call |
| BE | notes service owner-only | read | if existingNote.OwnerID != ownerID {…access denied} contact_notes_service.go:196-199; list/get gated by ValidateContactExists only |
| BE | migration number | ls | highest = 032_create_customer_segments.up.json; next = 033 (PRD said 032 — taken); format JSON key/name |
| BE | tooling | read | go test -race -tags dynamic … ./internal/... ./config/... Makefile:82; staticcheck ./... Makefile:140; migrate … up Makefile:174; no integration target |
| FE | getTeams() source | read | '/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 |
| FE | MpInputTag | read | <MpInputTag :suggestions="options" …> FilterMultiselect.vue:3-13; no MpSelect in repo |
| FE | MultipleSelect.vue emits names | read | const newNames = normalizedData.map(i=>i.name); emit('update:value', newNames) MultipleSelect.vue:146-148; reads userStore |
| FE | multiple_select form | read | case 'multiple_select': CustomerDetails.vue:832-833; arrayFieldTypes=['multiple_select','upload','signature'] :868; Profile.vue absent |
| FE | Contact type | read | interface CustomerStore.ts:270-309; assignee_name present; no team_owner_ids |
| FE | is_enabled reads | read | return perm?.is_enabled === true ListPage.vue:532, DetailPage.vue:131, UserStore.ts:161 (hasAssociatedAccess) |
| FE | filterByRoute | read | route.path.split('/').pop() ListPage.vue:190; 'owned-by-me'→owner_id … 'assigned-to-me'→assignee_id :367; no /customers/teams |
| FE | notes constants | read | only 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 |
| FE | notes APIs | read | GET /v1/contacts/${id}/notes (:522), POST /v1/contacts/notes/${id} (:627), PUT/DELETE /v1/contacts/notes/${id}/${noteId} (:643/:659) |
| FE | tooling | read | eslint . (:18), vitest (:16), nuxt build (:6); pnpm; @mekari/pixel3@1.0.10-dev.0 (:24) |
| MB | flags | read | contact360='flag_contact_360' (feature_flag_constant.dart:52), noteCdp='flag_note_cdp' (:115) |
| MB | per-note model | read | NoteCdpPermissionResponse{update,delete} note_cdp_data_response.dart:44-52; entity note_permission.dart:14-22 |
| MB | gating | read | delete 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 |
| MB | no notes keys | grep | grep customernotes → NOT FOUND; no permission level on mobile |
| MB | endpoints | read | cdpNotesByContactId => '/contacts/$contactId/notes' endpoint.dart; /v1 prefix |
| MB | tooling | read | melos run test (melos.yaml:97-105), melos run analyze (:77-81); make apk/ipa |
| LP | team_users no is_primary | read | CREATE 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) |
| LP | level-update route | read | PUT /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) |
| LP | no teams-for-user | grep | ListTeamsByUser/users/me/teams → NOT FOUND; /users/me UserInfoResponse has no teams; only ListTeamMember (team.sql:128-139) |
| LP | hierarchy exists | read | parent_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 isrest_router.go:172underr.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). Nois_primary, no teams-for-user endpoint, and the hierarchy traversal queries all re-confirmed on this checkout.
Design ↔ Code Mapping (frontend half)
| Figma frame / component | Implementing file | Reuse vs new | Tokens | Backing API | Deviation |
|---|---|---|---|---|---|
| Team Owner multi-select (reuse, no frame) | common/components/field/TeamOwnerSelect.vue (new) | new (fork of MultipleSelect.vue) | Pixel MpInputTag defaults | teams-for-user; PATCH /cdp/customers/{id} | emit ids not names |
| Team list view (reuse, no frame) | ListPage.vue + SidebarChildCustomer.vue | extended | existing sidebar tokens | GET /cdp/customers?team_owner_ids | none — mirrors existing route items |
| Notes gating (reuse, no frame) | Notes.vue, NotesList.vue | extended | existing | notes endpoints | server 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 permissionlevelis a per-request value fromcrs_permissions, not a stored entity state. - Partition/sharding: none (existing collection sharding unchanged).
- NoSQL note: already MongoDB; the array + multikey
$inis the native fit (no relational join table needed).
Detail 2.4 — APIs
Outbound endpoints (consumers call contact-service)
| Endpoint | Method | AuthN/AuthZ | Request | Response | Status | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/cdp/customers | GET | IAG token + customers_customers_view (middleware) | query incl. internal team_owner_ids filter (route-derived) | {data:[Contact{…, team_owner_ids}]} | 200 / 403 | n/a (read) | existing /cdp | extended |
/cdp/customers/{id} (+ by email/phone) | GET | + view; team guard | path | Contact{…, team_owner_ids} | 200 / 403 / 404 | n/a | existing | extended |
/cdp/customers/{id} | PATCH | + customers_customers_manage; team guard + write validation | {…, team_owner_ids:[uuid]} | updated Contact | 200 / 403 / 422 (non-member team) | natural (PATCH by id) | existing | extended |
/cdp/customers/{id} | DELETE | + customers_customers_delete; team guard | path | {} | 200 / 403 | natural | existing | extended |
/cdp/customers/search-assoc | GET | + customers_customers_searchassoc; team filter | query | filtered list | 200 / 403 | n/a | existing | extended |
/v1/contacts/{contact_id}/notes | GET | + customers_customernotes_view; parent-contact scope | path | {data:[Note{…, permission:{update,delete}}]} | 200 / 403 | n/a | existing | extended |
/v1/contacts/{contact_id}/notes/{id} | GET | + view; parent scope | path | Note | 200 / 403 | n/a | existing | extended |
/v1/contacts/{contact_id}/notes/{id} | PUT | + customers_customernotes_manage; parent scope | {note} | Note | 200 / 403 | natural | existing | extended |
/v1/contacts/{contact_id}/notes/{id} | DELETE | + customers_customernotes_delete; parent scope | path | {} | 200 / 403 | natural | existing | extended |
deprecated /v1/contacts/notes/{contact_id}[/{id}] group | GET/POST/PUT/DELETE | same keys | same | same | same | same | existing (shares handlers) | extended |
Sync/write path (
contact_sync_request.go) also gainsteam_owner_idswith 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
/privatewith BasicAuth, matching the verified Launchpad service-to-service convention (e.g. the level-update route atrest_router.go:172).
| Endpoint | Method | Auth | Request | Response | Notes |
|---|---|---|---|---|---|
GET /private/users/{user_sso_id}/teams?include_descendants=true | GET | BasicAuth (service token, same as /private/*) | path user_sso_id; query include_descendants (bool, default false) | 200 body below | empty 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). Wheninclude_descendants=false,expanded_team_idsequalsteam_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":[]}}with200(never404).
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
/privatenamespace (no new version). Caching: CDP keys the result on(company_sso_id, user_sso_id)in Redis, short TTL (~60s, NEG-4);expanded_team_idsis 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_idsadded); 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_idsadded); 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 toMpInputTagperFilterMultiselect.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 viagetMyTeams()). - Events: none analytics-critical; optional
cdp_team_owner_setis 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 read — UserStore.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 cachesmyTeamsin the store for the session. - TTL/refetch: FE
myTeamsfetched on detail/create mount (mirrors currentgetTeams()atDetailPage.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
PATCH5xx the field reverts and a toast shows (ERR-2).
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Customer list (team) | list skeleton | "No contacts in your team yet." | fallback banner + own-scoped results | n/a | team-scoped list |
| Team Owner select | spinner | "No teams assigned" → Unassigned | "Could not load teams. Try again." (save blocked) | n/a | team-name chips |
| Notes tab | notes skeleton | "No notes yet." | "Could not load notes." | n/a | notes list w/ per-note actions |
| Assoc search | typeahead spinner | "No contacts found in your team." | "Could not load contacts." | partial typeahead results | filtered results |
Detail 2.D — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency | Consistency | Duplicate handling | Stale-read handling |
|---|---|---|---|---|---|---|
PATCH team_owner_ids | single Mongo doc update | update fails → 5xx, FE reverts | natural (by id) | strong (single doc) | n/a | snapshot value; OQ-2 |
| Notes update/delete | single note doc | fails → 5xx | natural (by id) | strong | n/a | parent scope re-checked per request |
| Migration backfill | per-contact update (batched) | row error → logged, continue; report unmatched | re-runnable (idempotent set) | eventual until complete | re-set is idempotent | reads after flag-on |
Detail 2.E — Concurrency Collision Map
| Resource | Writers | Collision | Resolution | On conflict |
|---|---|---|---|---|
contact.team_owner_ids | two agents edit same contact | last-write-wins on the array | existing contact update semantics (no new lock) | last writer's array persists; both validated independently |
| viewer team-set cache | concurrent requests, same user | cache stampede on miss | short TTL; single-flight optional | extra Launchpad call worst case (bounded) |
Detail 2.F — Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input | Retry | DLQ | Concurrency | Idempotency | Timeout | Poison 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 CSV | re-run safe | n/a (batch log) | batched | idempotent set of team_owner_ids | per-batch | unmatched team → leave [] (Unassigned) + report |
No long-running runtime consumer; enforcement is synchronous.
Detail 2.F.1 — Responsibility Boundary Matrix
| Step (exec order) | Owning squad / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
1. Admin sets team level | Launchpad (settings UI, CHG-001) | admin save | crs_permissions level=team | inline error, prev level kept | TEAM-S01 |
| 2. Resolve viewer teams+descendants | Launchpad (teams-for-user API, NEW) | CDP request | expanded team ids | timeout→ CDP fallback OWNED | §7, TEAM-S02/AC-6 |
| 3. Apply TEAM ONLY filter / guard | CDP (contact-service) | list/get/patch/delete | scoped results / 403 / 422 | emit denied/fallback events | CHG-002 |
| 4. Compute per-note flags | CDP (contact-service) | notes request | note.permission.{update,delete} | drop to false out-of-scope | CHG-006 |
| 5. Render gating | CDP web + Mobile | response | show/hide actions | banner on fallback | FE-3/6, MB-1 |
6. Backfill team_owner_ids | CDP (backfill job, OQ-1 resolved) | one-off | populated arrays | unmatched→Unassigned+report | CHG-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 isPUT /private/users/{user_sso_id}/permissions/{permission_name}/level(BasicAuth). This has no CDP/FE/mobile execution impact:contact-servicenever calls level-update — it only readscrs_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/privatevs 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
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Contact | team_owner_ids | [] (Unassigned) | PATCH/sync/migration | GET /cdp/customers* | snapshot (OQ-2) |
| Note | permission.{update,delete} (computed) | false | resolveNotePermission (team branch) | notes responses | per-request (not stored) |
| Viewer | expanded team set | from Launchpad | teams-for-user API | server cache | short TTL (≈60s, NEG-4) |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|---|---|---|---|
GET /cdp/customers* | Contact{…, team_owner_ids: []string} | Contact{…, team_owner_ids?: string[]} (add to CustomerStore.ts:270-309) | yes | snake_case both sides; FE type must add field |
PATCH /cdp/customers/{id} | accepts team_owner_ids, 422 on non-member | FE sends ids, handles 422 revert | yes | FE must surface 422 message |
| notes list/get | Note{…, permission:{update,delete}} | web reads note.permission.* (NotesList.vue:91-101); mobile NoteCdpPermissionResponse | yes | mobile 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 picker | yes | Both 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 onContactNoteInterface. - BE modify:
contact/base.go(+field),search_contact_request.go(+bson:"-"field +$inbranch),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(resolveNotePermissionteam branch),contact_notes/read.go+base.go(projection), config (flag). - FE create:
common/components/field/TeamOwnerSelect.vue. - FE modify:
UserStore.ts(getMyTeams()+myTeams;hasAssociatedAccessshape),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-notepermission). - 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.vueis 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
everythingbaseline; ≤ 0.5% 5xx on permission checks. Multikey$inon(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 theeverythinglist 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
MpInputTagis 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/teamsview inherit the existingqontak-customer-fesupported browser matrix (current Chrome/Edge/Firefox/Safari + iOS Safari per the app's baseline);MpInputTagalready 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):
| Event | Trigger | Properties | Alert |
|---|---|---|---|
cdp_team_permission_fallback | teams-for-user >500ms or 5xx → OWNED ONLY | company_sso_id, user_sso_id, platform, permission_name, latency_ms | >1% of team requests / 5-min → page CDP on-call |
cdp_team_permission_denied | single-record action out-of-scope → 403 | company_sso_id, user_sso_id, contact_id, permission_name, viewer_team_count | spike >3× 7-day baseline → notify CDP Squad |
cdp_team_owner_validation_rejected | write 422 (non-member team) | company_sso_id, user_sso_id, rejected_team_ids | info only |
cdp_team_owner_set | team_owner_ids written | company_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-emptyteam_owner_ids. - Frontend analytics (REV-7) — emitted by
qontak-customer-fevia the existing analytics client, so adoption/UX is observable alongside the BE events (closes the OBS cross-layer gap):
| FE event | Trigger | Properties |
|---|---|---|
cdp_team_owner_field_saved | user saves team_owner_ids from TeamOwnerSelect.vue | team_owner_count, was_autofilled (bool), cleared_to_unassigned (bool) |
cdp_team_filter_view_loaded | /customers/teams list view loads | result_count, is_empty (bool) |
cdp_team_fallback_banner_shown | fallback banner rendered (BE fell back to OWNED) | surface (list/detail/assoc) |
cdp_team_owner_save_rejected | FE receives 422 on Team Owner save | rejected_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_countto the list-handler log line so on-call can follow a user-reported issue from the FEcdp_team_fallback_banner_shownevent to the BEcdp_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
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Additional constraint | Audit |
|---|---|---|---|---|---|---|
team agent | /cdp/customers*, /v1/contacts/{id}/notes* | GET/PATCH/DELETE | own company; team∪descendant∪Unassigned∪owned records | list/detail/notes within scope | write team_owner_ids only with own teams (422 else) | cdp_team_permission_denied on 403 |
own agent | same | same | owner/assignee records only | only own records | — | — |
everything agent | same | same | whole company | all records | — | — |
disabled | same | — | none | entry points hidden | direct URL/API → 403 | middleware 403 |
| Org Admin | Launchpad level-update | PUT | own company | settings page | admin role required | Launchpad 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_idremains 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)
| Surface | FE behavior on failure | BE response on failure | Codes match? |
|---|---|---|---|
| List (Launchpad teams timeout) | banner "Showing only your contacts — team filter temporarily unavailable" + own results | fallback OWNED ONLY, 200, cdp_team_permission_fallback | yes |
| Single-get out-of-scope | detail not rendered / redirect | 403 + cdp_team_permission_denied | yes |
| Team Owner save (non-member team) | field reverts, toast | 422 TEAM_OWNER_INVALID | yes |
| Team Owner save (5xx) | field reverts, "Could not save Team Owner. Try again." | 5xx | yes |
| Teams list load (picker) | "Could not load teams. Try again." (save blocked) | 5xx / timeout from Launchpad | yes |
| Notes out-of-scope | notes not populated | 403 | yes |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
cdp_team_permission_enabled OFF | contact-service (before TEAM ONLY) | team behaves as everything (legacy) | — | no |
| Launchpad teams unavailable | contact-service (per request) | degrade to OWNED ONLY | cdp_team_permission_fallback | yes (banner) |
Empty team_owner_ids (Unassigned) | filter / guard | visible to all team users | — | no |
| Teamless viewer | filter / guard | sees Unassigned + own only | — | no |
Detail 3.B — Error Response Catalog (BE)
Shape: { "error":"CODE", "message":"…", "details":{} }.
| Endpoint | Error code | HTTP | Message | When | User-facing? |
|---|---|---|---|---|---|
/cdp/customers/{id} GET/DELETE | FORBIDDEN | 403 | out of your team scope | not in scope, not owner/assignee | yes |
/cdp/customers/{id} PATCH | TEAM_OWNER_INVALID | 422 | team is not one of your teams | non-member team submitted | yes |
notes * | FORBIDDEN | 403 | parent contact out of scope | parent not visible | yes |
| any gated | FORBIDDEN | 403 | permission disabled | level=disabled | yes |
Detail 3.C — Error Message Catalog (FE)
| Error code | User-facing message | Surface | User-facing? |
|---|---|---|---|
| fallback | "Showing only your contacts — team filter temporarily unavailable" | banner | yes |
| 422 team owner | "Could not save Team Owner. Try again." (+ revert) | toast | yes |
| teams load | "Could not load teams. Try again." | inline (picker) | yes |
| notes load | "Could not load notes." | inline | yes |
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/teamsentry keyboard-navigable like existing items.
4. Backwards Compatibility and Rollout Plan
Compatibility
- BE: responses are additive (
team_owner_ids); existing consumers ignore it.EvaluatePermissionssignature is breaking internally only (compiler-enforced; all callers in the same PR). Behavior change (teamnarrows) is gated bycdp_team_permission_enabled(default OFF) — zero change for tenants until enabled. - FE: new optional
team_owner_ids?: string[]on the Contact type; readingperm.levelis additive tois_enabled. - Mobile: reads an already-parsed per-note
permissionobject — 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 carryteam_owner_ids, reads ignore). → (Stage 1) backfill fromcrm_team_hierarchy_idCSV on a QA tenant; verify coverage + Unassigned counts. → (Stage 2) enablecdp_team_permission_enabledon 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 writesteam_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_enabledOFF (instant return to legacyteam==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
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline |
| Backend first | Old | New (flag OFF) | yes | team_owner_ids additive; old FE ignores it |
| Frontend first | New | Old | yes | FE reads level but BE not enforcing; field hidden if API lacks it |
| Both + flag OFF | New | New | yes | target pre-enable state |
| Both + flag ON | New | New | yes | target state |
| Backend rollback (flag OFF) | New | New (flag OFF) | yes | FE field still works; enforcement reverts to legacy |
| Frontend rollback | Old | New | yes | BE 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
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
| BE | cdp_team_permission_enabled | per-company bool | OFF | yes | Ops / config service | no |
| BE | teams-for-user base URL/token | config | existing Launchpad config | yes | config / Vault | token: yes |
| BE | team-set cache TTL | int (s) | ~60 | no | config | no |
| FE | (none new) | — | — | — | — | — |
Detail 4.C — Test Plan (commands sourced from repo)
| Layer | Command (source) | What it must prove |
|---|---|---|
| BE unit | go test -race -tags dynamic ./internal/... ./config/... (Makefile:82) | filter $or, EvaluatePermissions team branch, 6 guards, notes scope, 422 validation |
| BE static | staticcheck ./... (Makefile:140) | no new lint regressions |
| BE migration | migrate -database "mongodb://…" -path ./db/migrations/ up (Makefile:174) | index company_isdeleted_team_owner_ids created; down drops it |
| FE unit | pnpm vitest (package.json:16) | TeamOwnerSelect emits ids; auto-fill all teams; clear→Unassigned; level gating |
| FE lint | pnpm eslint . (package.json:18) | clean |
| FE build | pnpm build (package.json:6) | builds |
| Mobile test | melos run test (melos.yaml:97-105) | add/edit/delete gate on per-note permission |
| Mobile analyze | melos run analyze (melos.yaml:77-81) | clean |
| Cross-layer | manual/contract: BE team_owner_ids ↔ FE Contact type; notes permission ↔ web+mobile | shapes 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
| Order | Layer | Chunk | Files | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | BE | Add team_owner_ids field + index migration | internal/app/repository/contact/base.go; db/migrations/033_add_team_owner_ids_index.{up,down}.json | migrate … up; go build -tags dynamic | field compiles; index company_isdeleted_team_owner_ids exists; down drops it |
| 2 | BE | List filter $in (bypass $or expansion) | internal/app/payload/search_contact_request.go | go test -race -tags dynamic ./internal/app/payload/... | team_owner_ids produces one $in, not per-element $or; existing tests pass |
| 3 | BE | Extend EvaluatePermissions + team branch | internal/app/service/permission_service.go | go test … ./internal/app/service/... | returns true on empty/intersect/owner/assignee; false otherwise; all callers compile |
| 4 | BE | 6 handler guards + single-get guard + assoc/delete | internal/app/handler/contact_handler.go; internal/app/service/get_contact.go | go test … ./internal/app/handler/... | out-of-scope get/patch/delete/assoc → 403; in-scope → 200 |
| 5 | BE | Write validation (team_owner_ids ⊆ viewer teams) | internal/app/payload/contact_sync_request.go; handler | go test … | non-member team → 422 TEAM_OWNER_INVALID; empty allowed |
| 6 | BE | Fallback + events + flag gate | permission_service.go; require_permission_middleware.go/config | go test … | flag OFF → legacy; Launchpad timeout → OWNED + cdp_team_permission_fallback |
| 7 | BE | Notes parent-scope projection + service eval + resolveNotePermission team branch | contact_notes/{base.go,read.go}; contact_notes_service.go; contact_notes_handler.go; regenerate mocks | go test … ./internal/app/service/contact_notes/... | notes out-of-scope → 403; permission.{update,delete} reflect team scope |
| 8 | FE | getMyTeams() + Contact type + TeamOwnerSelect.vue + form wiring | UserStore.ts; CustomerStore.ts; common/components/field/TeamOwnerSelect.vue; CustomerDetails.vue | pnpm vitest; pnpm eslint . | picker shows my teams; emits ids; auto-fill all teams; clear→Unassigned |
| 9 | FE | Read level; /customers/teams; notes gating consts | ListPage.vue; DetailPage.vue; SidebarChildCustomer.vue; Breadcrumb.vue; Notes/{constants.ts,Notes.vue,CustomerActivityV2.vue,NotesList.vue}; middleware/authenticated.global.ts; pages/customers/add.vue | pnpm vitest; pnpm build | team list view filters; notes view/add gate on level; edit/delete follow note.permission |
| 10 | Mobile | Gate add/edit on per-note permission | note_screen.dart; detail_note_screen.dart; other_tab.dart | melos run analyze; melos run test | add/edit/delete gate on note.permission.{update,delete} |
| 11 | BE | Migration backfill (CHG-004) — CDP backfill job | new CDP one-off job reading crm_people.crm_team_hierarchy_id → name-map → team_owner_ids | run 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 vitest3.pnpm build - Mobile: 1.
melos run analyze2.melos run test
Post-deploy signals:
- Dashboard (CDP Squad):
cdp_team_permission_fallbackrate < 1% (24h);cdp_team_permission_deniednot spiking >3× baseline; 5xx < 0.5%; list P95 ≤ 2s; coverage (% non-emptyteam_owner_ids) ≥ 95% post-backfill.
Rollback recipe (deploy-order aware):
- Flip
cdp_team_permission_enabledOFF for the affected tenant(s) → instant return to legacyteam==everything. - If a bad BE deploy: revert the contact-service PR (FE/mobile remain compatible — additive field).
- If the index is problematic:
migrate … downto drop033(filter falls back to scan; only relevant when flag ON). - Confirm
cdp_team_permission_fallback/deniedreturn 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_idsarrays (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_segmentsalready 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/v1is wrong (actual/private). (Re-verified on the canonical checkoutDocuments/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 receivesexpanded_team_ids. See Decision 4 + §2.4 Dependency endpoint. - OQ-11 /
REV-3— RESOLVED. Underteam/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 readscrs_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_viewvs legacycrm_view(cross-system reconciliation; out of this RFC's code scope). - OQ-6 Do
profileview/profilemanageneed 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
_addgoverned 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-18 | RFC 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-18 | RFC 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-18 | RFC 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 + BErows have both halves; TEAM-S01 markedCross-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
033JSON; per-status lifecyclen/a(no enum). - APIs — ✅ outbound tagged
extended; teams-for-user contract pinned normatively (§2.4) + taggednew-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-reviewerfor a second-pass score once the three OQ-NEW blockers are closed.