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
| Field | Value |
|---|---|
| RFC Type | Backend |
| Author | agus.suparman@mekari.com |
| Status | IDEA (draft) |
| Created | 2026-07-14 |
| PRD | crm-actions-custom-fields.md v1.4 |
| Epic | BOT-4662 |
| Delivery | not yet handed to delivery |
Sections at a Glance
- Infrastructure Topology
- Repo Reading Guide
- Technical Decisions
- Execution Plan
- API Contracts
- Observability
- Rollout and Rollback
- Open Questions
- §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 / Module | Responsibility | Net-new? |
|---|---|---|
LookupResources | Org-level additional-field definitions lookup (two new resource keys: additional_field_deal, additional_field_ticket) + normalizer call | Yes |
AdditionalFieldNormalizer | CRM field_type_id → PropertiesItem descriptor + is_additional_field: true shape for POST /v1/node-resources/lookup response | Yes (new file) |
Execute | merge_custom_field_arguments: reads parameters['custom_fields'], resolves values, merges into CRM body | Yes (new private method) |
AiAgentActionCustomFields rollout class | Per-org feature flag check (same pattern as AiAgent rollout) | Yes (new file) |
NodeRegistry seed | Add custom_fields_enabled: true to settings for qontak_crm_deal_create + qontak_crm_ticket_create | Yes (seed/migration) |
CrmHttpClient | Unchanged — reused as-is for new lookup endpoints | No |
| Qontak CRM API | Must accept custom-field keys/values in POST /deals and POST /tickets bodies | External — verify per A-1 |
2. Repo Reading Guide
2.1 Existing Code Anchors
Read these files in this order before writing any implementation:
| # | File | Read to learn |
|---|---|---|
| 1 | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:186-208 | extract_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. |
| 2 | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:244-260 | node_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. |
| 3 | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:56-105 | perform_execution — the orchestration method; new custom-field merge step inserts after process_body. |
| 4 | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:262-276 | enrich_body_for_deal_create — lead lookup enrichment runs after process_body; the custom-field merge must chain after both this and the standard body. |
| 5 | app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:1-57 | RESOURCE_PATHS + call dispatch — new resource keys crm_deal_custom_fields / crm_ticket_custom_fields add entries here. |
| 6 | app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:61-201 | Existing fetch_crm_deal_pipelines / fetch_crm_deal_stages / extract_data_from_response — the exact pattern the new fetch methods must follow. |
| 7 | app/core/repositories/node_resources/mekari_qontak_crm/crm_http_client.rb:45-62 | request_with_auth — the auth + 401-refresh entry point; all new CRM calls go through this, not request. |
| 8 | app/models/node_registry.rb:1-14 + schema | node_registry.properties is jsonb (static array), settings is jsonb (hash). The custom_fields_enabled flag lives in settings, not properties. |
| 9 | app/models/ai_agent_action.rb + schema | ai_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. |
| 10 | app/core/repositories/system_preferences/rollout/ai_agent.rb | Reference implementation for the per-org rollout pattern (company_id list in JSON value). Copy this shape for AiAgentActionCustomFields. |
| 11 | app/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb:74-92 | build_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
| Endpoint | Tag | Evidence |
|---|---|---|
POST /api/v3.1/deals | reused — body extended | execute.rb:19 ACTION_TYPE_MAPPING |
POST /api/v3.1/tickets | reused — body extended | execute.rb:21 ACTION_TYPE_MAPPING |
GET /api/v3.1/pipelines | reused as-is | lookup_resources.rb:11 |
GET /api/v3.1/pipelines/{id}/stages | reused as-is | lookup_resources.rb:12 |
GET /api/v3.1/tickets/ticket_pipelines | reused as-is | lookup_resources.rb:14 |
GET /api/v3.1/deals/info | reused as-is | lookup_resources.rb:13 |
GET /api/v3.1/tickets/info | reused as-is | lookup_resources.rb:15-16 |
GET /api/v3.1/users | reused as-is | lookup_resources.rb:18 |
GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user | new-with-justification — org-level deal additional field definitions; confirmed 2026-07-15 | No prior usage in repo; net-new CRM mobile API call |
GET /api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=user | new-with-justification — org-level ticket additional field definitions; same response shape as deal | No 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
| Claim | Evidence |
|---|---|
extract_arguments_by_destination drops args without a registered destination | execute.rb:193-194 — next 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 array | db/schema.rb — t.jsonb "properties", default: []; no dynamic mutation path exists in the codebase |
ai_agent_actions.parameters is a JSON column | db/schema.rb — t.json "parameters" |
No custom/additional-field lookup in LookupResources | lookup_resources.rb:37-57 — call dispatch covers exactly 8 resource keys, none for additional fields |
CrmHttpClient.request_with_auth handles 401 + retry | crm_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.value | rollout/ai_agent.rb:14 — rollout_cids = JSON.parse(rollout_ai_agent&.value || '[]'); rollout_cids.include?(organization.company_id) |
node_registry.settings is jsonb hash | db/schema.rb — t.jsonb "settings", default: {} |
Rollbar.error is the error instrumentation convention | execute.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-scoped | CRM 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: true | Confirmed 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 fields | train_ai_agent.rb:87 — map_param_type(registry_prop['type']); for additional fields registry_prop is nil — NoMethodError without the fix |
Training includes only use_ai: true args | train_ai_agent.rb:79 — next 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:
| Option | Pros | Cons |
|---|---|---|
A. parameters['arguments'] inline — is_additional_field: true marker | Matches actual FE storage; no schema change; execute.rb iterates one hash; consistent with existing arg resolution shape | Must filter is_additional_field in both execute and training paths |
B. Separate parameters['custom_fields'] top-level key | Cleaner separation | Contradicts 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 markerid— the CRM additional field's id (matchesidin CRM additional_fields API response; used asadditional_fields[n].idin 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 runtimecached_result_name— display label for dropdowns (sent asvalue_namein 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:
| Option | Pros | Cons |
|---|---|---|
A. New private merge_custom_field_arguments method | Zero risk to existing standard-field path; single responsibility | Slight duplication of use_ai resolution logic (3 lines) |
B. Extend extract_arguments_by_destination to also read parameters['custom_fields'] | DRY | Complicates 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:
| Option | Pros | Cons |
|---|---|---|
A. Standalone CustomFieldNormalizer module in node_resources/mekari_qontak_crm/ | Colocated with other CRM resource concerns; testable in isolation | New file |
B. Inline in LookupResources | No new file | Bloats 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 location — node_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:
| Option | Pros | Cons |
|---|---|---|
A. system_preferences rollout pattern — JSON array of company_ids in value | Identical to AiAgent rollout (rollout/ai_agent.rb); no schema change; ops can enable per account via rails console or admin | Global list — enabling for 100k accounts requires a large JSON blob (acceptable at current scale) |
B. Per-org boolean in organization.settings | Per-org, fast lookup | Requires 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:
| Option | Pros | Cons |
|---|---|---|
| A. Show additional fields without pipeline gate | Correct — fields are org-level; no artificial pipeline dependency | PRD 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 now | Technically 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:
| Option | Pros | Cons |
|---|---|---|
| A. No cache — synchronous lookup, consistent with existing lookups | Zero added complexity | If 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 session | Faster repeat opens | Cache 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:
| Option | Pros | Cons |
|---|---|---|
A. Native JSON array ["a", "b"] in the body | Standard REST practice; consistent with how tags already works in the existing deal body | Must be confirmed per A-1 |
B. Comma-separated string "a,b" | Some legacy APIs expect this | Non-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)returnstrueAiAgentActionCustomFields.enabled_for?(org_with_company_id_not_in_list)returnsfalseAiAgentActionCustomFields.enabled_for?(nil)returnsfalse- 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_id | CRM type label | html.element | html.type | property type | is_rl | notes |
|---|---|---|---|---|---|---|
| 1 | Single-line text | input | text | string | false | |
| 2 | Dropdown select | select | — | string | true | dropdown[] → resource_types fixed items |
| 3 | Number | input | number | number | false | |
| 7 | Percentage | input | number | number | false | stored as number |
| 8 | Text Area | textarea | — | string | false | |
| 9 | URL | input | url | string | false | |
| 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 tohtml.element: 'input',html.type: 'text',is_rl: false,resource_types: nil - Number field (
field_type_id: 3) normalizes tohtml.type: 'number',prop_type: 'number' - Textarea field (
field_type_id: 8) normalizes tohtml.element: 'textarea', nohtml.type - Unknown
field_type_id(e.g. 99) → returnsnil - 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:
- 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'
- Extend
calldispatch:
when 'additional_field_deal' then fetch_additional_fields('additional_field_deal')
when 'additional_field_ticket' then fetch_additional_fields('additional_field_ticket')
- Add one shared private method (both objects use identical response shape —
AdditionalFieldNormalizeris 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, notpipeline_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').callissuesGET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user(stubbed)LookupResources.new(organization_id: 1, resource_key: 'additional_field_ticket').callissuesGET /api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=user(stubbed)- Both return arrays matching the
POST /v1/node-resources/lookupitem shape (name,value,propertywithis_additional_field: true) - Dropdown fields (
field_type_id: 2):property.is_rl: true,resource_typespopulated fromdropdown[] - Non-dropdown:
property.is_rl: false,resource_types: nil - Unsupported
field_type_id→ excluded + Rollbar warning withresource_keyin payload - Auth error →
auth_error?path (consistent with other methods) - Network error →
[]+ Rollbar error withresource_key - Spec: extend
spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb
Chunk 4 — Execute — merge_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 andparameters['arguments']has:when"role_additional_field" => { "is_additional_field" => true, "id" => 1, "type" => "string", "value" => "12", "cached_result_name" => "admin" }merge_additional_field_argumentsruns, 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" }, thenvalue_namefalls back to"42"(same as value) - Given
use_ai: trueand@arguments = { 'role_additional_field' => '99' }, then the array item hasvalue: '99',value_name: '99'(nocached_result_nameavailable at AI-fill time) - Given multiple additional fields, then
body['additional_fields']contains one item per field - Given
value: nilfor a field (AI field, no runtime value resolved), then that field is omitted from the array - Given
additional_fields_enabled?false,merge_additional_field_argumentsis never called — body unchanged - Given no
is_additional_field: trueentries,body['additional_fields']is not set - Standard fields are NOT in
additional_fields—extract_arguments_by_destinationhandles 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(extendbuild_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" }, whenbuild_non_api_argsruns, thenargs['role_additional_field']={ type: 'str', description: 'Role additional field' } - Given
"balance": { is_additional_field: true, use_ai: true, type: "number" }, thenargs['balance']={ type: 'float', ... } - Given
"role_additional_field": { is_additional_field: true, use_ai: false, value: "12" }(manually set — nouse_ai), then it is NOT included in training args (standarduse_aiguard still applies) - Standard fields (no
is_additional_field) continue to resolve type fromregistry_prop['type']— no regression - No
NoMethodErrorwhen an additional field withuse_ai: trueis present and registry has no matching entry - Spec:
spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb— add context forbuild_non_api_argswith 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']→trueNodeRegistry.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.rbspec/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 foradditional_field_dealspec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb— add contexts foradditional_fields_enabled?,merge_additional_field_arguments, fullperform_executionwith additional fieldsspec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb— add context forbuild_non_api_argswithis_additional_field: trueentries (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/dataenvelope is emitted by the existingPOST /v1/node-resources/lookupcontroller layer —LookupResources#callreturns only the innerdataarray.
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; bothexecute.rbandtrain_ai_agent.rbbranch on thisid— 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 whenuse_ai: trueuse_ai: true— if present, runtime value comes from AI agent's argumentscached_result_name— FE display label for dropdown options; sent asvalue_nameinadditional_fields[]item (for non-dropdown fields,value_namefalls back tovalue)
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)
| Signal | Code location | Rollbar call |
|---|---|---|
| Custom field lookup failure | lookup_resources.rb rescue block in each new fetch method | Rollbar.error(e, message: 'ai_agent_action_custom_field_lookup_failed', pipeline_id:, organization_id:) |
| Unsupported CRM field type | lookup_resources.rb inside filter_map | Rollbar.warning('ai_agent_action_custom_field_type_unsupported', pipeline_id:, raw_type:, field_key:) |
| Custom field merge error | execute.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.infowith 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
| Stage | Action | Verification |
|---|---|---|
| Deploy | Ship all 5 chunks behind flag OFF | No behavior change for any org; existing create actions work as before |
| Internal QA | Add 1 internal org's company_id to the system_preferences value JSON array | Config → runtime → CRM merge verified for scalar + array fields; flag-OFF orgs unaffected |
| Closed Beta | Add 3–5 design-partner company_ids | Monitor ai_agent_action_custom_field_lookup_failed Rollbar rate; confirm CRM accept rate |
| GA | Flip all entitlement-holding orgs on | Monitor 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):
- Revert the 3 new files + 2 modified files via
git revert - Re-run
bundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rbto confirm baseline - 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
| # | Type | Question | Owner | Deadline | Status |
|---|---|---|---|---|---|
| A-1 | BE + Qontak CRM | — | RESOLVED 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-2 | Assumption | Additional-field lookup returns within ≤ 1.5 s p95. | BE | During Beta | Open — monitor during Internal QA |
| A-3 | Open | merge_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 + BE | Before Beta | Open |
| A-4 | PM | Before FE sprint | RESOLVED 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-5 | name vs field id | BE | — | RESOLVED 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):
| # | Item | Blocks |
|---|---|---|
| Closed 2026-07-27 — ratified; PRD amended to v1.5 | ||
| A-3 | PM 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_idmapping table confirmed against sample data - Additional field parameter shape confirmed (stored in
argumentswithis_additional_field: true) - CRM body key format confirmed (A-5 resolved 2026-07-15) —
param_name, not field id -
train_ai_agent.rbcrash 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 section | RFC coverage | Status |
|---|---|---|
| §6 Constraints — feature flag | Chunk 1 + §3 Decision 3.4 | ⛔ Specified 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 contract | Chunk 2 + AdditionalFieldNormalizer FIELD_TYPE_MAP (field_type_id-keyed) | Covered |
| §8 API behavior #1 — load custom fields | Chunk 3 + §5.2 | Covered — 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 fields | Chunk 4 + §5.1 | Covered |
| §8 API behavior #4 — AI array fill | Chunk 4 merge_custom_field_arguments Array(value) branch | ⛔ NOT 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 criteria | Covered |
| §11 Observability events | §6.1 + §6.2 | ⛔ NOT 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 — RESOLVED | Resolved 2026-07-15 |
| §16 A-3 (nil AI value) | §8 — open; omit key and let CRM reject | Open — pending PM confirmation |
| §16 A-4 | §8 — org-level additional fields, PRD D-1/D-2 incorrect | Resolved 2026-07-27 — PM ratified org-level; PRD amended to v1.5 |
| §16 A-5 | Resolved — body key = param_name, value = cfg['value'] | Resolved 2026-07-15 |
| Training (new) | Chunk 4B — train_ai_agent.rb build_non_api_args fix | Covered |
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 renamedAdditionalFieldNormalizer; TYPE_MAP rewritten usingfield_type_idintegers (1/2/3/7/8/9);resource_types.itemsbuilt from CRMdropdown[]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. Rewrotemerge_additional_field_argumentsto 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 fourWRITE_ACTION_TYPES, so custom fields already reachdeal_update/ticket_updateat 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. Addedadditional_field_ticketresource key; Chunk 3 now uses one sharedfetch_additional_fields(resource_key)method for both.AdditionalFieldNormalizerunchanged. RFC status: FULLY READY.