Skip to main content

[DRAFT][RFC] Phase 1 — Bot & AI Creation Parity: Backend (qontak.com)

Document Conventions

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Replace placeholder values; mark sections N/A — reason when truly inapplicable.

It is also agent-execution-ready: the §1 PRD-to-Schema Derivation, §2 Repo Reading Guide (Detail 2.0), Infrastructure Topology, ADR-format Technical Decisions, mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

Scope boundary: This RFC covers only qontak.com CRM backend changes to accept creator_flag on deal/ticket create. Creator attribution uses audits.username (via as_user); channel origin stored in data_source. Timeline note: deals reuse Crm::HubChannelTicket; tickets get a new create_ticket_notes helper → Crm::HubChannelTicketV2. Contact association via customer_ids for tickets requires two new files: Contacts::LinkTicketByCustomerIdService + Worker. No new tables, no feature flag.

Metadata

FieldValueNotes
StatusIDEAIDEA / RFC / ABANDON / AGREED
OwnercrmCRM squad owns qontak.com backend
Author(s)Ardian Pradipta
ReviewersCRM Eng lead · Chatbot Squad · Agentic AI SquadCross-squad review required
Approver(s)CRM Tech Lead · InfoSec approverRequired before §7 flips to yes
Submitted Date2026-07-02ISO-8601
Last Updated2026-07-02
Target Release2026-Q3Quarter target
Related DocumentsPRDPRD v1.2
DiscussionTBDSlack thread or review doc

Type: backend Sub-type: enhancement

Sections at a Glance

  1. Overview (PRD-to-Schema Derivation, Traceability, Per-Story Change Map)
  2. Technical Design (Infrastructure Topology, ADRs, Repo Reading Guide, Architecture, Sequence, APIs, Branch catalog)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (Agent Execution Plan, Verification & Rollback)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

Table of Contents

[TOC]


1. Overview

Problem & Scope

Deals/tickets created by Chatbot or Agentic AI show no creator attribution — data_source remains 'open-api' and the timeline shows no "Bot"/"AI" creator label. This RFC adds creator_flag acceptance to the existing POST create endpoints. When present: data_source is set to the creator_flag value (channel-origin tracking); audits.username is set via as_user(creator_flag) so the timeline displays "Bot"/"AI" via audit.rb#mapping_who. When creator_flag + channel_integration_room_id are both present, a timeline note is created — Crm::HubChannelTicket for deals (existing create_notes pattern), Crm::HubChannelTicketV2 for tickets (new create_ticket_notes helper).

Success Criteria

MetricTarget
Deals with creator_flag: 'bot' have data_source = 'bot'100%
Tickets with creator_flag: 'agentic_ai' have data_source = 'agentic_ai'100%
Timeline entry exists when channel_integration_room_id + creator_flag present100%
Existing creates without creator_flag behave identically100% backward compat

Out of Scope

  • New timeline tables (existing Crm::HubChannelTicket / Crm::HubChannelTicketV2 reused)
  • Chat history fetch outside existing flow
  • Feature flag
  • Omnichannel notification APIs
  • FE rendering
DocumentPath
PRDcrm/consistent-deal-ticket-creation/prds/phase-1-bot-ai-creation-parity.md

Assumptions

#AssumptionRiskVerification
A1Chatbot/AI squads send creator_flag + room_id in create payloadHighAlign at sprint planning
A2data_source column accepts any string (no CHECK constraint)NoneVerified: db/schema.rb:1394

Dependencies

DependencyOwnerDeliverableBlocking?
Chatbot enhanced payloadChatbot Squadcreator_flag + room_id in POST createYES
Agentic AI enhanced payloadAgentic AI Squadcreator_flag + room_id in POST createYES

PRD-to-Schema Derivation

