Skip to main content

Qontak CRM Actions — Custom Fields on Create Deal / Create Ticket — Backend RFC

RFC type: Backend · PRD: crm-actions-custom-fields.md · Epic: BOT-4662 · Status: DRAFT


Metadata

FieldValue
RFC TypeBackend
Authoragus.suparman@mekari.com
StatusIDEA (draft)
Created2026-07-14
PRDcrm-actions-custom-fields.md v1.4
EpicBOT-4662
Deliverynot yet handed to delivery

Sections at a Glance

  1. Infrastructure Topology
  2. Repo Reading Guide
  3. Technical Decisions
  4. Execution Plan
  5. API Contracts
  6. Observability
  7. Rollout and Rollback
  8. Open Questions
  9. §7 Ready for Agent Execution

1. Infrastructure Topology

1.1 Deployment topology

The chatbot service is a single Rails monolith — no separate microservices for the create deal / create ticket action path. All of the work in this RFC is a code change within this one service. No new pods, workers, queues, or databases are introduced.

┌──────────────────────────────────────────────────────────────────┐
│ chatbot monolith (Rails) │
│ │
│ ┌──────────────────────┐ ┌─────────────────────────────────┐ │
│ │ API / frontend_service│ │ AI Agent runtime │ │
│ │ (config time) │ │ (execute time) │ │
│ │ │ │ │ │
│ │ LookupResources │ │ Execute │ │
│ │ + AdditionalField │ │ + merge_custom_field_arguments │ │
│ │ Normalizer │ │ │ │
│ └────────┬─────────────┘ └──────────────┬──────────────────┘ │
│ │ GET /api/mobile/v2.8/crm/ │ POST /deals │
│ │ additional_fields?object=deal │ POST /tickets │
└───────────┼──────────────────────────────────┼────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ Qontak CRM API (external) │
│ https://app.qontak.com │
│ Auth: Bearer company_token (existing) │
└─────────────────────────────────────────────┘

1.2 Per-service responsibility

Service / ModuleResponsibilityNet-new?
LookupResourcesOrg-level additional-field definitions lookup (two new resource keys: additional_field_deal, additional_field_ticket) + normalizer callYes
AdditionalFieldNormalizerCRM field_type_idPropertiesItem descriptor + is_additional_field: true shape for POST /v1/node-resources/lookup responseYes (new file)
Executemerge_custom_field_arguments: reads parameters['custom_fields'], resolves values, merges into CRM bodyYes (new private method)
AiAgentActionCustomFields rollout classPer-org feature flag check (same pattern as AiAgent rollout)Yes (new file)
NodeRegistry seedAdd custom_fields_enabled: true to settings for qontak_crm_deal_create + qontak_crm_ticket_createYes (seed/migration)
CrmHttpClientUnchanged — reused as-is for new lookup endpointsNo
Qontak CRM APIMust accept custom-field keys/values in POST /deals and POST /tickets bodiesExternal — verify per A-1

2. Repo Reading Guide

2.1 Existing Code Anchors

Read these files in this order before writing any implementation:

#FileRead to learn
1app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:186-208extract_arguments_by_destination — how standard args are resolved from parameters.arguments keyed by node_registry destination. The custom-field merge must NOT touch this method.
2app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:244-260node_registry_properties_map and node_registry_destination_for — how the static node_registry.properties JSON array is indexed by name. Custom fields bypass this map entirely.
3app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:56-105perform_execution — the orchestration method; new custom-field merge step inserts after process_body.
4app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:262-276enrich_body_for_deal_create — lead lookup enrichment runs after process_body; the custom-field merge must chain after both this and the standard body.
5app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:1-57RESOURCE_PATHS + call dispatch — new resource keys crm_deal_custom_fields / crm_ticket_custom_fields add entries here.
6app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:61-201Existing fetch_crm_deal_pipelines / fetch_crm_deal_stages / extract_data_from_response — the exact pattern the new fetch methods must follow.
7app/core/repositories/node_resources/mekari_qontak_crm/crm_http_client.rb:45-62request_with_auth — the auth + 401-refresh entry point; all new CRM calls go through this, not request.
8app/models/node_registry.rb:1-14 + schemanode_registry.properties is jsonb (static array), settings is jsonb (hash). The custom_fields_enabled flag lives in settings, not properties.
9app/models/ai_agent_action.rb + schemaai_agent_actions.parameters is a json column. The existing arguments key holds standard field configs; the new custom_fields key holds the dynamic list. Both live at the top level of this JSON.
10app/core/repositories/system_preferences/rollout/ai_agent.rbReference implementation for the per-org rollout pattern (company_id list in JSON value). Copy this shape for AiAgentActionCustomFields.
11app/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb:74-92build_non_api_args — iterates parameters['arguments'], skips non-use_ai entries, reads type from registry_prop['type']. For additional fields (is_additional_field: true), there is no registry entry → registry_prop is nil → reading registry_prop['type'] would raise NoMethodError. Fix: bail out early for additional fields and use param_value['type'] instead.

2.2 Repo Map — slice this RFC touches

flowchart LR
subgraph chatbot["chatbot (Rails monolith)"]
LR["LookupResources (existing, extended)"]
AFN["AdditionalFieldNormalizer (new)"]
EX["Execute (existing, extended)"]
AACF["Rollout::AiAgentActionCustomFields (new)"]
NR["NodeRegistry (settings seed)"]
HC["CrmHttpClient (unchanged)"]
AIA["AiAgentAction (parameters JSON extended)"]
end

LR --> AFN
LR --> HC
EX --> AACF
EX --> AIA
EX --> HC
NR --> EX

2.3 Existing API check — inbound CRM calls

EndpointTagEvidence
POST /api/v3.1/dealsreused — body extendedexecute.rb:19 ACTION_TYPE_MAPPING
POST /api/v3.1/ticketsreused — body extendedexecute.rb:21 ACTION_TYPE_MAPPING
GET /api/v3.1/pipelinesreused as-islookup_resources.rb:11
GET /api/v3.1/pipelines/{id}/stagesreused as-islookup_resources.rb:12
GET /api/v3.1/tickets/ticket_pipelinesreused as-islookup_resources.rb:14
GET /api/v3.1/deals/inforeused as-islookup_resources.rb:13
GET /api/v3.1/tickets/inforeused as-islookup_resources.rb:15-16
GET /api/v3.1/usersreused as-islookup_resources.rb:18
GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=usernew-with-justification — org-level deal additional field definitions; confirmed 2026-07-15No prior usage in repo; net-new CRM mobile API call
GET /api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=usernew-with-justification — org-level ticket additional field definitions; same response shape as dealNo prior usage in repo