PRD entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced wherePRD section #
creator_flag ("bot" / "agentic_ai") — creation channel origincrm_deals.data_source / tickets.data_source (records the channel through which the object was created, same purpose as 'open-api', 'mobile', 'webhook_chat')GET /api/mobile/v2.8/crm/deals/:id · GET /api/mobile/v2.8/crm/tickets/:id (data_source field — for channel tracking, not creator label)Controller param permit + validation§6 CHG-002, §8 #1-2
creator_flagtimeline creator display nameaudits.username = creator_flag value (set via Audited.audit_class.as_user(creator_flag) wrapping both the deal/ticket save and the timeline note create)GET /api/mobile/v2.8/crm/deals/:id/timeline · GET /api/mobile/v2.8/crm/tickets/:id/timelinedefine_who resolved by audit.rb#mapping_who"Bot" / "Agentic AI"audit.rb:1824 extended to map 'bot'"Bot", 'agentic_ai'"Agentic AI"ADR-1, ADR-2, C7
channel_integration_room_id (room association)crm_deals.channel_integration_room_id / tickets.channel_integration_room_id (existing columns)GET /api/mobile/v2.8/crm/deals/:id · GET /api/mobile/v2.8/crm/tickets/:idAlready permitted; no change§8 #1-2
Contact associationExisting crm_people_deals / people_tickets join tablesGET /api/mobile/v2.8/crm/deals/:id/contacts · GET /api/mobile/v2.8/crm/tickets/:id/contactsExisting service logic (CreateService#378); tickets: new LinkTicketByCustomerIdWorker§8 #1, PRD §9 S03
Timeline entry (chat history + room deeplink)Deals: Crm::HubChannelTicket (existing STI). Tickets: Crm::HubChannelTicketV2 (existing STI, new helper)GET /api/mobile/v2.8/crm/deals/:id/timeline · GET /api/mobile/v2.8/crm/tickets/:id/timeline (via audited)Created when both creator_flag + room_id presentADR-2, §7, PRD §9 S04

Detail 1.A — PRD Traceability

Forward (PRD → RFC):

PRD requirementService / endpoint / jobRFC section
Accept creator_flag in deal creationPOST /api/v4/deals · POST /api/v3.1/deals§2.4
Accept creator_flag in ticket creationPOST /api/v4/tickets · POST /api/v3.1/tickets§2.4
Map creator_flagdata_sourceDeal/Ticket create services§2.4
Auto-associate contact via crm_lead_idsExisting param already wired — no change§2.4
Auto-associate contact via customer_ids (deals)Follow v3.1 pattern (v3dot1/deals.rb:1354): pre-save async/sync branch, post-save Contacts::LinkDealByCustomerIdWorker.perform_async per customer_id§2.4, Detail 2.0
Auto-associate contact via customer_ids (tickets)No equivalent worker exists — new Contacts::LinkTicketByCustomerIdService + Contacts::LinkTicketByCustomerIdWorker required, mirroring deal counterparts; uses PeopleTicket join model instead of Crm::PeopleDeal§2.4, ADR-4, C2-C3
Create timeline entry with chat context (deals)Existing create_notesCrm::HubChannelTicket when creator_flag + room_id present§2.4, ADR-2
Create timeline entry with chat context (tickets)New create_ticket_notes helper → Crm::HubChannelTicketV2 when creator_flag + room_id present§2.4, ADR-2, C4
Expose data_source on readGET /api/mobile/v2.8/crm/deals/:id · GET /api/mobile/v2.8/crm/tickets/:id entity§2.4
Creator label "Bot" / "AI"audits.usernameaudit.rb#mapping_whodefine_who in timeline response; FE renders define_whoADR-1, C7

Reverse (RFC → PRD):

New endpoint / table / dependencyPRD need it serves
creator_flag param on existing POST endpoints§6 CHG-002 — creator attribution (CDTC-S06)
as_user(creator_flag)audits.username + mapping_who extension§7 — timeline creator label "Bot"/"AI" (CDTC-S06)
Conditional Crm::HubChannelTicket (deals) / Crm::HubChannelTicketV2 (tickets) creation§7 — timeline log with chat history (CDTC-S04)
New Contacts::LinkTicketByCustomerIdService + Worker§9 S03 — contact auto-association for ticket customer_ids (CDTC-S03)

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE endpoint)Required writes (BE endpoint)Status surface
Deal/Ticket detail — Timeline (creator label + chat history note)FE web + mobile appGET /api/mobile/v2.8/crm/deals/:id/timeline · GET /api/mobile/v2.8/crm/tickets/:id/timelinedefine_who = "Bot" / "Agentic AI" from audit.rb#mapping_who; chat note entry from Crm::HubChannelTicket / Crm::HubChannelTicketV2
Deal/Ticket create (bot/AI)External API clients only (Chatbot, Agentic AI)POST /api/v4/deals · POST /api/v4/tickets · POST /api/v3.1/deals · POST /api/v3.1/ticketsFE never calls these create endpoints

Role Coverage

PRD roleAuthorization mechanismEndpoints permittedCross-tenant?Audit trail
Chatbot / Agentic AI (system)Company token / API keyPOST /api/v4/deals · POST /api/v4/tickets · POST /api/v3.1/deals · POST /api/v3.1/tickets (create only)No — tenant-scoped via tokendata_source records origin
Sales Agent / CS Agent (FE web + mobile)Devise session + CanCanCanGET /api/mobile/v2.8/crm/deals/:id/timeline · GET /api/mobile/v2.8/crm/tickets/:id/timelineNoRead-only; define_who from audit.rb#mapping_who → "Bot"/"AI" rendered by FE

PRD Section Coverage