A-1 RESOLVED (2026-07-15): Additional fields endpoint confirmed. Fields are org-level (team_id-scoped in CRM), not pipeline-scoped — the PRD's original D-1/D-2 pipeline gate does not apply and was amended in PRD v1.5 (2026-07-27, A-4 closed). See §8 Open Questions and Decision 3.7.

2.4 Source Verification

ClaimEvidence
extract_arguments_by_destination drops args without a registered destinationexecute.rb:193-194next unless param_destination.to_s == destination.to_s; any arg whose key is not in node_registry.properties returns nil from node_registry_destination_for and is skipped
node_registry.properties is a static JSON arraydb/schema.rbt.jsonb "properties", default: []; no dynamic mutation path exists in the codebase
ai_agent_actions.parameters is a JSON columndb/schema.rbt.json "parameters"
No custom/additional-field lookup in LookupResourceslookup_resources.rb:37-57call dispatch covers exactly 8 resource keys, none for additional fields
CrmHttpClient.request_with_auth handles 401 + retrycrm_http_client.rb:45-62 — fetches token, calls request, on result[:code] == 401 calls refresh_token and retries once
Rollout flag pattern: company_id JSON array in system_preferences.valuerollout/ai_agent.rb:14rollout_cids = JSON.parse(rollout_ai_agent&.value || '[]'); rollout_cids.include?(organization.company_id)
node_registry.settings is jsonb hashdb/schema.rbt.jsonb "settings", default: {}
Rollbar.error is the error instrumentation conventionexecute.rb:103,150,294; lookup_resources.rb:69,85,97,119,131,143,156,169; crm_http_client.rb:39
CRM additional fields endpoint confirmed (A-1 resolved)GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user — response has response[] array with id, name, field_type_id, name_alias, dropdown[]; same extract_data_from_response pattern applies
Additional fields are org-level not pipeline-scopedCRM response has team_id field (not pipeline_id); no pipeline parameter in the endpoint; confirmed 2026-07-15
Additional fields stored in parameters['arguments'] with is_additional_field: trueConfirmed from FE parameter sample 2026-07-15 — same hash as standard fields, distinguished by marker
build_non_api_args reads type from registry_prop['type'] for standard fieldstrain_ai_agent.rb:87map_param_type(registry_prop['type']); for additional fields registry_prop is nil — NoMethodError without the fix
Training includes only use_ai: true argstrain_ai_agent.rb:79next unless param_value['use_ai'] == true; additional fields with use_ai: true must also be included but type source is different

3. Technical Decisions

Decision 3.1 — Additional fields stored in parameters['arguments'] with is_additional_field: true marker, not a separate key

Context (corrected 2026-07-15): The FE stores additional field configs inline in parameters['arguments'] alongside standard fields, distinguished by is_additional_field: true. This was confirmed from the actual parameter sample:

{
"arguments": {
"name": { "use_ai": true },
"crm_pipeline_id": { "use_ai": true },
"role_additional_field": {
"is_additional_field": true,
"id": 1,
"type": "string",
"value": "12",
"cached_result_name": "admin"
},
"favorite_number_additional_field": {
"is_additional_field": true,
"id": 2,
"type": "number",
"value": "agus"
}
}
}

Options:

OptionProsCons
A. parameters['arguments'] inline — is_additional_field: true markerMatches actual FE storage; no schema change; execute.rb iterates one hash; consistent with existing arg resolution shapeMust filter is_additional_field in both execute and training paths
B. Separate parameters['custom_fields'] top-level keyCleaner separationContradicts actual FE implementation — would require FE change

Decision: Option A (confirmed by actual FE parameter shape). Additional fields are entries in parameters['arguments'] with is_additional_field: true. The execute and training code filter on this marker.

Key fields per additional field entry:

  • is_additional_field: true — the filter marker
  • id — the CRM additional field's id (matches id in CRM additional_fields API response; used as additional_fields[n].id in the CRM create body)
  • type — normalized type (string, number, etc.) — used by training (not from registry)
  • value — the configured value (dropdown option id, text, number, or nil for AI-filled)
  • use_ai — optional; if true, value comes from AI arguments at runtime
  • cached_result_name — display label for dropdowns (sent as value_name in CRM body)

CRM create body format for additional fields: a top-level additional_fields array, NOT individual top-level keys:

{
"additional_fields": [
{ "id": 1, "name": "role_additional_field", "value": "12", "value_name": "admin" }
]
}
  • id = cfg['id'] (CRM field id)
  • name = param_name (field machine name)
  • value = resolved value (cfg['value'] or AI-filled)
  • value_name = cfg['cached_result_name'].presence || resolved_value (display label; for non-dropdown falls back to the value itself)

Consequences: Two code paths need to handle is_additional_field: true differently: (1) execute.rb — bypass node-registry destination lookup, merge value directly into body; (2) train_ai_agent.rb — bypass registry type lookup, use param_value['type'] directly.

Reversibility: Fully reversible — the flag check is a guard; removing it reverts to current behavior where additional fields are silently ignored.


Decision 3.2 — New private method merge_custom_field_arguments in Execute, not a modification of extract_arguments_by_destination

Context: extract_arguments_by_destination resolves values from parameters['arguments'] keyed by node-registry property names. Custom fields have no node-registry entry and live in parameters['custom_fields']. The resolution logic is similar (use_ai vs static value) but the source and key space are different.

Options:

OptionProsCons
A. New private merge_custom_field_arguments methodZero risk to existing standard-field path; single responsibilitySlight duplication of use_ai resolution logic (3 lines)
B. Extend extract_arguments_by_destination to also read parameters['custom_fields']DRYComplicates the existing method's single-responsibility contract; risks a regression in standard-field behavior

Decision: Option A. The new method reads parameters.dig('custom_fields') (an array), iterates, resolves values, returns a flat hash { crm_key => resolved_value }, and merges it into the body. The use_ai resolution is 3 lines — acceptable duplication for isolation.

Consequences: perform_execution gains one conditional call:

body = merge_custom_field_arguments(body, param_config) if custom_fields_enabled?

Inserted after process_body and after enrich_body_for_deal_create (so lead-lookup enrichment is not affected by custom field ordering).

Reversibility: Remove the conditional call and the two private methods; no other code changes.


Decision 3.3 — New CustomFieldNormalizer module in node_resources/mekari_qontak_crm/

Context: The PRD defines a type → descriptor mapping (Section 7.1). This normalization must live in one place so a new CRM field type = one row, not an FE release. The normalizer converts a raw CRM field definition hash into a PropertiesItem descriptor shape the FE already renders.

Options:

OptionProsCons
A. Standalone CustomFieldNormalizer module in node_resources/mekari_qontak_crm/Colocated with other CRM resource concerns; testable in isolationNew file
B. Inline in LookupResourcesNo new fileBloats LookupResources; normalizer is a separate concern

Decision: Option A. New file app/core/repositories/node_resources/mekari_qontak_crm/custom_field_normalizer.rb.

No alternative seriously considered for the locationnode_resources/ is the canonical home for CRM resource-handling code, consistent with lookup_resources.rb and crm_http_client.rb.

Reversibility: Delete the file; remove the call from LookupResources.


Decision 3.4 — Feature flag via system_preferences rollout pattern (per-org company_id list)

Context: The PRD requires ai_agent_action_custom_fields as an org-level flag (default OFF, enabled per account by ops).

Options:

OptionProsCons
A. system_preferences rollout pattern — JSON array of company_ids in valueIdentical to AiAgent rollout (rollout/ai_agent.rb); no schema change; ops can enable per account via rails console or adminGlobal list — enabling for 100k accounts requires a large JSON blob (acceptable at current scale)
B. Per-org boolean in organization.settingsPer-org, fast lookupRequires touching every org row to enable; no ops self-serve pattern today

Decision: Option A. New app/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rb following the exact shape of rollout/ai_agent.rb. Checked via organization.company_id.

Reversibility: Delete the rollout class and the system_preferences row; remove flag checks from Execute.


Decision 3.7 — Additional fields are org-level; PRD D-1/D-2 pipeline-gate does not apply