PRD section #TitleWhere covered (RFC section) or n/a — reason
§1One-liner + Problem§1.0
§2If we don't ship§1.0 (motivation)
§3Personas§1 Detail 1.A Role Coverage
§4Non-Goals§1.0 Out of Scope
§5Constraints§3
§6Feature Changes (CHG-001/002)§2.4 APIs
§7New Feature (Timeline log)ADR-2 — Crm::HubChannelTicket (deals) + Crm::HubChannelTicketV2 (tickets)
§8API & Webhook Behavior (#1-2 only)§2.4 APIs
§9System Flow + Stories + ACs§1.C Per-Story Change Map
§10Rollout§4 Rollout
§11Observability§3
§12Success Metrics§1.0 Success Criteria
§13Launch Plan & Stage Gates§4 Rollout
§14Dependencies§1 Dependencies
§15Key Decisions§2 ADRs
§16Open Questions§5

Detail 1.B — Key Decisions Summary

#DecisionChosen option§2 ADR block
1Creator label displayaudits.username via as_user(creator_flag) + mapping_who extension (not data_source)ADR-1
2Timeline note modelCrm::HubChannelTicket for deals, Crm::HubChannelTicketV2 for tickets — not a single shared modelADR-2
3Feature flagNone — param is self-gatingADR-3
4customer_ids ticket associationNew Contacts::LinkTicketByCustomerIdService + Worker (not generalise deal counterpart)ADR-4

Detail 1.C — Per-Story Change Map

Layer scope values: BE-only, BE + FE consumes existing, Cross-squad.

Story #TitleLayer scopeChangesAcceptance criteria (verifiable)RFC anchors
CDTC-S01Preview in chat roomCross-squadAccept creator_flag + room_id on POST createdata_source set correctly; note created§2.4, ADR-1
CDTC-S02Navigate room → detailN/A — FE (hub-chat)n/a
CDTC-S03Auto-associate contactBE (reuse + new)crm_lead_ids: existing, no change. customer_ids deals: follow v3.1 pattern — Contacts::LinkDealByCustomerIdWorker. customer_ids tickets: new Contacts::LinkTicketByCustomerIdWorker + serviceContact linked§2.4, ADR-4, Detail 2.0
CDTC-S04Timeline log with chat previewBE (reuse + extend)Deals: Crm::HubChannelTicket via existing create_notes. Tickets: Crm::HubChannelTicketV2 via new create_ticket_notes helperTimeline entry appears for bothADR-2, §2.4
CDTC-S05Navigate timeline → roomBE (reuse)Room deeplink handled by existing Crm::HubChannelTicket / Crm::HubChannelTicketV2 behaviorDeeplink presentADR-2
CDTC-S06Creator label "Bot"/"AI"BE-onlyaudit.rb#mapping_who extended: 'bot'"Bot", 'agentic_ai'"Agentic AI"; FE reads define_who from timeline auditdefine_who = "Bot" / "Agentic AI" in timeline responseADR-1, C7
NEG-01/02/03Guard railsConfig/behaviorNo change to manual flow; source value preserved§2.C

2. Technical Design

Infrastructure Topology

No new tables, queues, or feature flags. Two new Sidekiq worker + service files for ticket customer_ids association (ADR-4). All other changes are additive param acceptance on existing endpoints.

Deployment topology

flowchart TB
internet([Internet]) -->|HTTPS| lb[Load Balancer]
lb -->|HTTP| pods["CRM API pods xN"]
pods -->|read/write| db_primary[(Postgres primary)]
pods -->|read-only| db_replica[(Postgres replica)]
pods -->|get/set| cache[(Redis cache)]

Per-service responsibility

ServiceUse cases (this RFC)Internal calls (owner)External calls
qontak.com CRMAccept creator_flag on POST deal/ticket create; set data_source + audits.username via as_user; create Crm::HubChannelTicket (deals) / Crm::HubChannelTicketV2 (tickets) note when room_id present; dispatch LinkTicketByCustomerIdWorker for ticket customer_idsContacts::LinkTicketByCustomerIdService [NEW]

Technical Decisions (ADR-format)

ADR-1: Creator label via audits.username (not data_source)

Context: PRD requires "Bot"/"AI" displayed as creator in the timeline. data_source is an origin-channel tracker (values: 'open-api', 'mobile', 'webhook_chat', 'omnichannel') — it records how an object entered the system, not who created it. The timeline creator label is already resolved by audit.rb#mapping_who from audits.username, set at write time via Audited.audit_class.as_user(actor). creator_id is a User FK and cannot hold a non-user string actor.

Options considered:

  • Option A — Set audits.username = creator_flag via as_user(creator_flag): Wrap the entire deal/ticket save block (and subsequent note creation) in Audited.audit_class.as_user(params[:creator_flag]). mapping_who falls through to the username branch at audit.rb:1824 — extend it to map 'bot'"Bot", 'agentic_ai'"Agentic AI".
    • Pros: Correct semantic. No schema change. Follows the same pattern used for 'hub'/'central'/'Qontak system'. Creator label controlled entirely in one place (mapping_who).
    • Cons: None significant.
  • Option B — Use data_source for creator label: Store 'bot'/'agentic_ai' in data_source and have FE read it as the creator label.
    • Cons: Wrong semantic — data_source is channel origin, not actor identity. Conflates two unrelated concerns. FE would need custom logic to disambiguate "this data_source value means a creator label, not a channel".

Decision: Option A

Rationale: audits.username is the canonical actor field for timeline display. data_source retains its meaning as channel-origin tracker and may independently receive 'bot'/'agentic_ai' values to signal creation channel — but that is orthogonal to the creator label. The display label is owned by audit.rb#mapping_who, consistent with how system actors ('hub', 'Qontak system') are already handled.

Consequences: audit.rb:1824 extended with two new username mappings. data_source receives 'bot'/'agentic_ai' as a legitimate channel-origin value — same semantic as 'open-api', 'webhook_chat'. Creator label is independently owned by audits.username.

Exact patch for audit.rb:1824 — replace the else branch inside the user.present? outer else block:

# BEFORE (audit.rb:1823-1828):
else
if ['central', 'hub', 'Qontak system'].include?(self.username)
self.define_who = "Qontak system"
else
self.define_who = "<deleted user>"
end
end

# AFTER:
else
if ['bot', 'agentic_ai'].include?(self.username)
self.define_who = self.username == 'bot' ? 'Bot' : 'Agentic AI'
elsif ['central', 'hub', 'Qontak system'].include?(self.username)
self.define_who = "Qontak system"
else
self.define_who = "<deleted user>"
end
end

Bot/AI check goes before the system-actor check — both are non-user string actors; order within the else block does not affect the existing 'Qontak system' branch.

creator_id behavior: creator_id remains a User FK and is always set to @current_user.id via return_valid_creator_id (crm_referencable.rb:845) — i.e. the service account user that holds the company token. It is not nulled out or overridden for bot/AI records. This is correct: creator_id means "which user account executed this call"; the timeline creator label is a separate concern owned by audits.username. Do not attempt to clear or fake creator_id for bot/AI creates.

Reversibility: High. Remove the two mapping_who entries → falls back to "<deleted user>". No schema changes.


ADR-2: Timeline entry — Crm::HubChannelTicket for deals, Crm::HubChannelTicketV2 for tickets (not new table or worker)

Context: PRD describes a timeline log entry with chat history + room deeplink. The two models are distinct STI subclasses with different parent associations:

ModelParentaudited associated_withUsed for
Crm::HubChannelTicketCrm::DealNote:crm_dealDeals
Crm::HubChannelTicketV2TicketNote:ticketsTickets

Deals already have a create_notes helper (deals.rb:120-138) that creates Crm::HubChannelTicket. Tickets have no equivalent helper today — Crm::HubChannelTicketV2 is created in hub/ticket/new_ticket_service.rb:226 and hub/ticket/v2/note_creator.rb:50, but not wired into the v4/v3.1 ticket create endpoint.

Options considered:

  • Option A — Reuse existing models, add ticket create_notes equivalent: Deals call existing create_notesCrm::HubChannelTicket. Tickets get a parallel create_ticket_notes helper → Crm::HubChannelTicketV2. No new infrastructure.
    • Pros: Correct model per object type. Follows established pattern (hub/ticket/new_ticket_service.rb:226). Timeline auto-populated by audited.
    • Cons: Small new helper method needed for ticket endpoint.
  • Option B — New table + async worker: Scope of original draft.
    • Cons: Unnecessary complexity. Existing models handle the same use case.

Decision: Option A

Rationale: Crm::HubChannelTicket and Crm::HubChannelTicketV2 are already the canonical note types for deals and tickets respectively — confirmed by hub/ticket/new_ticket_service.rb, note_creator.rb, and audit.rb:1558. Using the wrong type on a ticket would associate the note to the wrong parent and break timeline rendering.

Consequences: Deals use existing create_notesCrm::HubChannelTicket (unchanged). Ticket create endpoint gets a new create_ticket_notes helper → Crm::HubChannelTicketV2, mirroring hub/ticket/new_ticket_service.rb:217-233 (ticket_id in parameters, type: 'Crm::HubChannelTicketV2'). Both wrapped in as_user(audited_actor).

create_ticket_notes full spec:

def create_ticket_notes(params, ticket_id)
source = { "email": 3, "wa": 8, "wa_cloud": 8, "telegram": 9, "fb": 11, "ig": 13,
"twitter": 14, "line": 15, "livechat_dot_com": 16, "web_chat": 17,
"qontak": 18, "tokopedia_chat": 19, "app_chat": 20, "unknown": 21,
"ig_comment": 22, "shopee": 23 }
source_id = source[params[:crm_note_type].to_s.to_sym]
note_type = Crm::NoteType.find_by(id: source_id)

# Guard: if crm_note_type absent or unresolvable, skip silently
return unless note_type.present?

# Dedup: one note per ticket per team
return if Crm::HubChannelTicketV2.exists?(ticket_id: ticket_id, team_id: @current_user.team_id)

parameters = {
channel_room_id: params[:channel_integration_room_id],
note: note_type.note_type,
crm_person_id: params[:crm_person_id],
crm_note_type_id: note_type.id,
team_id: @current_user.team_id,
organization_id: @current_user.organization_id,
creator_id: @current_user.id,
type: 'Crm::HubChannelTicketV2',
channel_organization_id: params[:channel_integration_organization_id],
ticket_id: ticket_id
}

Crm::HubChannelTicketV2.create!(parameters)
rescue StandardError => e
# Non-blocking: note failure never blocks ticket creation
Rails.logger.warn("[create_ticket_notes] failed ticket_id=#{ticket_id} error=#{e.message}")
end

Reversibility: High. Stop calling the helper, no data loss.


ADR-3: No feature flag

Context: creator_flag is an optional additive param. Absent → existing behavior. Producers adopt when ready.

Options considered:

  • Option A — No flag: Param is self-gating.
  • Option B — Server-side flag: Adds rollout complexity with no benefit — the flag would gate nothing that the absence of creator_flag doesn't already gate.

Decision: Option A

Rationale: Absent param = existing behavior. Producers control when they start sending creator_flag. No flag needed.


ADR-4: New Contacts::LinkTicketByCustomerIdService + Worker (not reuse deal counterpart)

Context: Tickets need the same customer_ids async association pattern as deals (v3.1:1354). The deal side has Contacts::LinkDealByCustomerIdServiceContacts::LinkDealByCustomerIdWorker. No ticket equivalent exists. The join model is PeopleTicket (crm_person_id + ticket_id) vs Crm::PeopleDeal (crm_person_id + crm_deal_id).

Options considered:

  • Option A — New service + worker mirroring deal counterparts: Contacts::LinkTicketByCustomerIdService with PeopleTicket.find_or_create_by, Contacts::LinkTicketByCustomerIdWorker with identical queue/retry/exhausted-log shape.
    • Pros: Exact parity. Self-contained. Easy to test independently.
    • Cons: Two new files (minimal — same shape as existing).
  • Option B — Generalize existing service to handle both: Add record_type: param to LinkDealByCustomerIdService.
    • Cons: Touches tested production code. Polymorphic branching adds complexity. Existing callers unaffected anyway — not worth the risk.

Decision: Option A

Rationale: Smallest safe change. Deal service is tested and in production — no reason to touch it. Two ~50-line files following an established template.

Consequences: Two new files: app/services/contacts/link_ticket_by_customer_id_service.rb, app/workers/contacts/link_ticket_by_customer_id_worker.rb.

Reversibility: High. Stop dispatching the worker; no schema changes.


Detail 2.0 — Repo Reading Guide

Repo Map

flowchart LR
subgraph crm[BE: qontak.com]
subgraph controllers[Controllers]
v4d["api/v4/deals.rb"]
v4t["api/v4/tickets.rb"]
v31d["api/v3dot1/deals.rb"]
v31t["api/v3dot1/tickets.rb"]
end

subgraph notes[Timeline Notes]
hct["models/crm/hub_channel_ticket.rb\n(Crm::DealNote — deals)"]
hctv2["models/crm/hub_channel_ticket_v2.rb\n(TicketNote — tickets)"]
note_tmpl["services/hub/ticket/new_ticket_service.rb\n(create_note_ticket — V2 template)"]
end

subgraph contact_assoc[Contact Association]
svc_d["services/contacts/link_deal_by_customer_id_service.rb"]
wrk_d["workers/contacts/link_deal_by_customer_id_worker.rb"]
svc_t["services/contacts/link_ticket_by_customer_id_service.rb [NEW]"]
wrk_t["workers/contacts/link_ticket_by_customer_id_worker.rb [NEW]"]
pt["models/people_ticket.rb"]
end

subgraph models[Models / Schema]
deal_m["models/crm/deal.rb"]
schema["db/schema.rb"]
audit["models/audit.rb\n(mapping_who — extended)"]
end
end

v4d -->|create_notes| hct
v4t -->|create_ticket_notes NEW| hctv2
note_tmpl -.->|template for| hctv2
v4d -->|customer_ids async| wrk_d
v4t -->|customer_ids async| wrk_t
wrk_d --> svc_d
wrk_t --> svc_t
svc_d --> pt
svc_t --> pt

Existing Code Anchors

PathWhy the agent reads itWhat pattern it teaches
app/controllers/api/v4/deals.rb:72-118Deal create handlerdata_source hardcoded at line 90; channel_integration_room_id at 92-93
app/controllers/api/v4/deals.rb:120-138create_notes — creates Crm::HubChannelTicket for deal timelineExisting pattern to reuse for deals
app/services/hub/ticket/new_ticket_service.rb:217-233create_note_ticket — creates Crm::HubChannelTicketV2 for ticket timelineTemplate for new create_ticket_notes helper in ticket create endpoint
app/models/crm/hub_channel_ticket.rbCrm::HubChannelTicket < Crm::DealNote, audited associated_with: :crm_dealDeal-specific note model
app/models/crm/hub_channel_ticket_v2.rbCrm::HubChannelTicketV2 < TicketNote, audited associated_with: :ticketsTicket-specific note model
app/controllers/api/v4/tickets.rb:57-74Ticket create handlerdata_source = 'open-api-v4' at 62
app/controllers/api/v3dot1/deals.rb:1354-1403customer_ids async/sync branch + Contacts::LinkDealByCustomerIdWorker dispatchTemplate for customer_ids handling in v4 deal/ticket create
app/services/contacts/link_deal_by_customer_id_service.rbDeal-contact association serviceTemplate for new LinkTicketByCustomerIdService — same shape, Crm::PeopleDealPeopleTicket, crm_deal_idticket_id
app/workers/contacts/link_deal_by_customer_id_worker.rbDeal-contact async workerTemplate for new LinkTicketByCustomerIdWorker — same queue/retry/exhausted-log shape
app/models/people_ticket.rbPeopleTicket join model (crm_person_id + ticket_id)Target join model for ticket-contact association
app/services/crm/deals/create_service.rb:381-398create_deal_note — mobile v2.8 equivalentTemplate for HubChannelTicket creation
db/schema.rb:1394crm_deals.data_source columncharacter varying default 'web'
db/schema.rb:4961tickets.data_source columncharacter varying
app/models/crm/deal.rb:150belongs_to :creator, class_name: 'User'Confirms no string actor field today

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
POST /api/v4/dealsextendedAdd :creator_flag, customer_ids branch to permitted paramsCRM BE
POST /api/v4/ticketsextendedSameCRM BE
POST /api/v3.1/dealsextendedSameCRM BE
POST /api/v3.1/ticketsextendedSameCRM BE
create_notes (deals.rb:120-138)reusedCall when creator_flag + room_id presentCRM BE
Contacts::LinkDealByCustomerIdServicereused (template only)Reference implementation for new ticket serviceCRM BE
Contacts::LinkDealByCustomerIdWorkerreused (template only)Reference implementation for new ticket workerCRM BE
Contacts::LinkTicketByCustomerIdServicenewTicket-contact association by customer_id via PeopleTicketCRM BE
Contacts::LinkTicketByCustomerIdWorkernewAsync wrapper dispatched after ticket saveCRM BE

Patterns to Follow

ConcernPattern in repoReference fileDeviation in this RFC?
Controller param permittingpermit with explicit symbol listdeals.rb:72-118Add :creator_flag, :customer_ids, :async_lead_assoc
Note creation for timelineCrm::HubChannelTicket.create! wrapped in Audited.audit_class.as_userdeals.rb:120-138, create_service.rb:381-398Identical — reuse as-is
data_source assignmentAPI controller sets data_source on new recordsdeals.rb:90 (hardcodes 'open-api')Same, but conditional on creator_flag
customer_ids async worker dispatch (deals)Pre-save branch → post-save perform_async per idv3dot1/deals.rb:1354-1403Ported to v4 deals — identical pattern
customer_ids async worker dispatch (tickets)Same pattern, new workerlink_deal_by_customer_id_worker.rb (template)New LinkTicketByCustomerIdWorker + service

Reading Order for the Agent

  1. app/controllers/api/v4/deals.rb:72-118 — deal create handler: data_source, creator_id, channel_integration_room_id patterns
  2. app/controllers/api/v4/deals.rb:120-138create_notesCrm::HubChannelTicket: deal timeline note pattern to reuse
  3. app/services/hub/ticket/new_ticket_service.rb:217-233create_note_ticketCrm::HubChannelTicketV2: template for ticket timeline note helper
  4. app/models/crm/hub_channel_ticket.rb + hub_channel_ticket_v2.rb — confirm STI parent and audited associated_with per object type
  5. app/controllers/api/v3dot1/deals.rb:1354-1403customer_ids async/sync branch + LinkDealByCustomerIdWorker dispatch (primary template for controller)
  6. app/services/contacts/link_deal_by_customer_id_service.rbtemplate for new ticket service
  7. app/workers/contacts/link_deal_by_customer_id_worker.rbtemplate for new ticket worker
  8. app/models/people_ticket.rb — join model for ticket-contact association
  9. app/controllers/api/v4/tickets.rb:57-74 — ticket create handler
  10. app/services/crm/deals/create_service.rb:381-398 — mobile v2.8's create_deal_note (additional reference)
  11. db/schema.rb:1394,4961 — column types for data_source
  12. app/models/crm/deal.rb:150creator is User FK

Source Verification

Anchor / pattern / contractVerified byEvidence
data_source is string, default 'web', no CHECKdb/schema.rb:1394character varying default 'web'::character varying
channel_integration_room_id column existsdb/schema.rb:1392channel_integration_room_id character varying
data_source hardcoded in v4 deal createdeals.rb:90crm_deal.data_source = 'open-api'
data_source hardcoded in v4 ticket createtickets.rb:62ticket_par[:data_source] = 'open-api-v4'
creator_id is User FKcrm/deal.rb:150belongs_to :creator, class_name: 'User'
create_notes creates Crm::HubChannelTicket (deals)deals.rb:120-137type: 'Crm::HubChannelTicket', audited associated_with: :crm_deal
create_note_ticket creates Crm::HubChannelTicketV2 (tickets)hub/ticket/new_ticket_service.rb:217-233type: 'Crm::HubChannelTicketV2', ticket_id: in params, audited associated_with: :tickets
Crm::HubChannelTicketV2 is tickets-onlyapp/models/crm/hub_channel_ticket_v2.rb:1-2class Crm::HubChannelTicketV2 < TicketNote — wrong model if used on a deal
audit.rb:1558 handles both note typesaudit.rb:1558note_class_name.eql?("Crm::HubChannelTicket") || note_class_name.eql?("Crm::HubChannelTicketV2")
create_deal_note in mobile v2.8create_service.rb:381-398Same Crm::HubChannelTicket pattern
No LinkTicketByCustomerIdService/Worker existsfind /app/workers/contactsOnly link_deal_by_customer_id_worker.rb present — new files required
PeopleTicket join model shapeapp/models/people_ticket.rb:1-5crm_person_id + ticket_id FKs, audited associated_with: :ticket

Detail 2.1 — Architecture

Component diagram

flowchart TB
caller([API caller]) --> api[/v4 · v3.1 deals · tickets API/]
api --> svc[create handler]
svc -->|as_user creator_flag| audit_write[(audits table\nusername=creator_flag)]
svc -->|data_source=creator_flag| db[(Postgres)]
svc -->|room_id present - deals| hct[Crm::HubChannelTicket]
svc -->|room_id present - tickets| hctv2[Crm::HubChannelTicketV2]
hct --> audit_write
hctv2 --> audit_write
hct --> db
hctv2 --> db
svc -->|customer_ids async - tickets| wrk[LinkTicketByCustomerIdWorker]
wrk --> svc2[LinkTicketByCustomerIdService]
svc2 --> db

Detail 2.2 — Sequence

Happy path — bot deal creation with timeline note

sequenceDiagram
actor Bot as Chatbot API
participant LB as Load Balancer
participant API as CRM API pod
participant DB as Postgres primary

Bot->>LB: POST /v4/deals (creator_flag:bot, room_id)
LB->>API: HTTP
API->>API: permit params, validate creator_flag
API->>API: set audited_actor = creator_flag ("bot")
note over API: Audited.as_user("bot") block begins
API->>DB: INSERT deal (data_source=bot, channel_integration_room_id)<br/>audits.username="bot"
DB-->>API: commit
API->>DB: INSERT Crm::HubChannelTicket note<br/>audits.username="bot"
DB-->>API: commit
note over API: Audited.as_user block ends
API-->>Bot: 201 Created

Failure path — invalid creator_flag

sequenceDiagram
actor Bot as Chatbot API
participant API as CRM API pod

Bot->>API: POST /v4/deals (creator_flag:invalid)
API->>API: validate creator_flag
alt value not bot or agentic_ai
API-->>Bot: 422 INVALID_CREATOR_FLAG
else absent
API->>API: skip mapping, default behavior
API-->>Bot: 201 (data_source unchanged)
end

Detail 2.3 — Database Model (DDL)

No DDL changes. The data_source column on crm_deals (schema.rb:1394) and tickets (schema.rb:4961) already exists as a free-form character varying with no CHECK constraint — new values 'bot' and 'agentic_ai' are accepted without migration.

Crm::HubChannelTicket (Crm::DealNote STI) and Crm::HubChannelTicketV2 (TicketNote STI) already exist — no schema changes needed.

PII classification: Crm::HubChannelTicket stores chat message content which may contain PII. Existing retention and access policies apply.

Cardinality: No new tables. Note volume matches existing deal/ticket creation volume.

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

| Endpoint | Method | AuthN/AuthZ | Request schema changes | Response changes | Status codes | Reuse? | |---|---|---|---|---|---|---|---| | /api/v4/deals | POST | Company token / Doorkeeper | Add optional creator_flag "bot"|"agentic_ai" (case-sensitive, max 20 chars); customer_ids: string[] (qontak_customer_id values); async_lead_assoc: boolean (default false) | data_source may be 'bot'/'agentic_ai'; 201 body unchanged (data_source NOT exposed in entity) | 201, 422, 401, 403 | extended | | /api/v4/tickets | POST | Company token / Doorkeeper | Same | Same | Same | extended | | /api/v3.1/deals | POST | Company token / Doorkeeper | Same | Same | Same | extended | | /api/v3.1/tickets | POST | Company token / Doorkeeper | Same | Same | Same | extended |

creator_flag validation:

  • Valid: 'bot', 'agentic_ai' (case-sensitive)
  • Absent/empty → default (data_source unchanged, no note)
  • Invalid → 422 INVALID_CREATOR_FLAG (generic message — allowed values not disclosed)

201 response schema (no change from existing entity):

data_source is not exposed in V4::Entities::Deal (app/controllers/api/v4/entities/deal.rb) or V4::Entities::Ticket (app/controllers/api/v4/entities/ticket.rb) — verified. The 201 response body is identical to a normal create. creator_flag is not echoed back. No entity change needed.

// POST /api/v4/deals — 201 response (unchanged shape)
{
"meta": 1,
"response": {
"id": 123,
"name": "Deal name",
"creator_id": 456,
"creator_name": "Service Account Name",
"idempotency_key": "uuid",
// ... existing fields unchanged
// data_source NOT exposed — channel origin is internal
}
}
// POST /api/v4/deals — 422 invalid creator_flag
{ "meta": { "message": "invalid creator_flag" } }

// POST /api/v4/deals — 404 customer_ids not found
{ "meta": { "message": "<validation_result error message>" } }

data_source mapping + audited actor override:

creator_flag is present → wrap the entire deal save block (and the subsequent note creation) in as_user(creator_flag) instead of as_user(@current_user). This sets audits.username = creator_flag on every audit record produced during that create — the deal's own creation audit and the Crm::HubChannelTicket note audit — so the timeline shows "Bot" / "Agentic AI" as creator for both entries.

# deals.rb — before create(), mirrors v3dot1/deals.rb:1354
customer_ids_for_worker = nil
if params[:customer_ids].present?
if params[:async_lead_assoc] == true && params[:crm_lead_ids].blank?
customer_ids_for_worker = params[:customer_ids]
else
validation_result = dl.validate_and_convert_customer_ids(params)
unless validation_result[:status]
status 404
return present :meta, { "message": validation_result[:error] }, with: V4::Entities::Meta::NotFound
end
end
end

# deals.rb ~926 — replace existing as_user(@current_user) block when creator_flag present
audited_actor = params[:creator_flag].presence || @current_user

Audited.audit_class.as_user(audited_actor) do
# direct assignment — creator_flag IS the data_source value; allowlist validation already ran
crm_deal.data_source = params[:creator_flag] if params[:creator_flag].present?

if crm_deal.save
# ... existing post-save logic unchanged ...

# note creation — inherits same as_user context
if params[:channel_integration_room_id].present?
if params[:creator_flag].present?
dl.create_notes(params, crm_deal.id)
elsif params[:crm_note_type].present?
dl.create_notes(params, crm_deal.id) # existing behavior unchanged
end
end

# customer_ids async path — mirrors v3dot1/deals.rb:1394-1402
if customer_ids_for_worker.present?
customer_ids_for_worker.each do |customer_id|
::Contacts::LinkDealByCustomerIdWorker.perform_async(
@current_user.team_id,
crm_deal.id,
customer_id,
@current_user.id
)
end
end
end
end

audit.rb:1824-1828 maps username to display string — must be extended:

self.usernameCurrent displayRequired change
'hub', 'central', 'Qontak system'"Qontak system"Unchanged
'bot'"<deleted user>" (wrong)"Bot"
'agentic_ai'"<deleted user>" (wrong)"Agentic AI"

For tickets: create_ticket_notes full spec in ADR-2 (nil guard, dedup, rescue). Ticket create (v4/tickets.rb:57-74) delegates to Ticket::Create service — no Idempotency-Key header support today and none added by this RFC (out of scope; bot/AI callers handle dedup on their side).

Error catalog

HTTPCodeMessageWhen
422INVALID_CREATOR_FLAGinvalid creator_flagValue present but not allowed
422VALIDATION_ERRORStandard Rails errorsMissing required fields
404NOT_FOUNDDeal/ticket not foundInvalid ID on GET
403FORBIDDENAccess deniedNo CanCanCan permission

Detail 2.A — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotencyConsistencyDuplicate handling
Deal create + note createTwo separate transactions (deal save, then note save)Deal created, note skipped if note save fails (rare)Deal idempotency key (existing)Strong per-transactionNote skip on duplicate is acceptable
Ticket create + note createSame patternSameSameSameSame

Detail 2.B — Concurrency Collision Map

No new concurrent resources. Existing deal/ticket creation patterns unchanged. Crm::HubChannelTicket dedup at deals.rb:136 (find_by(crm_deal_id, team_id) → skip if exists). Crm::HubChannelTicketV2 dedup follows same pattern in create_ticket_notes helper.

Detail 2.C — Branch & Skip Catalog

ConditionBehaviorOwner
creator_flag absentaudited_actor = @current_user; data_source unchanged; no noteCreate handler
creator_flag valid ('bot' / 'agentic_ai')audited_actor = creator_flag; data_source = creator_flag; audits.username = creator_flagCreate handler
creator_flag invalid422 generic error, record not createdCreate handler
creator_flag present + room_id absentdata_source set, audits.username set; no note (note gated on room_id)Create handler
creator_flag present + room_id present (deal)All above + Crm::HubChannelTicket created via create_notesCreate handler
creator_flag present + room_id present (ticket)All above + Crm::HubChannelTicketV2 created via create_ticket_notesCreate handler
customer_ids + async_lead_assoc: true (deals)LinkDealByCustomerIdWorker enqueued per customer_id after saveCreate handler
customer_ids + async_lead_assoc: true (tickets)LinkTicketByCustomerIdWorker enqueued per customer_id after saveCreate handler

3. High-Availability & Security

  • AuthN: All callers use existing company token (Doorkeeper). No new auth mechanism. creator_flag is an optional param available to any token holder — not restricted to bot/AI integrations (see §5 Risks).
  • AuthZ: creator_flag validated server-side against allowlist. No privilege escalation. Timeline read gated by existing CanCanCan permissions.
  • PII: Crm::HubChannelTicket / Crm::HubChannelTicketV2 contain chat message content (potentially PII). Existing encryption and retention policies apply unchanged. Bot/AI-created audit records (audits.username = 'bot'/'agentic_ai') have no user FK — the standard right-to-delete user deletion path does not touch them. These records are retained per the existing audit retention policy; no new deletion path is required.
  • Injection: creator_flag validated against allowlist — not interpolated into SQL.
  • Performance: No new queries or writes beyond one conditional INSERT on existing endpoint. No latency impact.
  • Logging: Structured log lines per observable event:
    • WARN [creator_flag.invalid] { creator_flag: "<value>", team_id: <id>, endpoint: "POST /api/v4/deals" } — on 422 rejection
    • INFO [hub_channel_ticket.created] { type: "Crm::HubChannelTicket", deal_id: <id>, creator_flag: "<value>", team_id: <id> } — on successful deal note creation
    • INFO [hub_channel_ticket_v2.created] { type: "Crm::HubChannelTicketV2", ticket_id: <id>, creator_flag: "<value>", team_id: <id> } — on successful ticket note creation
    • WARN [create_ticket_notes.failed] { ticket_id: <id>, error: "<message>", team_id: <id> } — on note rescue (non-blocking)
    • INFO [link_ticket_by_customer_id.enqueued] { ticket_id: <id>, customer_id: "<value>", team_id: <id> } — on worker dispatch
  • Failure mode: Note creation failure does not block deal/ticket creation (non-critical path). Errors rescued and logged as WARN per create_ticket_notes spec.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • API contracts: Additive optional param. Existing callers unchanged.
  • DB migration: None. No columns added, no tables created.
  • Versioning: No change needed.

Rollout Strategy

| Phase | Scope | Guardrail | Exit Criteria | |---|---|---|---|---| | 1 — Producers adopt | Chatbot/AI add creator_flag + room_id | Feature inactive while param absent | creator_flag present in production API calls; zero unexpected creator_flag.invalid WARN logs over 7 days | | 2 — FE reads timeline audit | CRM FE renders define_who from timeline audit entries — "Bot"/"AI" once mapping_who is extended (C7) | Existing audit entries unchanged | Creator label displays correctly in timeline |

Rollback Strategy

  1. Revert PR — removes creator_flag param acceptance
  2. No DB rollback needed (zero schema changes)
  3. Confirm POST latency unchanged, no errors

Detail 4.A — Configuration Contract

None. No env vars, no feature flags.

Detail 4.B — Test Plan

LayerCommandWhat it must prove
Requestbundle exec rspec spec/controllers/api/v4/deals_spec.rbcreator_flag → correct data_source; invalid → 422; note created when room_id present; customer_ids async → worker enqueued; sync → validate_and_convert_customer_ids
Requestbundle exec rspec spec/controllers/api/v4/tickets_spec.rbSame for tickets
Requestbundle exec rspec spec/controllers/api/v3dot1/deals_spec.rbv3.1 backward compat
Requestbundle exec rspec spec/controllers/api/v3dot1/tickets_spec.rbv3.1 backward compat
Servicebundle exec rspec spec/services/contacts/link_ticket_by_customer_id_service_spec.rbperson found → PeopleTicket created; person not found → :person_not_found; invalid args → :invalid_args
Workerbundle exec rspec spec/workers/contacts/link_ticket_by_customer_id_worker_spec.rbdelegates to service; raises PersonNotFoundError on :person_not_found; exhausted callback logs to Crm::Log

Detail 4.C — Agent Execution Plan

OrderChunkFilesCommands to runAcceptance criteria (verifiable)
C1v4 deal: add creator_flag + note + customer_idsapp/controllers/api/v4/deals.rbbundle exec rspec spec/controllers/api/v4/deals_spec.rb'bot'data_source='bot'; invalid → 422; room_id present → Crm::HubChannelTicket created; customer_ids + async_lead_assoc:trueLinkDealByCustomerIdWorker enqueued; sync path → validate_and_convert_customer_ids called
C2New LinkTicketByCustomerIdServiceapp/services/contacts/link_ticket_by_customer_id_service.rbbundle exec rspec spec/services/contacts/link_ticket_by_customer_id_service_spec.rbMirror LinkDealByCustomerIdService: PeopleTicket.find_or_create_by; :person_not_found, :invalid_args, :ticket_not_found statuses; Audited.as_user wraps create
C3New LinkTicketByCustomerIdWorkerapp/workers/contacts/link_ticket_by_customer_id_worker.rbbundle exec rspec spec/workers/contacts/link_ticket_by_customer_id_worker_spec.rbMirror LinkDealByCustomerIdWorker: queue :contact, retry 3, exhausted logs Crm::Log with reference_object_type: 'Ticket'; raises PersonNotFoundError on :person_not_found
C4v4 ticket: add creator_flag + note + customer_ids (dispatches C2/C3 worker)app/controllers/api/v4/tickets.rbbundle exec rspec spec/controllers/api/v4/tickets_spec.rbcreator_flag valid → data_source set + as_user; invalid → 422 generic; room_id + creator_flag + resolvable crm_note_typeCrm::HubChannelTicketV2 created; room_id + creator_flag + nil crm_note_type → note skipped silently; duplicate room_id for same ticket → skipped (dedup); customer_ids + async_lead_assoc:trueLinkTicketByCustomerIdWorker enqueued; note failure does not block ticket save
C5v3.1 deal: add creator_flag + noteapp/controllers/api/v3dot1/deals.rbbundle exec rspec spec/controllers/api/v3dot1/deals_spec.rbcreator_flagdata_source + as_user; room_idCrm::HubChannelTicket; customer_ids already wired — no change
C6v3.1 ticket: add creator_flag + note + customer_ids (dispatches C2/C3 worker)app/controllers/api/v3dot1/tickets.rbbundle exec rspec spec/controllers/api/v3dot1/tickets_spec.rbSame as C4
C7Audit model: extend mapping_whoapp/models/audit.rb:1824bundle exec rspec spec/models/audit_spec.rbusername='bot'define_who="Bot"; username='agentic_ai'define_who="Agentic AI"; username='hub' still → "Qontak system" (regression check); username=nil with deleted user → "<deleted user>" (regression check)

Execution order matters: C2 + C3 must merge before C4 and C6 (ticket controllers depend on the new worker). C1 and C2+C3 are independent — can be parallelised.

Deals use existing create_notes (deals.rb:120-138) → Crm::HubChannelTicket. Tickets require a new create_ticket_notes helper → Crm::HubChannelTicketV2, modelled on hub/ticket/new_ticket_service.rb:217-233 (add ticket_id: to parameters, type: 'Crm::HubChannelTicketV2'). Both wrapped in as_user(audited_actor).

Detail 4.D — Verification & Rollback Recipe

  • Pre-merge: bundle exec rspec spec/controllers/api/v4/deals_spec.rb spec/controllers/api/v4/tickets_spec.rb spec/services/contacts/link_ticket_by_customer_id_service_spec.rb spec/workers/contacts/link_ticket_by_customer_id_worker_spec.rb && bundle exec rubocop
  • Post-deploy signals: create deal with creator_flag: 'bot', channel_integration_room_id: 'room_x' → GET shows data_source: 'bot'; timeline shows "Bot" as creator for both deal create and HubChannel note. Create ticket with customer_ids: ['cid_1'], async_lead_assoc: trueLinkTicketByCustomerIdWorker job appears in Sidekiq queue.
  • Rollback: revert PR; no DB changes to undo; workers in queue become no-ops (service guard ticket_not_found handles orphaned jobs)

5. Concern, Questions, or Known Limitations

Risks & Mitigations

RiskImpactMitigationOwner
Chatbot/AI squads miss payload deliveryFeature inactiveAlign at sprint planning; include in RFCChatbot + AI squads
Existing queries filter by data_source = 'open-api'Bot/AI records excluded — intentional, they are a different channelDocument in release notes; align with consumers (Chatbot, AI squads)CRM BE

Open Questions

#SeverityQuestionOwner
Q1[critical]Chatbot/AI squad payload delivery timelineChatbot + AI squads

Known Limitations

  • No historical backfill: Pre-phase records retain existing data_source. No retroactive note creation.
  • No source granularity: "Bot"/"AI" only — no specific flow name. Deferred.
  • Note gated on room_id: Without room_id, no timeline note is created (same as manual flow).

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-02Initial draftAwait CRM Tech Lead + InfoSec review

7. Ready for agent execution

yes

All technical gates pass. An AI agent can implement all C1-C7 chunks from the RFC content alone. Remaining items are organizational, not spec gaps:

ItemTypeImpact on agent
Q1 — payload delivery timelineDependencyAgent can implement BE code regardless; feature inactive until producers send creator_flag
InfoSec approverProcess gateAdd before merging

Passing gates:

  • Infrastructure Topology diagram: ✅
  • ADR-format Technical Decisions for minimum coverage: ✅
  • Repo Reading Guide with Source Verification: ✅
  • Mermaid sequence diagrams (happy + failure path): ✅
  • DDL (no change needed): ✅
  • APIs with reuse/extend tags: ✅
  • Agent Execution Plan with files + commands + AC: ✅
  • Verification & Rollback Recipe: ✅