Context: PRD D-1 stated "custom fields are scoped to the selected pipeline." PRD D-2 stated "custom fields require Pipeline set manually." The confirmed CRM endpoint (GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user) returns fields scoped to team_id (the org's CRM team) — not to a specific pipeline. All pipelines in an org share the same additional-field definitions.

Options:

OptionProsCons
A. Show additional fields without pipeline gateCorrect — fields are org-level; no artificial pipeline dependencyPRD D-1/D-2 need amendment; FE picker UX changes
B. Keep pipeline gate (show additional fields only when pipeline is set)No PRD amendment needed nowTechnically wrong — the picker withholds data the builder could always see

Decision: Option A — BE implementation makes no pipeline check for resource_key: additional_field_deal. The lookup is a simple org-level call. PM ratified this 2026-07-27 and amended PRD D-1/D-2 in v1.5 (A-4 closed). The FE can load additional fields regardless of pipeline selection.

Consequences: LookupResources.fetch_deal_additional_fields requires no pipeline_id parameter. The FE no longer needs the "choose a pipeline first" gate for additional fields (UX simplification).

Reversibility: If PM decides to keep the gate, add a pipeline_id filter to the CRM call (if CRM API supports it) or apply it client-side.


Decision 3.5 — No caching of custom field definitions at this scope

Context: The config-time custom field lookup is called when the builder opens the Add-field picker for a pipeline. The PRD targets ≤ 1.5 s p95. The existing pipeline/stage lookups are synchronous with no cache.

Options:

OptionProsCons
A. No cache — synchronous lookup, consistent with existing lookupsZero added complexityIf CRM is slow, builder UX degrades — mitigated by the ≤ 1.5 s target and A-2 monitoring
B. Cache per (org, pipeline_id) in Redis for config sessionFaster repeat opensCache invalidation risk if builder edits CRM custom fields mid-session; adds complexity before confirming the performance problem

Decision: Option A (no cache) for v1. If Beta shows p95 > 1.5 s (per A-2), add a short-TTL Redis cache (5 minutes) in a follow-up. This is explicitly reversible — the API call is idempotent.


Decision 3.6 — Array values sent as JSON arrays in the CRM body

Context: The PRD says "array fields sent as a list of allowed-option values". The exact payload shape depends on A-1.

Options:

OptionProsCons
A. Native JSON array ["a", "b"] in the bodyStandard REST practice; consistent with how tags already works in the existing deal bodyMust be confirmed per A-1
B. Comma-separated string "a,b"Some legacy APIs expect thisNon-standard; loses type information

Decision: Option A. The normalizer stores is_array: true for array fields. merge_custom_field_arguments wraps single values in an array when is_array is true; multi-value selections are already arrays. Confirm array format against CRM API per A-1 before shipping Chunk 4.


4. Execution Plan

Pre-condition: A-1 (CRM API contract) must be resolved before Chunk 3. All other chunks can begin in parallel.

Chunk 1 — Feature flag rollout class

Files:

  • app/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rb (new)

What to implement:

# frozen_string_literal: true

module Repositories
module SystemPreferences
module Rollout
class AiAgentActionCustomFields < Repositories::AbstractRepository
def self.enabled_for?(organization)
return false unless organization

rollout = Repositories::SystemPreferences::FindBy.new(
group_code: 'rollout',
code: 'ai_agent_action_custom_fields'
).call
cids = JSON.parse(rollout&.value || '[]') rescue []
cids.include?(organization.company_id)
end
end
end
end
end

Seed entry (rails console or db seed):

SystemPreference.find_or_create_by!(group_code: 'rollout', code: 'ai_agent_action_custom_fields') do |sp|
sp.name = 'AI Agent Action Custom Fields'
sp.group_name = 'Rollout'
sp.value = '[]'
sp.enabled = true
sp.description = 'Per-org rollout for custom fields on create deal/ticket actions. Value: JSON array of company_ids.'
end

Acceptance criteria:

  • AiAgentActionCustomFields.enabled_for?(org_with_company_id_in_list) returns true
  • AiAgentActionCustomFields.enabled_for?(org_with_company_id_not_in_list) returns false
  • AiAgentActionCustomFields.enabled_for?(nil) returns false
  • Spec: spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb

Chunk 2 — AdditionalFieldNormalizer

Files:

  • app/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rb (new)

What to implement: A module with one public class method normalize(raw_field) that accepts a single item from GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user's response[] array and returns the item shape expected by POST /v1/node-resources/lookup, or nil for unsupported field_type_ids.

Confirmed field_type_id mapping (from CRM API sample 2026-07-15):

field_type_idCRM type labelhtml.elementhtml.typeproperty typeis_rlnotes
1Single-line textinputtextstringfalse
2Dropdown selectselectstringtruedropdown[] → resource_types fixed items
3Numberinputnumbernumberfalse
7Percentageinputnumbernumberfalsestored as number
8Text Areatextareastringfalse
9URLinputurlstringfalse
other(unsupported)return nil; caller logs + skips
# frozen_string_literal: true

module Repositories
module NodeResources
module MekariQontakCrm
module AdditionalFieldNormalizer
# CRM field_type_id → descriptor config
# Keys: html_element, html_type (nil = omit from html hash), prop_type, is_rl
FIELD_TYPE_MAP = {
1 => { html_element: 'input', html_type: 'text', prop_type: 'string', is_rl: false },
2 => { html_element: 'select', html_type: nil, prop_type: 'string', is_rl: true },
3 => { html_element: 'input', html_type: 'number', prop_type: 'number', is_rl: false },
7 => { html_element: 'input', html_type: 'number', prop_type: 'number', is_rl: false },
8 => { html_element: 'textarea', html_type: nil, prop_type: 'string', is_rl: false },
9 => { html_element: 'input', html_type: 'url', prop_type: 'string', is_rl: false }
}.freeze
private_constant :FIELD_TYPE_MAP

# @param raw_field [Hash] One item from CRM additional_fields response[].
# Known keys: id, name, field_type_id, name_alias, name_locale, dropdown[]
# @return [Hash, nil] POST /v1/node-resources/lookup item shape, or nil for unsupported type
def self.normalize(raw_field)
field = raw_field.with_indifferent_access
type_id = field[:field_type_id].to_i
config = FIELD_TYPE_MAP[type_id]
return nil if config.nil?

field_name = field[:name].to_s
display_name = field[:name_alias].presence || field[:name_locale].presence || field_name

html = { element: config[:html_element], placeholder: display_name }
html[:type] = config[:html_type] if config[:html_type]

resource_types = if config[:is_rl] && field[:dropdown].is_a?(Array)
[{ type: 'fixed', items: build_dropdown_items(field[:dropdown]) }]
end

{
name: field_name,
value: field[:id],
property: {
is_additional_field: true,
html: html,
is_rl: config[:is_rl],
name: field_name,
type: config[:prop_type],
display_name: display_name,
resource_types: resource_types
}
}
end

def self.build_dropdown_items(dropdown)
dropdown.filter_map do |opt|
o = opt.with_indifferent_access
{ name: o[:name], value: o[:id] } if o[:name].present?
end
end
private_class_method :build_dropdown_items
end
end
end
end

Acceptance criteria:

  • Dropdown field (field_type_id: 2, e.g. "membership" with 3 dropdown items) normalizes to:
    {
    "name": "membership",
    "value": 648665,
    "property": {
    "is_additional_field": true,
    "html": { "element": "select", "placeholder": "Membership" },
    "is_rl": true,
    "name": "membership",
    "type": "string",
    "display_name": "Membership",
    "resource_types": [{ "type": "fixed", "items": [
    { "name": "Reguler", "value": 199757 },
    { "name": "Advance", "value": 199758 },
    { "name": "Platinum", "value": 199759 }
    ]}]
    }
    }
  • Text field (field_type_id: 1, "address") normalizes to html.element: 'input', html.type: 'text', is_rl: false, resource_types: nil
  • Number field (field_type_id: 3) normalizes to html.type: 'number', prop_type: 'number'
  • Textarea field (field_type_id: 8) normalizes to html.element: 'textarea', no html.type
  • Unknown field_type_id (e.g. 99) → returns nil
  • Spec: spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb

Chunk 3 — LookupResources — two new resource keys: additional_field_deal + additional_field_ticket

Prerequisite: None — A-1 resolved (2026-07-15). Can begin immediately.

Files:

  • app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb (extend)

Changes:

  1. Add two entries to RESOURCE_PATHS:
'additional_field_deal' => '/api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user',
'additional_field_ticket' => '/api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=user'
  1. Extend call dispatch:
when 'additional_field_deal' then fetch_additional_fields('additional_field_deal')
when 'additional_field_ticket' then fetch_additional_fields('additional_field_ticket')
  1. Add one shared private method (both objects use identical response shape — AdditionalFieldNormalizer is reused as-is):
def fetch_additional_fields(resource_key)
path = RESOURCE_PATHS[resource_key]
response = crm_http_client.request_with_auth(method: 'GET', path: path)
return response if auth_error?(response)

extract_data_from_response(response).filter_map do |raw_field|
normalized = AdditionalFieldNormalizer.normalize(raw_field)
if normalized.nil?
Rollbar.warning(
'ai_agent_action_additional_field_type_unsupported',
resource_key: resource_key,
field_type_id: raw_field.with_indifferent_access[:field_type_id],
field_name: raw_field.with_indifferent_access[:name],
organization_id: @organization_id
)
end
normalized
end
rescue => e
Rollbar.error(e, message: 'ai_agent_action_additional_field_lookup_failed',
resource_key: resource_key, organization_id: @organization_id)
[]
end

Scope note: Both endpoints return org-level fields (team_id-scoped, not pipeline_id). The PRD's original D-1/D-2 pipeline gate does not apply and was amended in PRD v1.5 — see Decision 3.7 and A-4 (closed 2026-07-27).

Acceptance criteria:

  • LookupResources.new(organization_id: 1, resource_key: 'additional_field_deal').call issues GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user (stubbed)
  • LookupResources.new(organization_id: 1, resource_key: 'additional_field_ticket').call issues GET /api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=user (stubbed)
  • Both return arrays matching the POST /v1/node-resources/lookup item shape (name, value, property with is_additional_field: true)
  • Dropdown fields (field_type_id: 2): property.is_rl: true, resource_types populated from dropdown[]
  • Non-dropdown: property.is_rl: false, resource_types: nil
  • Unsupported field_type_id → excluded + Rollbar warning with resource_key in payload
  • Auth error → auth_error? path (consistent with other methods)
  • Network error → [] + Rollbar error with resource_key
  • Spec: extend spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb

Chunk 4 — Executemerge_additional_field_arguments

Files:

  • app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb (extend)

Context: extract_arguments_by_destination (line 186) looks up each argument's destination from node_registry_properties_map. Additional fields (is_additional_field: true) have no registry entry → node_registry_destination_for(key) returns nil → they are silently skipped. The fix: a separate method reads from parameters['arguments'], filters is_additional_field: true entries, and merges them into the body. Standard fields remain untouched.

Changes to perform_execution (after line 80, after enrich_body_for_deal_create):

# Merge additional field arguments (gated by feature flag)
if additional_fields_enabled?
body = merge_additional_field_arguments(body, param_config)
end

New private methods:

def additional_fields_enabled?
return @additional_fields_enabled if defined?(@additional_fields_enabled)

organization = Organization.find_by(id: @organization_id)
@additional_fields_enabled = Repositories::SystemPreferences::Rollout::AiAgentActionCustomFields
.enabled_for?(organization)
end

# Reads parameters['arguments'], collects entries with is_additional_field: true,
# resolves each value (AI-filled or static), and merges them into body['additional_fields'].
#
# CRM body shape per item:
# { id: <crm_field_id>, name: <field_machine_name>, value: <resolved>, value_name: <display> }
#
# value_name: cached_result_name (dropdown display label) if present; else the value itself.
# cached_result_name is only used for value_name — it is not a separate body key.
def merge_additional_field_arguments(body, param_config)
body ||= {}

additional_items = param_config.filter_map do |param_name, cfg|
next unless cfg.is_a?(Hash)
next unless cfg['is_additional_field'] == true || cfg[:is_additional_field] == true

use_ai = cfg['use_ai'] || cfg[:use_ai]
value = if use_ai == true
@arguments&.dig(param_name.to_s) || @arguments&.dig(param_name.to_sym)
else
cfg['value'] || cfg[:value]
end

next if value.nil?

value_name = cfg['cached_result_name'] || cfg[:cached_result_name] || value

{
'id' => cfg['id'] || cfg[:id],
'name' => param_name.to_s,
'value' => value,
'value_name' => value_name
}
end

body['additional_fields'] = additional_items if additional_items.any?
body
end

Sequence of calls in perform_execution:

sequenceDiagram
participant PE as perform_execution
participant PB as process_body
participant EBD as enrich_body_for_deal_create
participant MAF as merge_additional_field_arguments
participant CRM as Qontak CRM API

PE->>PB: build standard body from parameters.arguments
PB-->>PE: body (standard fields only)
PE->>EBD: enrich with crm_lead_ids (deal only)
EBD-->>PE: body (standard + lead ids)
alt additional_fields_enabled?
PE->>MAF: iterate parameters.arguments, filter is_additional_field, resolve values
MAF-->>PE: body (standard + lead ids + additional fields)
end
PE->>CRM: POST /deals or /tickets (full body)
CRM-->>PE: 200 OK or 4xx
alt 401
PE->>CRM: retry with refreshed token
end

Acceptance criteria:

  • Given additional_fields_enabled? true and parameters['arguments'] has:
    "role_additional_field" => { "is_additional_field" => true, "id" => 1, "type" => "string", "value" => "12", "cached_result_name" => "admin" }
    when merge_additional_field_arguments runs, then body equals:
    { "additional_fields" => [{ "id" => 1, "name" => "role_additional_field", "value" => "12", "value_name" => "admin" }] }
  • Given a non-dropdown field (no cached_result_name), e.g. "favorite_number": { is_additional_field: true, id: 2, value: "42" }, then value_name falls back to "42" (same as value)
  • Given use_ai: true and @arguments = { 'role_additional_field' => '99' }, then the array item has value: '99', value_name: '99' (no cached_result_name available at AI-fill time)
  • Given multiple additional fields, then body['additional_fields'] contains one item per field
  • Given value: nil for a field (AI field, no runtime value resolved), then that field is omitted from the array
  • Given additional_fields_enabled? false, merge_additional_field_arguments is never called — body unchanged
  • Given no is_additional_field: true entries, body['additional_fields'] is not set
  • Standard fields are NOT in additional_fieldsextract_arguments_by_destination handles them unchanged
  • Spec: extend spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb

Chunk 4B — TrainAiAgent — additional fields with use_ai: true in training payload

Files:

  • app/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb (extend build_non_api_args)

Problem: build_non_api_args (line 74–92) only passes use_ai: true args to the AI training payload. For additional fields with use_ai: true, registry_prop is nil (no registry entry) → registry_prop['type'] raises NoMethodError. Also depends_on_satisfied?(nil, ...) may return false, silently dropping them.

Fix — insert an early-return branch for additional fields before the registry lookup:

def build_non_api_args(parameters, action)
function_params = parameters.dig('arguments') || {}
registry_properties = find_registry_properties(action.action_type, action.action_type_version)

function_params.each_with_object({}) do |(param_name, param_value), hash|
next unless param_value['use_ai'] == true

# Additional fields: type comes from param_value directly, no registry entry.
if param_value['is_additional_field'] == true
description = param_value['description'].presence || param_name.to_s.humanize
hash[param_name] = {
type: map_param_type(param_value['type']),
description: description
}
next
end

# Standard fields: type comes from node_registry.properties.
registry_prop = registry_properties.find { |p| p['name'] == param_name } if registry_properties.present?
next unless depends_on_satisfied?(registry_prop, function_params)

description = param_value['description']
description = registry_prop['description'] if description.blank? && registry_prop.present?

hash[param_name] = {
type: map_param_type(registry_prop['type']),
description: description
}
end
end

Type mapping for additional fields (uses existing map_param_type):

param_value['type']→ AI service type
"string""str"
"number""float"
"integer""int"
"boolean""bool"

Acceptance criteria:

  • Given parameters['arguments'] has "role_additional_field": { is_additional_field: true, use_ai: true, type: "string" }, when build_non_api_args runs, then args['role_additional_field'] = { type: 'str', description: 'Role additional field' }
  • Given "balance": { is_additional_field: true, use_ai: true, type: "number" }, then args['balance'] = { type: 'float', ... }
  • Given "role_additional_field": { is_additional_field: true, use_ai: false, value: "12" } (manually set — no use_ai), then it is NOT included in training args (standard use_ai guard still applies)
  • Standard fields (no is_additional_field) continue to resolve type from registry_prop['type'] — no regression
  • No NoMethodError when an additional field with use_ai: true is present and registry has no matching entry
  • Spec: spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb — add context for build_non_api_args with additional field args

Chunk 5 — Node registry settings seed

What: Add custom_fields_enabled: true to the settings JSONB column of the qontak_crm_deal_create and qontak_crm_ticket_create node registry records. This signals the FE that the Add-field picker should render the Custom group for these action types.

Implementation (rails console / seed script):

%w[qontak_crm_deal_create qontak_crm_ticket_create].each do |node_type|
nr = NodeRegistry.find_by_type_and_version(node_type)
next unless nr

nr.settings ||= {}
nr.settings['custom_fields_enabled'] = true
nr.save!
end

Acceptance criteria:

  • NodeRegistry.find_by_type_and_version('qontak_crm_deal_create').settings['custom_fields_enabled']true
  • NodeRegistry.find_by_type_and_version('qontak_crm_ticket_create').settings['custom_fields_enabled']true
  • All other node registry records are untouched (spot-check qontak_crm_deal_update)

Chunk 6 — Specs

New spec files:

  • spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb
  • spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb

Extended spec files:

  • spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb — add context for additional_field_deal
  • spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb — add contexts for additional_fields_enabled?, merge_additional_field_arguments, full perform_execution with additional fields
  • spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb — add context for build_non_api_args with is_additional_field: true entries (type from param, no registry crash)

Test commands:

bundle exec rspec spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb
bundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb
bundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb
bundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb
bundle exec rspec spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb

Acceptance criteria (Chunk 6):

  • All new + extended specs pass with bundle exec rspec <paths> --format documentation
  • No regressions in the existing execute + lookup specs (run full file, not just new examples)

5. API Contracts

5.1 Existing CRM create endpoints — body delta

POST /api/v3.1/deals

Before (existing standard body shape):

{
"deal_pipeline_id": 12,
"deal_stage_id": 34,
"name": "Deal for Budi (customer_name suffix)",
"owner_id": 5,
"crm_lead_ids": [101, 102],
"tags": ["enterprise", "vip"]
}

After (additional fields merged):

{
"deal_pipeline_id": 12,
"deal_stage_id": 34,
"name": "Deal for Budi (customer_name suffix)",
"owner_id": 5,
"crm_lead_ids": [101, 102],
"tags": ["enterprise", "vip"],
"additional_fields": [
{
"id": 1,
"name": "role_additional_field",
"value": "12",
"value_name": "admin"
},
{
"id": 2,
"name": "favorite_number_additional_field",
"value": "42",
"value_name": "42"
}
]
}

additional_fields is a top-level array — fields are NOT spread as individual top-level keys. Each item: id = CRM field id (from cfg['id']), name = param_name, value = resolved value, value_name = cached_result_name or value itself.

POST /api/v3.1/tickets — same pattern

{
"ticket_pipeline_id": 5,
"ticket_stage_id": 11,
"name": "Support ticket from Budi",
"additional_fields": [
{
"id": 3,
"name": "severity_additional_field",
"value": "high",
"value_name": "High"
}
]
}

merge_additional_field_arguments is action-type-agnostic — same method handles both qontak_crm_deal_create and qontak_crm_ticket_create.

POST /api/v3.1/tickets

Same pattern — custom fields are merged under their CRM keys alongside the existing ticket body fields.

5.2 Net-new CRM lookup endpoints (confirmed 2026-07-15)

GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user and ?object=ticket&created_by=user

Both endpoints have identical response shape — only the object query param differs. AdditionalFieldNormalizer handles both without change.

Deal endpoint example:

Request:

GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user
Authorization: Bearer <company_token>

Response shape (confirmed):

{
"meta": { "status": 200, "type": "OK", ... },
"response": [
{
"id": 648665,
"name": "membership",
"field_type_id": 2,
"name_alias": "Membership",
"name_locale": "Membership",
"type": "Dropdown select",
"dropdown": [
{ "id": 199757, "name": "Reguler", "crm_additional_field_id": 648665 },
{ "id": 199758, "name": "Advance", "crm_additional_field_id": 648665 },
{ "id": 199759, "name": "Platinum", "crm_additional_field_id": 648665 }
]
},
{
"id": 648805,
"name": "address",
"field_type_id": 1,
"name_alias": "Address",
"name_locale": "Address",
"type": "Single-line text",
"dropdown": []
}
],
"current_page": 1,
"total_page": 1,
"total_data": 6
}

Key fields used by normalizer: id (→ value), name (→ name + property.name), field_type_id (→ descriptor), name_alias (→ display_name + html.placeholder), dropdown[] (→ resource_types.items for field_type_id: 2).

Failure behavior: Error → LookupResources.fetch_deal_additional_fields rescues → returns [] + Rollbar error.

Timeout: Inherit CRM HTTP client timeout (existing budget); target ≤ 1.5 s p95 per PRD constraint.

5.3 POST /v1/node-resources/lookup response shape for resource_key: additional_field_deal

Transformed from CRM response, the lookup endpoint returns:

{
"status": "success",
"code": 200,
"message": "Resources fetched successfully",
"data": [
{
"name": "membership",
"value": 648665,
"property": {
"is_additional_field": true,
"html": { "element": "select", "placeholder": "Membership" },
"is_rl": true,
"name": "membership",
"type": "string",
"display_name": "Membership",
"resource_types": [
{
"type": "fixed",
"items": [
{ "name": "Reguler", "value": 199757 },
{ "name": "Advance", "value": 199758 },
{ "name": "Platinum", "value": 199759 }
]
}
]
}
},
{
"name": "address",
"value": 648805,
"property": {
"is_additional_field": true,
"html": { "element": "input", "type": "text", "placeholder": "Address" },
"is_rl": false,
"name": "address",
"type": "string",
"display_name": "Address",
"resource_types": null
}
}
]
}

The wrapping status/code/message/data envelope is emitted by the existing POST /v1/node-resources/lookup controller layer — LookupResources#call returns only the inner data array.

5.4 ai_agent_actions.parameters schema delta

Before (existing — standard fields only):

{
"trigger": "When a customer wants to buy a product",
"arguments": {
"crm_pipeline_id": { "use_ai": true, "value": null },
"crm_stage_id": { "use_ai": true, "value": null },
"name": { "use_ai": true, "value": null },
"customer_contact_association": { "value": true, "type": "boolean" }
}
}

After (additional fields added inline in arguments):

{
"trigger": "When a customer wants to buy a product",
"arguments": {
"crm_pipeline_id": { "use_ai": true, "value": null },
"crm_stage_id": { "use_ai": true, "value": null },
"name": { "use_ai": true, "value": null },
"customer_contact_association": { "value": true, "type": "boolean" },
"role_additional_field": {
"is_additional_field": true,
"id": 1,
"type": "string",
"value": "12",
"cached_result_name": "admin"
},
"favorite_number_additional_field": {
"is_additional_field": true,
"id": 2,
"type": "number",
"value": null,
"use_ai": true
}
}
}

Field semantics:

  • is_additional_field: true — the filter marker; both execute.rb and train_ai_agent.rb branch on this
  • id — FE sequential id (not the CRM field's database id)
  • type — normalized property type (source of truth for training type mapping)
  • value — static value (dropdown option id, text, number); nil when use_ai: true
  • use_ai: true — if present, runtime value comes from AI agent's arguments
  • cached_result_name — FE display label for dropdown options; sent as value_name in additional_fields[] item (for non-dropdown fields, value_name falls back to value)

Old configs (no is_additional_field entries in arguments) behave identically — merge_additional_field_arguments iterates and finds nothing matching, body unchanged.


6. Observability

6.1 Error instrumentation (Rollbar — existing convention)

SignalCode locationRollbar call
Custom field lookup failurelookup_resources.rb rescue block in each new fetch methodRollbar.error(e, message: 'ai_agent_action_custom_field_lookup_failed', pipeline_id:, organization_id:)
Unsupported CRM field typelookup_resources.rb inside filter_mapRollbar.warning('ai_agent_action_custom_field_type_unsupported', pipeline_id:, raw_type:, field_key:)
Custom field merge errorexecute.rb main rescue (already covers all of perform_execution)Existing Rollbar.error(e, message: 'Failed to execute Qontak CRM action', ...) — no separate call needed

6.2 Analytics events (per PRD §11)

The PRD specifies five analytics events (ai_agent_action_custom_field_added, ai_agent_action_custom_field_lookup_failed, etc.). The chatbot BE codebase uses Rollbar for error tracking and Rails.logger for info — there is no in-process analytics event system in execute.rb or lookup_resources.rb today. The five PRD events should be implemented as:

  • Error/warning events (custom_field_lookup_failed, custom_field_create_rejected, custom_field_type_unsupported) → Rollbar.error / Rollbar.warning (as above)
  • Success/count events (custom_field_added, custom_field_merged) → Rails.logger.info with structured JSON payload so they can be queried in log aggregation

Squad to confirm if an analytics system (e.g. Segment, Kafka event bus) should be wired here. If yes, add the integration as a follow-up task; it does not block shipping.

6.3 Alerting

Per PRD §11: if ai_agent_action_custom_field_create_rejected / total custom-field creates > 5% over any rolling day, page the squad. Squad to configure this alert in whatever Rollbar / Datadog / PagerDuty setup the BOT squad uses. This alert is a post-ship configuration task, not a code change.


7. Rollout and Rollback

7.1 Pre-merge verification

# Run all new + affected specs
bundle exec rspec \
spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb \
spec/core/repositories/node_resources/mekari_qontak_crm/custom_field_normalizer_spec.rb \
spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb \
spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb

# Verify flag is off by default (no company_id in the list yet)
rails runner "puts Repositories::SystemPreferences::Rollout::AiAgentActionCustomFields.enabled_for?(Organization.first)"
# => false

# Verify existing create deal/ticket behavior is unchanged when flag is off
# (covered by execute_spec contexts with custom_fields_enabled? stubbed to false)

7.2 Rollout stages

StageActionVerification
DeployShip all 5 chunks behind flag OFFNo behavior change for any org; existing create actions work as before
Internal QAAdd 1 internal org's company_id to the system_preferences value JSON arrayConfig → runtime → CRM merge verified for scalar + array fields; flag-OFF orgs unaffected
Closed BetaAdd 3–5 design-partner company_idsMonitor ai_agent_action_custom_field_lookup_failed Rollbar rate; confirm CRM accept rate
GAFlip all entitlement-holding orgs onMonitor rejection rate; alert at 5%

7.3 Rollback recipe

Immediate (< 1 min): Clear the system_preferences row's value to '[]' — all orgs instantly revert to standard-field-only behavior. No deploy required.

# Rails console
sp = SystemPreference.find_by(group_code: 'rollout', code: 'ai_agent_action_custom_fields')
sp&.update!(value: '[]')

Full rollback (if code must be reverted):

  1. Revert the 3 new files + 2 modified files via git revert
  2. Re-run bundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb to confirm baseline
  3. No migration to revert — no schema change was made; parameters['custom_fields'] keys in stored configs are silently ignored once the merge code is removed

8. Open Questions

#TypeQuestionOwnerDeadlineStatus
A-1BLOCKERCRM custom-field definitions endpoint, response shape, and body key format.BE + Qontak CRMRESOLVED 2026-07-15 — endpoint confirmed: GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user; field key in CRM body = name field (e.g. "membership"); mapping key = field_type_id (integer).
A-2AssumptionAdditional-field lookup returns within ≤ 1.5 s p95.BEDuring BetaOpen — monitor during Internal QA
A-3Openmerge_custom_field_arguments does next if value.nil? — does a nil AI-resolved value for a required field need explicit error (surface early) or omit key and let CRM reject with 4xx?PM + BEBefore BetaOpen
A-4New — needs PM confirmationAdditional fields are org-level (not pipeline-scoped) per the confirmed CRM API; PM to confirm the picker shows them without a pipeline selection and that PRD D-1/D-2 is amended.PMBefore FE sprintRESOLVED 2026-07-27 — PM ratified Decision 3.7 Option A: (a) yes, the picker shows additional fields with no pipeline selection; (b) yes, amended in PRD v1.5 — D-1 amended to org-level, D-2 withdrawn, story QACF-S04 (prune-on-pipeline-change) withdrawn, S01/AC-4 + NEG-2 + NEG-3 replaced, Non-Goals 5–6 rewritten.
A-5New — CRM body key formatConfirm body key = field name vs field idBERESOLVED 2026-07-15 — additional fields sent as top-level additional_fields[] array (NOT individual top-level keys). Each item: { id: cfg['id'], name: param_name, value: resolved, value_name: cached_result_name || resolved }.

9. Ready for Agent Execution

Ready for agent execution: YES — all chunks unblocked

Remaining open items (non-blocking for BE):

#ItemBlocks
A-4PM to confirm additional-fields shown without pipeline gate (org-level, not pipeline-scoped)Closed 2026-07-27 — ratified; PRD amended to v1.5
A-3PM to confirm nil-AI-value behaviour for a required field (surface early vs let CRM 4xx)Runtime error semantics only — as-built omits the key

Gates met:

  • Infrastructure topology documented — no new infra required
  • All design decisions made with ADR format and rejected alternatives cited
  • Existing code anchors verified (all file paths read, line numbers confirmed)
  • CRM endpoint confirmed (A-1 resolved 2026-07-15)
  • field_type_id mapping table confirmed against sample data
  • Additional field parameter shape confirmed (stored in arguments with is_additional_field: true)
  • CRM body key format confirmed (A-5 resolved 2026-07-15) — param_name, not field id
  • train_ai_agent.rb crash path identified and fix specified (Chunk 4B)
  • Execution plan has ordered chunks with file paths, complete code skeletons, and verifiable acceptance criteria
  • Rollback is immediate (console update) + full (git revert, no schema migration)
  • Mermaid diagrams validated with mmdc

PRD section coverage:

PRD sectionRFC coverageStatus
§6 Constraints — feature flagChunk 1 + §3 Decision 3.4Specified but NOT shipped — no rollout/ai_agent_action_custom_fields.rb exists on chatbot@master and no flag check is called anywhere; task BOT-4668 closed Won't Fix. Recorded as PRD v1.5 D-12 (feature is ungated; release control = the node_registries seed)
§7.1 Type → component normalization contractChunk 2 + AdditionalFieldNormalizer FIELD_TYPE_MAP (field_type_id-keyed)Covered
§8 API behavior #1 — load custom fieldsChunk 3 + §5.2Covered — fields are org-level, not pipeline-scoped; PRD D-1/D-2 amended in v1.5 (A-4 closed 2026-07-27)
§8 API behavior #2/#3 — create with custom fieldsChunk 4 + §5.1Covered
§8 API behavior #4 — AI array fillChunk 4 merge_custom_field_arguments Array(value) branchNOT shipped — the as-built merge_additional_field_arguments (execute.rb:267) accepts only String/Numeric/boolean and skips anything else; no array type is whitelisted in ADDITIONAL_FIELD_CONFIG, so arrays never reach the picker. Recorded as PRD v1.5 D-11 (deferred; PM decision A-5)
§9.2 QACF-S03 BE merge (all ACs)Chunk 4 acceptance criteriaCovered
§11 Observability events§6.1 + §6.2NOT shipped — the only signal in the shipped path is a generic Rollbar.error "Failed to fetch <object> additional fields" (lookup_resources.rb:198); none of the five named events exist and unsupported field types are skipped silently. Recorded as PRD v1.5 D-13 (risk A-7 — every §12 metric is unreportable)
§16 A-1§8 of this RFC — RESOLVEDResolved 2026-07-15
§16 A-3 (nil AI value)§8 — open; omit key and let CRM rejectOpen — pending PM confirmation
§16 A-4§8 — org-level additional fields, PRD D-1/D-2 incorrectResolved 2026-07-27 — PM ratified org-level; PRD amended to v1.5
§16 A-5Resolved — body key = param_name, value = cfg['value']Resolved 2026-07-15
Training (new)Chunk 4B — train_ai_agent.rb build_non_api_args fixCovered

Comment log

  • 2026-07-14: RFC authored from PRD v1.4. All mermaid blocks validated with npx mmdc — 2 diagrams (flowchart + sequenceDiagram) parse cleanly. A-1 remains the sole blocker; all other chunks can begin.
  • 2026-07-15 (pass 1): A-1 resolved. Updated: resource_key → additional_field_deal; endpoint → GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user; normalizer renamed AdditionalFieldNormalizer; TYPE_MAP rewritten using field_type_id integers (1/2/3/7/8/9); resource_types.items built from CRM dropdown[] array; §5.2–5.3 updated with real endpoint + response shape + transformed output sample. New Decision 3.7: additional fields are org-level. A-4 open (PM); A-5 open (body key format).
  • 2026-07-15 (pass 2): Corrected Decision 3.1 (storage in parameters['arguments']), Chunk 4 written. New Chunk 4B for training fix.
  • 2026-07-15 (pass 3): CRM body format confirmed — additional_fields[] array, not individual top-level keys. Rewrote merge_additional_field_arguments to build the array. Updated §5.1.
  • 2026-07-27 (pass 5, PM): A-4 closed — PM ratified Decision 3.7 Option A (additional fields are org-level; no pipeline gate) and amended the PRD to v1.6 accordingly (D-1 amended, D-2 + story QACF-S04 withdrawn), answering open questions 1–4 of the completion-alignment audit. While reconciling the PRD against chatbot@master + chatbot-fe@main, three RFC-specified items were found not shipped and are now marked in the §9 coverage table rather than left reading "Covered": the Chunk-1 feature-flag rollout class (BOT-4668 Won't Fix → PRD D-12), AI array fill / any array type in the whitelist (PRD D-11 — the as-built merge is scalar-only, execute.rb:267), and the §11 observability events (PRD D-13). Also noted: the shipped merge runs for all four WRITE_ACTION_TYPES, so custom fields already reach deal_update/ticket_update at the BE (PRD D-15 / A-6). No BE re-work is proposed here — these are PM/squad decisions tracked in PRD §16 (A-5 → A-8).
  • 2026-07-15 (pass 4): Ticket additional field endpoint confirmed: GET /api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=user — same response shape as deal. Added additional_field_ticket resource key; Chunk 3 now uses one shared fetch_additional_fields(resource_key) method for both. AdditionalFieldNormalizer unchanged. RFC status: FULLY READY.