Skip to main content

RFC: AI Agent Impact Report — Phase 1: Live Impact Report

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Sections marked N/A — reason are not deleted.

It is also agent-execution-ready: §1 Design References (FE half) + §1 PRD-to-Schema Derivation (BE half), §2 Repo Reading Guide (Detail 2.0) for both layers, mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe are complete before §7.

Delivery & project management live elsewhere. This RFC is the technical artifact only — no staffing, effort, timeline, or rollout scheduling. Those live in the initiative's delivery/ folder. Until handed off, the Delivery row reads not yet handed to delivery.

The YAML frontmatter is the machine-readable index; the metadata table is the human-readable governance record. Both agree on every shared field.

Metadata

FieldValueNotes
StatusIDEAYAML status: carries the remapped linter enum draft
DRIDimas Fauzi HidayatSingle accountable owner of this RFC. Per-task staffing lives in delivery/.
Teamchatbot (BOT — Bot, AI & Automation)Advisory squad slug from the source PRD / initiative README
Author(s)Dimas Fauzi HidayatPM/DRI; eng implementors listed under Reviewers
Reviewersagus.suparman (BE), baghiz.firdausa (FE), BI/Data (datamart)Tech reviewers across affected squads
Approver(s)TBD — BOT tech lead · TBD — infosec approverInfosec approver required before AGREED (see §5 OQ-8)
Submitted Date2026-07-08Date RFC opened for discussion
Last Updated2026-07-08Bump on every material edit
Target Release2026-Q3Carried from PRD
Target Quarter2026-Q3Advisory, from source PRD / initiative README
Deliverynot yet handed to deliveryWill point to ../delivery/timeline.md once handed off
RelatedPhase 1 PRD · AnchorRepo-relative paths that resolve
DiscussionTBD — #bot-ai squad channelSlack thread to be created at RFC open

Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature

Sections at a Glance

  1. Overview (§1 Design References — FE half, §1 PRD-to-Schema Derivation — BE half, traceability, per-story change map)
  2. Technical Design (Repo Reading Guide → topology → ADRs → end-to-end mermaid → DDL → APIs → cross-layer verification)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, §4 Agent Execution Plan, Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Phase 1 ships a native, read-only, per-organization report at /reports/ai-agent-impact in the Qontak web admin (chatbot-fe), backed by new read + config endpoints in the chatbot Grape API. It answers one question for a non-technical org admin ("Bu Rina"): did the AI agent do a good job and is it worth the spend? — via an honest containment hero (net of 48h reopens), value tiles (volume, after-hours, work-absorbed), a room-grain AI+human blended-journey bar, quality tiles (reopen rate, sentiment delta, turns-to-resolve), and a flag-gated forecast trend.

The report reads a pre-computed daily aggregate (ai_activity_logs) rather than hitting the live rooms table at request time. Phase 1 introduces no new AI-telemetry instrumentation (persisted confidence / answer-scope reason are deferred to Phase 2); it composes the aggregate entirely from data the platform already writes.

This is a delta-free new surface: no existing endpoint, report, or behavior changes.

Success Criteria

  1. Correctness of the hero. Honest containment = share of eligible conversations with rooms.closed_reason='RESOLVE_AI' AND assign_channel_agent_id IS NULL AND closed_at IS NOT NULL, minus same-contact_id reopens within 48h; excludes channel='bot_preview' and closed_reason='SPAM'. Validated against a hand-checked sample at Internal Alpha (PRD §13 gate).
  2. Latency. Report render ≤ 3s p95 for a 90-day window; the aggregate read ≤ 2s p95 (served from a pre-computed per-org-per-day table, indexed on (organization_id, activity_date)).
  3. Data freshness ≤ 24h (nightly aggregation).
  4. Reliability ≥ 99% successful loads within 30 days of GA (1 − impact_report_load_failed ÷ impact_report_viewed).
  5. Honest-containment integrity — 100% of rendered reports show the reopen-adjusted (net) figure plus the false-resolution gap; a gross-only number is never shown.

Out of Scope

Carried from PRD §5 Non-Goals — all n/a — deferred:

  • CSAT / customer-satisfaction survey (Phase 3).
  • Quality-validated resolution / Ada-grade scoring — needs persisted confidence (Phase 3).
  • Answer-coverage / out-of-scope reasons — needs AI-service reason persisted (Phase 2).
  • Knowledge-gap → projected-lift / "Draft with AI" (Phase 2).
  • Audited rupiah (Billing-audited cost-per-resolution) — Phase 1 uses an admin-set assumption (Phase 3).
  • Per-message blended transcript — BE does not store human-agent reply text; room-grain only.
  • Scheduled email/push digest (later enhancement).
  • Mobile app — web admin only this phase.
  • i18n framework — the app has none today; copy is hardcoded (see Decision 8).
  • PRD (driver): Phase 1 — Live Impact Report (MVP) — READY 9.8/10, 2026-07-01.
  • Anchor: AI Agent Impact Report — ANCHOR — phase index + initiative decisions.
  • Repos read as documentation: chatbot (Rails 7.1 / Grape BE), chatbot-fe (Nuxt 3 / Vue 3 / Pinia FE), qontak-designer (reviewed — prototype sandbox, no reusable code; see §2.0 note).

Assumptions

  • A1. A per-org-per-day aggregate table named ai_activity_logs is the report's read spine. Phase 1 populates it from rooms (+ an app-level sentiment join) via a nightly job owned by chatbot BE (Decision 1); a future migration to a BI/Data-owned Hologres datamart is the Phase 2 path (Hologres::UnansweredQuestion is the existing precedent — app/models/hologres/unanswered_question.rb).
  • A2. The closed_reason string literals the metric depends on (RESOLVE_AI, RESOLVE, ASSIGN_AGENT, ASSIGN_AGENT_AI, WAITING_ASSIGN_AGENT, SPAM) match what the platform writes — verified in config/locales/en.yml:346-350 + write sites (§2.0 Source Verification). There is no DB enum/CHECK guaranteeing the set, so the aggregation job treats unknown values as "other" and excludes them from AI wins.
  • A3. Sentiment lives in Postgres (chatbot_gpt.omnichannel_room_summaries.sentiment, a string), not MongoDB — the PRD's MongoDB assumption is corrected here (verified: no Mongoid in the repo). Join to rooms is app-level (omnichannel_room_summaries.room_idrooms.channel_room_id, both strings, cross-DB).
  • A4. BE role strings are lowercase owner / supervisor / adminverified from report.rb:16 set_role(%w[owner supervisor admin]). Cost-assumption write is owner/admin only.
  • A5. The "AI add-on / Generative AI" entitlement is a per-org ChatbotGpt::OrganizationFeature record (enabled, under an active order) — mechanism verified; the exact feature code is data, not source → OQ-1.

Dependencies

#DependencyLayer / OwnerAvailabilityBlocking?
D1rooms table (Postgres primary) — read source for all room-outcome metricschatbot BE (exists)exists
D2chatbot_gpt.omnichannel_room_summaries.sentiment (Postgres, separate DB)chatbot BE (exists)existsNO — sentiment tile degrades gracefully
D3ai_activity_logs per-org daily aggregate + nightly jobchatbot BE (new, this RFC)needs buildingYES
D4ai_cost_assumptions per-org config table + CRUDchatbot BE (new, this RFC)needs buildingYES (for work-absorbed tile)
D5Report page + tiles + modal + journey/forecast vizchatbot-fe (new, this RFC)needs buildingYES
D6Per-org business-hours config (after-hours tile)chatbot BEunknown → OQ-4NO — tile ships only if config exists (PRD §16 mitigation)
D7Reports role gate (set_role) + org-ownership middlewarechatbot BE (exists)exists — reusedNO — already exists
D8Subscription features[] array carrying the AI add-on + report flag codes on FEchatbot BE → chatbot-fe (exists mechanism)exists mechanism; new code → OQ-1YES (for the FE entry gate)

Design References (frontend half)

Design not yet created. The PRD ships with plain-language + annotated wireframes and Appendix A Stitch prompts; no Figma frames exist. Per the skill, surfaces without a frame are n/a — design pending and the missing frames are logged in §5 (OQ-6). FE chunks that are purely layout-faithful to the component tree may proceed against the wireframe; pixel-level visual QA is gated on Figma landing.

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
AiAgentImpactPage (report)n/a — design pending (OQ-6)@mekari/pixel3@^1.0.12 (token mode 2.4)TBD (OQ-6)Reference: mekari-taste plain-language wireframe + PRD Appendix A Stitch prompt #1
CostAssumptionModaln/a — design pending (OQ-6)@mekari/pixel3@^1.0.12TBD (OQ-6)Reference: PRD Appendix A Stitch prompt #2; build on MpModal* (§2.A)
BlendedJourneyBar / ForecastPanel (custom viz)n/a — design pending (OQ-6)custom SVG/CSS (no chart lib — Decision 7)TBD (OQ-6)Hand-built SVG; not a DS component

Design-system version is verified from chatbot-fe/package.json ("@mekari/pixel3": "^1.0.12").

PRD-to-Schema Derivation (backend half)

PRD entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced whereSource
Honest containment = RESOLVE_AIassign_channel_agent_id IS NULLclosed_at NOT NULL, net of 48h same-contact reopensai_activity_logs.contained_ai_net, .contained_ai_gross, .reopened_48h (daily per-org counts)GET /v1/reports/ai_agent_impactNightly aggregation job AiActivityLogAggregator (SQL COUNT(...) FILTER (WHERE ...) over rooms)PRD §9.1 step 5, IMPACT-S01/AC-1..4
Exclude bot_preview + SPAM from numerator & denominatorfilter in aggregator; not stored as rowssame endpointaggregator WHERE channel <> 'bot_preview' AND closed_reason <> 'SPAM'IMPACT-S01/AC-3
Bare RESOLVE is NOT an AI winexcluded from contained_ai_*same endpointaggregator classificationIMPACT-S01/AC-4
Blended journey (AI-only / AI-assisted-then-human / escalated), room grainai_activity_logs.journey_ai_only, .journey_ai_assisted, .journey_escalatedsame endpointaggregator closed_reason→segment mappingPRD §9.1 step 6, IMPACT-S02/AC-1..3
Reopen rate (same contact_id, new conversation ≤48h)ai_activity_logs.reopened_48h / .contained_ai_grosssame endpointaggregator self-join on contact_id within 48hIMPACT-S03/AC-1
Sentiment delta (AI-contained vs escalated)ai_activity_logs.sentiment_contained_*, .sentiment_escalated_*, .sentiment_coveragesame endpointaggregator app-level join to omnichannel_room_summaries.sentimentIMPACT-S03/AC-2, AC-4
Turns-to-resolve (avg messages per AI-contained room)ai_activity_logs.turns_sum, .turns_countsame endpointaggregator message-count source (OQ-3 confirm source)IMPACT-S03/AC-3
After-hours containment (count + %)ai_activity_logs.contained_after_hourssame endpointaggregator vs per-org business-hours config (OQ-4)PRD §7, §14 D6
Containment trend vs onboarding baseline (first 30d of AI activity)derived at read time from ai_activity_logs date seriessame endpoint (trend[])report use-case (no new storage)IMPACT-S05/AC-1
Next-period forecast (flag-gated)derived at read time; not storedsame endpoint (forecast when flag on)report use-case behind ai_agent_impact_report_forecast flagIMPACT-S05/AC-2..3
Baseline-forming (<30d data OR <min conversations)ai_activity_logs row count / date spanbaseline_forming: true flag in responsereport use-caseIMPACT-S01/AC-6
Cost assumption (agent-hour rate + minutes/conversation, per org)ai_cost_assumptions.agent_hour_rate, .minutes_per_conversation, .organization_idGET/PUT /v1/reports/ai_agent_impact/cost_assumptionGrape endpoint + validation + set_role(%w[owner admin])PRD §8 Behavior 2, IMPACT-S04/AC-1..3
Work-absorbed ≈ rupiah = contained × minutes → hours × rate; only after assumption set (no default)computed at read time from ai_cost_assumptions × contained_ai_netreport endpoint (work_absorbed null until set)report use-case; null when no assumptionIMPACT-S04/AC-1, AC-3
AI add-on required; else no accessnot stored hereendpoint 403 + FE entry hiddenChatbotGpt::OrganizationFeature entitlement check (OQ-1)IMPACT-S01-NEG/NEG-1

Every §2.3 DDL row and §2.4 endpoint traces to a row here. Rows depending on unverified facts (business-hours config, message-count source, feature code) are flagged to §5, not invented.

Detail 1.A — PRD Traceability (cross-layer)

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
IMPACT-S01/AC-1..5HonestContainmentTile, ValueDeliveredGrid (§2.A)GET /v1/reports/ai_agent_impactcontained_ai_net/gross, tiles (§2.4)
IMPACT-S01/AC-6BaselineForming empty state (no-data.vue, §2.C)baseline_forming flag (§2.4)
IMPACT-S01/ERR-1Error+Retry state (§2.C, §3.C)5xx/timeout → error envelope; impact_report_load_failed (§3.B, §11)
IMPACT-S02/AC-1..3, ERR-1BlendedJourneyBar (custom SVG, §2.A)journey_* fields (§2.4)
IMPACT-S03/AC-1..4, ERR-1QualityGrid tiles (§2.A)reopen_rate, sentiment_*, turns_* (§2.4)
IMPACT-S04/AC-1..3, ERR-1CostAssumptionModal (MpModal*, §2.A)GET/PUT .../cost_assumption (§2.4)
IMPACT-S05/AC-1..4ForecastPanel (custom SVG, flag-gated, §2.A)trend[], forecast (§2.4)
IMPACT-S01-NEG/NEG-1entry hidden for non-AI accounts (middleware, §2.0)entitlement 403 (§3 auth matrix)
IMPACT-S02-NEG/NEG-2entry hidden for Agent roleset_role 403 (§3 auth matrix)
IMPACT-S03-NEG/NEG-3no CSAT/quality UI (out of scope)n/a — not built
IMPACT-S04-NEG/NEG-4web only; not on mobilen/a — web only

Reverse (RFC → PRD AC):

New FE component / BE endpoint / dependencyPRD composite AC id it serves
GET /v1/reports/ai_agent_impactIMPACT-S01/AC-1..6, S02/AC-1..3, S03/AC-1..4, S05/AC-1..4
GET/PUT /v1/reports/ai_agent_impact/cost_assumptionIMPACT-S04/AC-1..3, ERR-1
ai_activity_logs table + AiActivityLogAggregator jobIMPACT-S01/AC-1..4, S02, S03, S05
ai_cost_assumptions tableIMPACT-S04/AC-1..3
AiAgentImpactPage + pages/reports/ai-agent-impact/index.vueIMPACT-S01..S05 (surface)
ai-agent-impact-feature.ts middlewareIMPACT-S01-NEG/NEG-1, S02-NEG/NEG-2

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
AiAgentImpactPage (/reports/ai-agent-impact)webGET /v1/reports/ai_agent_impactAiAgentImpactPage viewbaseline_forming flag + fetchStatus string
CostAssumptionModalwebGET .../cost_assumptionPUT .../cost_assumptionCostAssumptionModalhas_cost_assumption flag
ForecastPanelwebGET .../ai_agent_impact (forecast field)ForecastPanelforecast present iff flag on

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
Ownerset_role(%w[owner supervisor admin]) + Ownership middleware; write: set_role(%w[owner admin])GET report; GET/PUT cost_assumptionfull report + cost editorno (org-scoped)Mixpanel cost_assumption_updated; Lograge audit
Adminsame as OwnerGET report; GET/PUT cost_assumptionfull report + cost editornosame
Supervisorset_role (read only)GET report; GET cost_assumptionfull report; cost figure read-only (no editor)noMixpanel impact_report_viewed
Agentset_role rejects (403)noneentry not renderedno
Any role on non-AI accountentitlement check rejects (403)noneentry not renderedno

PRD Section Coverage

PRD §TitleWhere covered
Scope ChangesBackend · Frontend · Data§1 Overview, §2 (all)
2One-liner + Problem§1 Overview
3What if we don't ship§1 Overview (motivation)
4Target Users + Persona§1 Overview; plain-language copy (Decision 8)
5Non-Goals§1 Out of Scope
6Constraints§1 Success Criteria, §3 Performance, §4.B Config
6.7Data Lifecycle§2.3 per-status/retention table
7New Features (component tree, UI states)§2.A UI Contract, §2.C UI State Matrix
8API & Webhook Behavior§2.4 APIs
9.1System Flow§2.2 Sequence diagrams
9.2User Stories + ACsDetail 1.A + 1.C
10Rollout§4 Rollout Strategy
10.5Semantic Regression Rollback (forecast)§4 Rollout (forecast flag), §4.E rollback
11Observability§3 Monitoring, §3.B events
11.1Post-Launch Monitoring Cadence§4 Rollout (stop conditions)
12Success Metrics§1 Success Criteria
13Launch Plan & Stage Gates§4 Rollout stages
14Dependencies§1 Dependencies + §2.F.1 Responsibility Boundary
15Key Decisions + Alternatives§1 Detail 1.B + §2 Technical Decisions
16Open Questions§5
Appendix AStitch UI Prompts§1 Design References (design pending)

Detail 1.B — Decisions Closed (cross-layer)

DecisionChosen optionAlternatives rejectedWhy rejectedLayer
D1 Aggregate ownership & sourceChatbot-owned nightly job → ai_activity_logs per-org daily aggregate table (primary Postgres)(a) BI/Data-owned Hologres datamart now; (b) live rooms read at request time(a) cross-squad blocking dep, unshippable MVP; (b) fails ≤2s p95 + PRD "not a direct read"BE / Data
D2 Containment definitionRESOLVE_AI ∧ no human ∧ closed, net of 48h reopens; exclude bare RESOLVE, bot_preview, SPAMgross containment as heroover-counts idle-timeout/abandonment; fails credibility barBE
D3 Sentiment sourcePostgres chatbot_gpt.omnichannel_room_summaries.sentiment, app-level joinMongoDB (PRD assumption)no Mongoid in repo — MongoDB does not exist hereBE
D4 Money modeladmin-set assumption (ai_cost_assumptions), no default rateQontak-audited rupiahno rate/benchmark in BE; contestable; Phase 3BE + FE
D5 Forecast gatingown flag ai_agent_impact_report_forecast, OFF by defaultship forecast unconditionallyprojection can mislead; §10.5 rollback needs a kill-switchBE + FE
D6 Read spinepre-computed daily aggregate, not request-time SQL over roomsMetabase iframe (existing /report)can't host cost toggle / forecast / config; native surface neededBE + FE
D7 Trend/journey visualizationcustom SVG/CSS (Tailwind + pixel3)add a chart lib (chart.js/echarts/apexcharts)no chart lib exists; MVP avoids new dep + bundle costFE
D8 Copy / localizationhardcoded Indonesian-first plain-language copy inlineintroduce an i18n frameworkno i18n exists in chatbot-fe; net-new infra out of scopeFE
D9 Route path/reports/ai-agent-impact (pages/reports/ai-agent-impact/index.vue)reuse singular /report namespacePRD commits the URL (appears in NEG-1); existing /report is a separate Metabase LPFE
D10 Reuse-vs-new (report endpoints)new-with-justification — new report shape; reuse set_role+Ownership middleware, Grape entity + envelope patternsextend existing V1::Report (metabase) / V1::CustomReportthose serve a Metabase URL / generic room counts, not this metric setBE
D11 Per-status lifecycleai_activity_logs is an append/replace aggregate (13-month TTL); ai_cost_assumptions is upsert (lifetime, overwrite on edit) — neither has a user-facing status enumsoft-delete/status columnsno lifecycle states surfaced to users; TTL cleanup onlyBE
D12 Sync vs asyncaggregation is async (nightly sidekiq-cron); report read + cost write are synccompute metrics synchronously at requestrequest-time cross-DB aggregation blows the ≤2s p95 budgetBE

Honesty note: D3 and A3 correct a factual error in the PRD (MongoDB) — surfaced, not silently "fixed".

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
IMPACT-S01Honest containment + value deliveredFE + BEHonestContainmentTile, VolumeTile, AfterHoursTile, WorkAbsorbedTile, ValueDeliveredGrid, PlainHeadlineSummary; baseline-forming via no-data.vue; error+retry stateai_activity_logs + aggregator (contained_ai_net/gross, reopened_48h, after_hours, volume); GET /v1/reports/ai_agent_impact report use-caseIMPACT-S01/AC-1..6, ERR-1rspec: aggregator on a fixture org yields expected net/gross & excludes bot_preview/SPAM/bare RESOLVE; vitest: tile renders verdict not bare number; e2e: baseline-forming shows volume-only, no %§2.3, §2.4 row 1, §2.A, §4.D chunks 1-3,6-8 · PRD-to-Schema rows 1-4,7,9
IMPACT-S02AI + human journeyFE + BEBlendedJourneyBar (custom SVG 3-segment), plain labelsjourney_ai_only/ai_assisted/escalated in aggregator + responseIMPACT-S02/AC-1..3, ERR-1rspec: segments sum to 100% and map closed_reason correctly; vitest: labels are plain-language; degrade placeholder on missing data§2.3, §2.4 row 1, §2.A · PRD-to-Schema row 4 · §4.D chunk 2,7
IMPACT-S03Is the AI doing a good jobFE + BEReopenRateTile, SentimentDeltaTile, TurnsTile (QualityGrid), per-tile degradereopen_rate, sentiment_* (app-level join, coverage note), turns_* in aggregatorIMPACT-S03/AC-1..4, ERR-1rspec: reopen self-join within 48h; sentiment excludes missing rows + reports coverage; turns avg computed; vitest: tile "not available" on missing sentiment§2.3, §2.4 row 1, §2.A · PRD-to-Schema rows 5,6,7 · §4.D chunk 2,7
IMPACT-S04Cost assumption → money savedFE + BECostAssumptionModal (MpModal*), inline validation, work-absorbed prompt when unsetai_cost_assumptions table; GET/PUT .../cost_assumption; set_role(%w[owner admin]); bounds validationIMPACT-S04/AC-1..3, ERR-1rspec: PUT persists + recompute; negative/non-numeric → 422, nothing saved; non-owner/admin → 403; vitest: unset → prompt, no fabricated figure§2.3, §2.4 rows 2-3, §2.A · PRD-to-Schema rows 11-12 · §4.D chunks 4,9
IMPACT-S05Trend + next-period forecastFE + BEForecastPanel (custom SVG, solid past + dashed projection), flag-gatedtrend[] derived; forecast when ai_agent_impact_report_forecast onIMPACT-S05/AC-1..4rspec: trend vs onboarding baseline; forecast present iff flag on; vitest: dashed projection hidden when flag off§2.4 row 1, §2.A, §4.B flag · PRD-to-Schema rows 8-9 · §4.D chunk 10
IMPACT-S01-NEGNo AI add-onConfig + BEentry not rendered (middleware)entitlement check → 403IMPACT-S01-NEG/NEG-1rspec: non-AI org → 403; e2e: no report entry rendered§3 auth matrix, §2.0 middleware · §4.D chunk 5
IMPACT-S02-NEGIneligible roleConfig + BEentry not rendered for Agentset_role → 403IMPACT-S02-NEG/NEG-2rspec: Agent role → 403; e2e: no entry, no surfaced 403§3 auth matrix · §4.D chunk 5
IMPACT-S03-NEGOut-of-scope metricsn/a — not builtno CSAT/quality UInoneIMPACT-S03-NEG/NEG-3manual: Phase-1 report shows no CSAT/quality section§1 Out of Scope
IMPACT-S04-NEGMobile appn/a — web onlynot on mobilenoneIMPACT-S04-NEG/NEG-4manual: report absent on mobile app§1 Out of Scope, D9

2. Technical Design

Detail 2.0 — Repo Reading Guide

Read this before writing any code. qontak-designer was reviewed and is recorded as "prototype sandbox — no reusable code or component dependency; visual reference only" (it is a private, unpublished Nuxt 4 Pixel-3 prototyping repo; its voice-of-customer and commerce analytics pages are useful UX references only). It is not a build input.

Repo Map (both layers)

flowchart LR
subgraph fe["chatbot-fe (Nuxt 3 / Vue 3 / Pinia)"]
page["pages/reports/ai-agent-impact/index.vue"]
view["modules/report/views/ai-agent-impact.vue + tiles"]
mw["middleware/ai-agent-impact-feature.ts"]
store["store/ai-agent-impact/ (6-file)"]
svc["common/services/main/v1/ai-agent-impact.ts + endpoint.ts"]
apimain["plugins/api/apiMain.ts ($apiMain / ofetch)"]
end
subgraph be["chatbot (Rails 7.1 / Grape)"]
ep["app/api/frontend_service/v1/report.rb (+ entities)"]
uc["app/core/use_cases/api/frontend_service/v1/ai_agent_impact/*"]
repo["app/core/repositories/ai_agent_impact/*"]
job["app/workers/ai_activity_log_aggregator_worker.rb (sidekiq-cron)"]
end
subgraph infra["datastores"]
agg[("ai_activity_logs (new, primary PG)")]
cost[("ai_cost_assumptions (new, primary PG)")]
rooms[("rooms (primary PG)")]
summ[("chatbot_gpt.omnichannel_room_summaries")]
end
page --> view --> store --> svc --> apimain --> ep
mw --> page
ep --> uc --> repo --> agg
repo --> cost
job --> rooms
job --> summ
job --> agg

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEapp/api/frontend_service/v1/report.rbthe report endpoint to mirrorGrape endpoint + set_role(%w[owner supervisor admin]) + Middlewares::Ownership + Grape entity response
BEapp/api/frontend_service/helpers/authorization_helpers.rbrole gateset_role(roles) raises ErrorException(code:403) if role not included
BEapp/api/frontend_service/middlewares/ownership.rborg scoping403 unless env['user']['chatbot_organization_id']
BEapp/core/repositories/custom_report/generate.rbthe containment-style aggregationRoom.select("COUNT(id) FILTER (WHERE ...)")....group("DATE(created_at)") scoped by organization_id
BEapp/models/room.rb + db/schema.rb:1761read schemaAR model on primary PG; closed_reason, assign_channel_agent_id, closed_at, contact_id, channel, channel_room_id columns + indexes
BEapp/models/hologres/unanswered_question.rb + app/models/hologres_record.rbdatamart precedent (Phase 2 path)HologresRecord connects_to + schema-qualified self.table_name
BEapp/models/chatbot_gpt/omnichannel_room_summary.rb + db/chatbot_gpt_schema.rbsentiment sourceseparate chatbot_gpt PG DB; sentiment string; join key room_idrooms.channel_room_id
BEapp/models/system_preference.rb + app/core/repositories/knowledge_stores/create.rb:36rollout flagSystemPreference.find_by(group_code:'rollout', code:..., enabled:true)
BEapp/core/repositories/gpt/subscription/subscription_detail.rb:36-51AI add-on entitlementChatbotGpt::OrganizationFeature.where(company_id:, order_id: <active>, enabled:true)
BEapp/workers/assign_agent_worker.rb + config/schedule.ymlworker + croninclude Sidekiq::Worker + sidekiq_options; sidekiq-cron entry cron: "0 1 * * * Asia/Jakarta"
BEdb/migrate/20260624000001_add_related_key_and_related_type_to_attachments.rbmigration styleActiveRecord::Migration[7.1] + disable_ddl_transaction! + add_index algorithm: :concurrently
FEmodules/report/views/bot-peformance.vueexisting report viewthin page → module view; fetchStatus string machine; 403 → redirect
FEstore/report/ (index/state/getters/actions/interface/types)store convention6-file Pinia store, extractStore, action constants, persist:true
FEcommon/services/main/v1/report.ts + common/services/main/endpoint.ts:62service layerservice returns { fetch, controller }; endpoints centralized under v1.<domain>
FEplugins/api/apiMain.ts + api/mainResources.tsHTTP client$apiMain (ofetch) injects Bearer token; returns response._data
FEmiddleware/ai-assist-dynamic-kb-feature.tsfeature-flag gatesubscriptionStore().$state.subscriptionData.features.find(code===...).enabledabortNavigation(createError({statusCode:404}))
FEmodules/settings/views/ai-assist.vue:474-513modal patternMpModal/MpModalContent/Header/Body/Footer/Overlay compound; local ref open state
FEcommon/components/error/no-data.vueempty statereusable empty/baseline component (title/description/svgName)
FEcommon/utils/tracking.ts + common/contants/mixpanel-events.tsanalyticstrackEvent(MIXPANEL_EVENTS.X, props, jimo?); names are [CHATBOT] ... strings
FEauthenticationStore().profile.data.role (via common/utils/tracking.ts)role sourcerole is a string on the auth profile (used for analytics today)

Existing Contracts to Reuse, Extend, or Replace (BE)

ContractStatusJustificationOwner
set_role helper + Middlewares::Ownershipreuseexact role + org gate the report needschatbot BE
Grape success envelope / ErrorException error shapereuseconsistent API contractchatbot BE
Room.select("COUNT FILTER") aggregation idiomreuse (pattern)proven containment-style aggregationchatbot BE
sidekiq-cron (config/schedule.yml)reuse (pattern)nightly aggregation jobchatbot BE
GET /v1/reports/ai_agent_impactnew-with-justificationno existing endpoint returns this metric set; V1::Report serves a Metabase URL, V1::CustomReport serves generic room countschatbot BE
GET/PUT /v1/reports/ai_agent_impact/cost_assumptionnew-with-justificationno per-org cost-assumption store existschatbot BE
ai_activity_logs table + aggregatornew-with-justificationtable does not exist (verified NOT FOUND)chatbot BE
ai_cost_assumptions tablenew-with-justificationno such table/columns existchatbot BE

Patterns to Follow

LayerConcernPattern in repoReference fileDeviation?
FEState management6-file Pinia store + extractStorestore/report/none
FEData fetchingservice {fetch,controller} → store action tracks fetchStatuscommon/services/main/v1/report.ts, store/report/actions.tsnone
FEError / retrynone reusable — build with MpBanner + MpButtoncommon/components/error/no-data.vue (empty only)yes — new error+retry component
FEModalpixel3 MpModal* compoundmodules/settings/views/ai-assist.vuenone
FEChartsnone — hand-built SVG/CSSn/a (no chart lib)yes — custom viz (Decision 7)
FERole gatenone — new middleware from flag-gate templatemiddleware/ai-assist-dynamic-kb-feature.tsyes — role predicate is new (Decision, OQ-2)
BEGrape handler shapeendpoint → use-case (dry-monads) → repositoryapp/api/frontend_service/v1/report.rbnone
BERepository / DB accessAR select/where/group, org-scopedapp/core/repositories/custom_report/generate.rbnone
BEScheduled jobSidekiq worker + sidekiq-cronapp/workers/assign_agent_worker.rb, config/schedule.ymlnone
BEError response shapeErrorException(message:, code:, errors:)app/api/frontend_service/helpers/error_response_helpers.rbnone
BELogging / tracingLograge structured + Datadog + Rollbar; Mixpanel product events (dot/[CHATBOT])config/initializers/{lograge,datadog,mixpanel}.rbnone
Crosssnake_case API → camelCase FEFE reads raw JSON keys from $apiMain response _datastore/report/actions.ts (res.data)none — FE consumes snake_case as-is (confirm mapper, OQ-5)

Reading Order for the Agent

  1. app/api/frontend_service/v1/report.rb — how a report endpoint is mounted + gated.
  2. app/core/repositories/custom_report/generate.rb — the containment-style SQL aggregation to reuse.
  3. app/models/room.rb + db/schema.rb:1761 — exact columns + indexes the metric touches.
  4. config/locales/en.yml:346-350 — canonical closed_reason string values.
  5. app/models/chatbot_gpt/omnichannel_room_summary.rb + db/chatbot_gpt_schema.rb — sentiment source + join key.
  6. app/workers/assign_agent_worker.rb + config/schedule.yml — worker + nightly cron pattern.
  7. db/migrate/20260624000001_*.rb — migration house style (disable_ddl_transaction!, concurrent index).
  8. store/report/ + common/services/main/v1/report.ts — FE store + service pattern.
  9. middleware/ai-assist-dynamic-kb-feature.ts — flag-gate middleware to extend with a role check.
  10. modules/settings/views/ai-assist.vue + common/components/error/no-data.vue — modal + empty-state components.

Source Verification

LayerAnchor / pattern / contractVerified byEvidence
BEreport.rb role gatereadreport.rb:16 set_role(%w[owner supervisor admin]); report.rb:6 use Middlewares::Ownership
BEset_role semanticsreadauthorization_helpers.rb raises ErrorException(message:['Permission denied'], code:403) unless role included
BEOwnership org scopereadownership.rb 403 ['Organization not found'] unless env['user']['chatbot_organization_id']
BEcontainment aggregation idiomreadcustom_report/generate.rb:100-118 Room.select("COUNT(id) FILTER (WHERE resolved_at IS NOT NULL) AS resolved ...").group("DATE(created_at)")
BErooms columnsreaddb/schema.rb:1761+ t.string "closed_reason", t.string "assign_channel_agent_id", t.datetime "closed_at", t.bigint "contact_id", t.string "channel"; indexes index_rooms_on_closed_reason, _on_assign_channel_agent_id, _on_organization_id
BEclosed_reason valuesreadconfig/locales/en.yml:346-350 (resolve_ai:"RESOLVE_AI" etc.); 'WAITING_ASSIGN_AGENT' at send_message_with_resolve.rb:1060; 'SPAM' at process_incoming_message_with_resolve.rb:785
BEbot_preview channel valuereaden.yml:279,533 bot_preview; compared at assign_agent_round_robin.rb:31 room.channel == I18n.t('bot_preview.channel')
BEsentiment source (not Mongo)read/grepomnichannel_room_summary.rb < ChatbotGptRecord; chatbot_gpt_schema.rb t.string "sentiment"; grep -ric mongoid → 0
BEjoin keyreadomnichannel_room_summaries.room_id (string) ↔ rooms.channel_room_id (string); different PG DBs → app-level join
BEdatamart precedentreadhologres_record.rb:6 connects_to database:{reading::hologres,...}; hologres/unanswered_question.rb self.table_name='chatbot_ai.qontak_chatbot_ai_unanswered_questions'
BEai_activity_logs absentgrepgrep -rin "ai_activity_log|containment|impact_report" → 0 hits (must be built)
BErollout flag mechanismreadsystem_preference.rb; knowledge_stores/create.rb:36 SystemPreference.find_by(group_code:'rollout', code:'...', enabled:true)
BEAI add-on entitlementreadsubscription_detail.rb:36-51 ChatbotGpt::OrganizationFeature.where(company_id:, order_id:<active>, enabled:true); exact code is data → OQ-1
BEworker + cronreadassign_agent_worker.rb include Sidekiq::Worker; config/schedule.yml cron:"0 1 * * * Asia/Jakarta"
BEmigration styleread20260624000001_*.rb ActiveRecord::Migration[7.1] + disable_ddl_transaction! + add_index ..., algorithm: :concurrently
BEtest/lint commandsreadbitbucket-pipelines.yml:74 bundle exec rspec ...; :349 bundle exec rubocop
FENuxt/Vue/pnpmreadpackage.json "nuxt":"^3.12.2", "pinia":"^2.1.7", "@mekari/pixel3":"^1.0.12", "packageManager":"pnpm@10.15.1"
FEexisting report viewreadpages/report/index.vuemodules/report/views/bot-peformance.vue; fetchStatus "idle"/"pending"/"resolved"/"rejected"
FEstore conventionreadstore/report/index.ts defineStore("report", ...extractStore(useState()) ...); types.ts action constants
FEservice + endpointreadcommon/services/main/v1/report.ts returns {fetch,controller}; endpoint.ts:62-63 report:{get_metabse_url:"v1/reports"}
FEHTTP clientreadplugins/api/apiMain.ts provide("apiMain", customFetch); api/mainResources.ts ofetch.create injects Bearer
FEflag-gate middlewarereadai-assist-dynamic-kb-feature.ts features.find(code==="ai_assist_dynamic_kb").enabledabortNavigation(createError({statusCode:404}))
FEmodal patternreadmodules/settings/views/ai-assist.vue:474-513 MpModal+compound; import from @mekari/pixel3
FEempty statereadcommon/components/error/no-data.vue props title/description/svgName
FEanalyticsreadcommon/utils/tracking.ts trackEvent(name, props, jimo); mixpanel-events.ts [CHATBOT] ...
FErole sourcereadcommon/utils/tracking.ts reads authentication.profile.data.role (string; analytics only; no gate) → OQ-2
FEno chart lib / no i18n / no typecheckgrep/readno chart dep in package.json; no i18n/vue-i18n; no typecheck script (CI runs lint:ts + vitest)

Design ↔ Code Mapping (frontend half)

Figma pending (OQ-6). Mapping is to the PRD component tree + wireframe; pixel-level tokens are n/a — design pending and confirmed at design QA. Tokens listed are the pixel3 families the components will draw from.

Frame / componentImplementing fileReuse vs newDesign tokens (pixel3)Backing API endpointDeviation
AiAgentImpactPagemodules/report/views/ai-agent-impact.vuenewtext.*, background.stage, pixel3 spacingGET /v1/reports/ai_agent_impactn/a — design pending
HonestContainmentTile (hero)modules/report/components/ai-agent-impact/HonestContainmentTile.vuenew (MpBox/MpText/MpTag)brand color on hero number onlysamen/a — design pending
Value/Quality tiles.../components/ai-agent-impact/*Tile.vuenew (MpBox/MpText)text.default/text.secondarysamen/a — design pending
BlendedJourneyBar.../components/ai-agent-impact/BlendedJourneyBar.vuenew (custom SVG)3 segment colorssame (journey_*)custom viz (Decision 7)
ForecastPanel.../components/ai-agent-impact/ForecastPanel.vuenew (custom SVG)line + dashed projectionsame (trend[],forecast)custom viz (Decision 7)
CostAssumptionModal.../components/ai-agent-impact/CostAssumptionModal.vuenew (MpModal*)pixel3 modal tokensGET/PUT .../cost_assumptionn/a — design pending

Infrastructure Topology

Deployment topology

flowchart TB
internet(["Org admin browser"]) -->|HTTPS| lb["Load Balancer / Ingress"]
lb -->|"static SPA"| fe["chatbot-fe (Nuxt SPA, CDN/static)"]
fe -->|"HTTPS Bearer JWT"| api["chatbot frontend_service API pods (Grape, Puma) xN"]
api -->|"read"| aggdb[("Postgres primary: ai_activity_logs, ai_cost_assumptions, rooms")]
api -->|"read-only"| replica[("Postgres read replica")]
api -->|"read"| gptdb[("Postgres chatbot_gpt: omnichannel_room_summaries")]
api -->|"get / set (optional)"| cache[("Redis cache")]
cron["sidekiq-cron scheduler"] -->|"enqueue nightly"| queue[["Sidekiq queue (application_maintenance)"]]
queue -->|"consume"| worker["AiActivityLogAggregatorWorker pods xM"]
worker -->|"read rooms + summaries"| aggdb
worker -->|"read"| gptdb
worker -->|"upsert daily rows"| aggdb
api -->|"product events"| mixpanel(["Mixpanel (external)"])
api -->|"APM / errors"| obs(["Datadog / AppSignal / Rollbar (external)"])

No new external third-party API is introduced (Mixpanel/Datadog/Rollbar are existing sinks). The read path can optionally cache the assembled report in Redis (Decision: TTL 1h, per-org key — see §2.D / §3 caching).

Per-service responsibility (service use cases & third-party connections)

flowchart LR
subgraph fs["chatbot frontend_service (Grape API)"]
uc1["GET /v1/reports/ai_agent_impact (read report)"]
uc2["GET /v1/reports/ai_agent_impact/cost_assumption"]
uc3["PUT /v1/reports/ai_agent_impact/cost_assumption (owner/admin)"]
end
subgraph wk["chatbot worker (Sidekiq)"]
uc4["AiActivityLogAggregator (nightly)"]
end
uc1 -->|"AR read"| agg[("ai_activity_logs")]
uc1 -->|"AR read"| cost[("ai_cost_assumptions")]
uc1 -->|"entitlement check"| feat(["ChatbotGpt::OrganizationFeature"])
uc2 --> cost
uc3 -->|"upsert"| cost
uc4 -->|"COUNT FILTER over rooms"| rooms[("rooms")]
uc4 -->|"app-level join"| summ[("chatbot_gpt.omnichannel_room_summaries")]
uc4 -->|"upsert daily rows"| agg
uc1 -->|"track"| mp(["Mixpanel (owner: BOT)"])

Technical Decisions

Decision 1: Aggregate ownership & source (ai_activity_logs)

Context. The report must read a per-org daily aggregate (PRD: "sourced from the ai_activity_logs datamart, not a direct Postgres read"). That table does not exist (verified NOT FOUND). Two things are conflated in the PRD: where the report reads from at request time (must be pre-computed for ≤2s p95) and who owns populating it (PRD lists a BI/Data Hologres datamart as a BLOCKING dependency).

Options considered

  • Option A — Chatbot-owned nightly sidekiq-cron job → ai_activity_logs table in primary Postgres.
    • Pros: fully in-repo, unblocked, agent-executable in one repo; reuses the proven COUNT FILTER idiom + sidekiq-cron; satisfies "pre-computed, not request-time read".
    • Cons: duplicates data BI may later own; chatbot owns the aggregation logic.
  • Option B — BI/Data-owned Hologres datamart now (chatbot_ai.ai_activity_logs).
    • Pros: matches the "flip the dependency" narrative; offloads storage/compute.
    • Cons: hard cross-squad blocking dependency → MVP unshippable in the quarter; not executable by a single agent against chatbot.
  • Option C — Live rooms aggregation at request time.
    • Pros: no new table.
    • Cons: cross-DB + reopen self-join per request blows the ≤2s budget; contradicts the PRD.

Decision: Option A.

Rationale. It ships the MVP without a cross-squad block, honors the pre-computed-read requirement, and reuses two established patterns (custom_report/generate.rb aggregation, config/schedule.yml cron). The Hologres datamart (Option B) is the documented Phase-2 migration target once telemetry enrichment lands — the read model (AiActivityLog) is written behind a repository so swapping the physical source is a one-file change.

Consequences. Chatbot owns aggregation correctness and a nightly job's operational surface (retry, alerting, backfill). The aggregate is a materialized copy with ≤24h staleness by design.

Reversibility. Point the repository at a HologresRecord-based AiActivityLog model (the Hologres::UnansweredQuestion precedent); drop the local table + job. Low cost — read contract is stable; migration is source-swap only.

Decision 2: Sync vs async

Context. Metrics need a cross-DB join (rooms + omnichannel_room_summaries) and a 48h reopen self-join — too expensive at request time.

Options considered

  • Option A — Async nightly aggregation; sync thin read. Pros: fast reads, freshness ≤24h (acceptable per PRD). Cons: not real-time.
  • Option B — Sync compute per request. Pros: real-time. Cons: fails p95; heavy DB load.

Decision: Option A. Rationale: PRD explicitly allows ≤24h freshness and forbids real-time; async is the only option meeting the latency budget. Consequences: a daily job to operate. Reversibility: add on-demand recompute later if freshness needs tighten.

Decision 3: Caching

Context. Same org/range is re-fetched on re-open and date-range toggles.

Options considered

  • Option A — Redis cache of the assembled report response, per (org, range, flags) key, TTL 1h. Pros: cuts repeat read cost; TTL << 24h freshness so never staler than the aggregate. Cons: cache invalidation on cost-assumption edit (bust the org's keys).
  • Option B — No cache (rely on indexed aggregate read). Pros: simplest, no invalidation. Cons: repeated identical reads.

Decision: Option B for MVP, with Option A as a fast-follow if p95 misses budget under load. Rationale: the aggregate read over an indexed (organization_id, activity_date) daily table is small (≤ ~400 rows/org for 13 months) and should meet ≤2s comfortably; caching adds invalidation complexity (cost edit must recompute money figure immediately per IMPACT-S04/AC-1). Consequences: if load tests fail, add the cache + a bust on PUT cost_assumption. Reversibility: additive; a repository-level wrapper.

Decision 4: Third-party integration

Context. Does the report call any external API? Decision: No new third-party integration — Mixpanel/Datadog/Rollbar are existing telemetry sinks used exactly as the repo already uses them (config/initializers/mixpanel.rb). n/a — no new external dependency.

Decision 5: Consistency model

Context. The aggregate is a materialized copy of rooms/summaries.

Decision: Eventual consistency (≤24h). Rationale: PRD allows ≤24h freshness; the report is analytical, not transactional. Cost-assumption writes are strong (read-after-write within the same primary DB) so the money figure updates immediately on the next read (IMPACT-S04/AC-1). Consequences: the report never reflects "today so far". Reversibility: tighten with an intra-day job if needed.

Decision 6: Multi-tenancy isolation

Context. Every metric is per-org; leakage would be severe.

Decision: Enforce at two layers: (1) Middlewares::Ownership derives organization_id from the authenticated session (never from a client param) — the same gate the existing report uses; (2) every repository query is where(organization_id:)-scoped, and ai_activity_logs/ai_cost_assumptions carry organization_id with a covering index. Rationale: reuses the platform's proven isolation point; defence in depth. Consequences: no cross-org report is possible even internally without a separate admin path (out of scope). Reversibility: n/a — this is a security invariant.

Decision 7: Trend/journey visualization (FE)

Context. chatbot-fe has no charting library (verified). The report needs a trend line + a 3-segment split bar.

Options considered

  • Option A — Hand-built SVG/CSS (pixel3 MpBox/MpFlex + Tailwind). Pros: zero new dep, no bundle cost, full control; precedent exists (qontak-designer hand-SVG charts). Cons: more FE code.
  • Option B — Add a chart lib (chart.js/echarts/apexcharts). Pros: less custom code. Cons: new dependency + bundle weight + DS-consistency risk for two simple visuals.

Decision: Option A. Rationale: two simple visuals don't justify a charting dependency and its bundle/CWV cost. Consequences: custom SVG components to test. Reversibility: swap to a lib later behind the same component API.

Decision 8: Copy / localization (FE)

Context. The app has no i18n framework (verified — strings hardcoded). The persona is Indonesian SMEs; copy must be plain-language.

Decision: Hardcode Indonesian-first plain-language copy inline (matching the rest of the app). Rationale: introducing i18n is net-new infrastructure out of Phase-1 scope. Consequences: a later i18n migration would touch these strings. Reversibility: extract to i18n keys when the app adopts a framework. Logged as OQ-7 (confirm language with PMM).

Minimum coverage: storage (D1), sync/async (D2), caching (D3), third-party (D4), consistency (D5), multi-tenancy (D6), reuse-vs-new (Detail 1.B D10 + §2.0 contracts table), per-status lifecycle (D11) — all addressed.

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
user(["Org admin"]) --> page["AiAgentImpactPage (Nuxt)"]
page --> mw["ai-agent-impact-feature middleware"]
page --> store["ai-agent-impact Pinia store"]
store --> svc["ai-agent-impact service"]
svc --> apimain["$apiMain (ofetch + Bearer)"]
apimain --> ep["Grape: /v1/reports/ai_agent_impact"]
ep --> uc["AiAgentImpact report use-case"]
uc --> repo["AiAgentImpact repository"]
repo --> agg[("ai_activity_logs")]
repo --> cost[("ai_cost_assumptions")]
ep --> feat(["OrganizationFeature entitlement"])
worker["AiActivityLogAggregatorWorker"] --> rooms[("rooms")]
worker --> summ[("omnichannel_room_summaries")]
worker --> agg

Data model (erDiagram)

erDiagram
ROOMS ||--o{ AI_ACTIVITY_SOURCE : "aggregated from"
OMNICHANNEL_ROOM_SUMMARIES ||--o{ AI_ACTIVITY_SOURCE : "sentiment join"
ORGANIZATION ||--o{ AI_ACTIVITY_LOGS : "has daily rows"
ORGANIZATION ||--|| AI_COST_ASSUMPTIONS : "has one"
ROOMS {
bigint id PK
bigint organization_id
string channel
string channel_room_id
bigint contact_id
string closed_reason
string assign_channel_agent_id
datetime closed_at
datetime assigned_at
datetime created_at
}
OMNICHANNEL_ROOM_SUMMARIES {
uuid id PK
string room_id
string sentiment
string organization_id
}
AI_ACTIVITY_LOGS {
bigint id PK
bigint organization_id
date activity_date
int volume
int contained_ai_gross
int contained_ai_net
int reopened_48h
int contained_after_hours
int journey_ai_only
int journey_ai_assisted
int journey_escalated
int sentiment_contained_pos
int sentiment_escalated_pos
int sentiment_coverage
int turns_sum
int turns_count
datetime computed_at
}
AI_COST_ASSUMPTIONS {
bigint id PK
bigint organization_id
bigint agent_hour_rate
int minutes_per_conversation
string updated_by
datetime created_at
datetime updated_at
}

UI report state machine

stateDiagram-v2
[*] --> Loading: open /reports/ai-agent-impact
Loading --> BaselineForming: aggregate under 30 days or under min conversations
Loading --> Success: sufficient data
Loading --> Error: query fails or timeout over 3s
Error --> Loading: click Retry
BaselineForming --> [*]: leave (volume shown, no verdicts)
Success --> Success: change date range or edit cost assumption (recompute)
Success --> [*]: leave

rooms.closed_reason is an external, read-only enum this RFC does not own or transition — it is classified (not state-machined) into journey segments by the aggregator; see the branch/skip flow below.

Branch & skip flow (non-error policy branches)

flowchart TD
req(["report requested"]) --> ent{"AI add-on enabled?"}
ent -- no --> deny["403 / entry hidden (NEG-1)"]
ent -- yes --> role{"role in owner/admin/supervisor?"}
role -- no --> deny2["403 / entry hidden (NEG-2)"]
role -- yes --> data{"data over min threshold?"}
data -- no --> baseline["baseline_forming: volume only, no percentages"]
data -- yes --> assemble["assemble metrics"]
assemble --> cost{"cost assumption set?"}
cost -- no --> promptcost["work_absorbed = null, show set-assumption prompt"]
cost -- yes --> money["compute work_absorbed rupiah"]
assemble --> sent{"sentiment coverage adequate?"}
sent -- no --> degradeSent["sentiment tile: reduced-coverage note"]
assemble --> bh{"business-hours config available?"}
bh -- no --> skipBH["omit after-hours tile (OQ-4 mitigation)"]
assemble --> fc{"forecast flag on and enough trend?"}
fc -- no --> noForecast["render historical trend only"]
fc -- yes --> forecast["render dashed projection"]

Detail 2.2 — Sequence (end-to-end, incl. failure paths)

Happy path — fetch report

sequenceDiagram
actor U as Org admin
participant FE as chatbot-fe (SPA)
participant LB as Load Balancer
participant API as frontend_service API pod
participant OWN as Ownership + set_role
participant FEAT as OrganizationFeature
participant Cache as Redis (optional)
participant DBR as Postgres replica
U->>FE: open /reports/ai-agent-impact
FE->>LB: GET /v1/reports/ai_agent_impact (Bearer JWT, range)
LB->>API: HTTP
API->>OWN: resolve org_id + check role
OWN-->>API: ok (owner/admin/supervisor, org scoped)
API->>FEAT: AI add-on enabled for org?
FEAT-->>API: enabled
opt cache configured
API->>Cache: GET report:org:range
Cache-->>API: miss
end
API->>DBR: SELECT ai_activity_logs WHERE org and date range
Note right of DBR: indexed (organization_id, activity_date), p95 under 2s
DBR-->>API: daily rows
API->>DBR: SELECT ai_cost_assumptions WHERE org
DBR-->>API: assumption or none
API-->>FE: 200 report (net containment, journey, quality, trend, work_absorbed or null)
FE-->>U: render tiles with verdicts + plain headline

Failure path — aggregate read fails or times out

sequenceDiagram
participant FE as chatbot-fe
participant API as frontend_service API pod
participant DBR as Postgres replica
FE->>API: GET /v1/reports/ai_agent_impact
API->>DBR: SELECT ai_activity_logs ...
Note right of DBR: no response within 3s budget
DBR--xAPI: timeout / error
API->>API: log impact_report_load_failed (reason, latency_ms)
API-->>FE: 5xx error envelope
FE-->>FE: set fetchStatus rejected
FE-->>FE: render Error state + Retry (no partial fabricated data)

Nightly aggregation job

sequenceDiagram
participant Cron as sidekiq-cron
participant Q as Sidekiq queue
participant W as AiActivityLogAggregatorWorker
participant DBW as Postgres primary (rooms)
participant GPT as Postgres chatbot_gpt (summaries)
participant AGG as ai_activity_logs
Cron->>Q: enqueue nightly (01:00 Asia/Jakarta)
Q->>W: perform(date)
W->>DBW: COUNT FILTER over rooms by org+day (exclude bot_preview, SPAM)
DBW-->>W: per-org-per-day room outcome counts
W->>DBW: reopen self-join same contact within 48h
DBW-->>W: reopened_48h counts
W->>GPT: read sentiment for the day's rooms (app-level join)
GPT-->>W: sentiment values + coverage
W->>AGG: upsert daily rows (computed_at set)
Note over W,AGG: idempotent upsert on (organization_id, activity_date)

Cost assumption save (happy + validation-fail)

sequenceDiagram
actor U as Owner/Admin
participant FE as CostAssumptionModal
participant API as frontend_service API pod
participant OWN as set_role owner/admin
participant DBW as Postgres primary
U->>FE: save (agent_hour_rate, minutes_per_conversation)
FE->>API: PUT /v1/reports/ai_agent_impact/cost_assumption
API->>OWN: check role owner/admin
alt not owner/admin
OWN-->>API: reject
API-->>FE: 403
FE-->>U: editor not rendered / write rejected
else authorized
OWN-->>API: ok
API->>API: validate non-negative + bounds
alt invalid input
API-->>FE: 422 validation error
FE-->>U: inline error, nothing saved
else valid
API->>DBW: upsert ai_cost_assumptions (org)
DBW-->>API: ok
API-->>FE: 200 saved
FE-->>U: modal closes, work_absorbed recomputes on next read
end
end

Detail 2.3 — Database Model (DDL)

Rails migrations, primary DB (db/migrate/), house style disable_ddl_transaction! + add_index algorithm: :concurrently (per 20260624000001_*.rb). Postgres.

-- ai_activity_logs: per-org, per-day aggregate (report read spine)
CREATE TABLE ai_activity_logs (
id bigserial PRIMARY KEY,
organization_id bigint NOT NULL,
activity_date date NOT NULL,
volume integer NOT NULL DEFAULT 0,
contained_ai_gross integer NOT NULL DEFAULT 0,
contained_ai_net integer NOT NULL DEFAULT 0,
reopened_48h integer NOT NULL DEFAULT 0,
contained_after_hours integer NOT NULL DEFAULT 0,
journey_ai_only integer NOT NULL DEFAULT 0,
journey_ai_assisted integer NOT NULL DEFAULT 0,
journey_escalated integer NOT NULL DEFAULT 0,
sentiment_contained_pos integer NOT NULL DEFAULT 0,
sentiment_escalated_pos integer NOT NULL DEFAULT 0,
sentiment_coverage integer NOT NULL DEFAULT 0,
turns_sum integer NOT NULL DEFAULT 0,
turns_count integer NOT NULL DEFAULT 0,
computed_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX idx_ai_activity_logs_org_date
ON ai_activity_logs (organization_id, activity_date); -- supports: per-org range read + idempotent upsert

-- ai_cost_assumptions: one per org (upsert)
CREATE TABLE ai_cost_assumptions (
id bigserial PRIMARY KEY,
organization_id bigint NOT NULL,
agent_hour_rate bigint NOT NULL CHECK (agent_hour_rate >= 0),
minutes_per_conversation integer NOT NULL CHECK (minutes_per_conversation >= 0),
updated_by varchar,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX idx_ai_cost_assumptions_org
ON ai_cost_assumptions (organization_id); -- supports: per-org read + upsert
  • Cardinality / growth: ai_activity_logs ≈ (orgs × 396 days for 13-month TTL) — e.g. 5,000 AI orgs × 396 ≈ ~2M rows steady-state; tiny. ai_cost_assumptions = 1 row/org.
  • Example rows: ai_activity_logs: (org=42, 2026-07-01, volume=120, contained_ai_gross=78, contained_ai_net=71, reopened_48h=7, journey_ai_only=71, journey_ai_assisted=19, journey_escalated=30, sentiment_coverage=95, turns_sum=430, turns_count=120, computed_at=...). ai_cost_assumptions: (org=42, agent_hour_rate=50000, minutes_per_conversation=8, updated_by='owner@acme.co').
  • PII classification: ai_activity_logs — none (aggregate counts only, no message content). ai_cost_assumptions — none sensitive; updated_by is an internal user identifier (treat as low-sensitivity, scrub from external logs).
  • Retention: ai_activity_logs rolling 13 months (TTL from computed_at; nightly cleanup in the same job) — matches PRD §6.7. ai_cost_assumptions — lifetime of account, overwritten on edit.
  • Per-status lifecycle: neither table exposes a user-facing status enum (Decision 11). The only lifecycle is data retention:
TableRetentionCleanup triggerUser-visible effectRestore
ai_activity_logs13 months rollingnightly TTL on computed_atolder ranges simply unavailable beyond the 12-month selectornot applicable (recomputable from rooms while rooms retained)
ai_cost_assumptionsaccount lifetime; overwritten on editmanual edit (upsert)new rupiah figures on next viewlast write wins (no history in P1)
  • Partition / sharding: none (dataset small). NoSQL alternative: rejected — Postgres aggregate fits the relational read; the existing datamart precedent is Postgres-protocol (Hologres).

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequestResponseStatusIdempotencyVersioningReuse?
/v1/reports/ai_agent_impactGET:api_auth Bearer + Middlewares::Ownership (org from session) + set_role(%w[owner supervisor admin]) + AI-add-on entitlementquery: start_date, end_date (≤12mo span)success envelope data: `{ baseline_forming, volume, containment:{net,gross,reopen_gap,verdict}, after_hours:{count,pct}null, journey:{ai_only,ai_assisted,escalated}, quality:{reopen_rate, sentiment:{delta,coverage}null, turns_avg}, work_absorbed:{hours,rupiah}null, trend:[{date,net}], forecast:{projected_net}null }`
/v1/reports/ai_agent_impact/cost_assumptionGETBearer + Ownership + set_role(%w[owner supervisor admin])data: `{ agent_hour_rate, minutes_per_conversation, updated_by }null`200, 403safe GET/v1/
/v1/reports/ai_agent_impact/cost_assumptionPUTBearer + Ownership + set_role(%w[owner admin]){ agent_hour_rate:int≥0, minutes_per_conversation:int≥0 }data: saved record200, 403 (supervisor/agent), 422 (validation)upsert on organization_id (idempotent by nature)/v1/new-with-justification

Per endpoint:

  • Example success (report): { "status":"success","code":200,"message":"OK","data":{ "baseline_forming":false, "containment":{"net":0.71,"gross":0.78,"reopen_gap":0.07,"verdict":"Healthy"}, "work_absorbed":null, ... } }.
  • Example 403 (no add-on): { "status":"error","error":{"code":403,"messages":["Permission denied"]} }.
  • Example 422 (cost): { "status":"error","error":{"code":422,"messages":["agent_hour_rate must be a non-negative number"]} }.
  • Rate limits / payload size: inherit platform defaults (no new limit); report GET is read-only and cheap.
  • Pagination: none (bounded per-org daily series ≤ 396 rows returned as trend[]; not paginated).
  • Backward compatibility: all endpoints new — no consumer to break.

Inbound webhooks (other services call us)

n/a — reason: this feature introduces no inbound webhook. The nightly aggregation is an internal sidekiq-cron job, not an externally-triggered callback.

Detail 2.A — UI Contract

For each new component (Figma pending — OQ-6; frame URLs n/a — design pending):

  • AiAgentImpactPagemodules/report/views/ai-agent-impact.vue. State: ai-agent-impact Pinia store (report, fetchStatus, costAssumption). Fetches on mount via store action; stashes AbortController (abort on unmount, per plugins/api/request.ts). Fires trackEvent(MIXPANEL_EVENTS.AI_AGENT_IMPACT_VIEWED, {org_id, role, date_range, has_forecast}).
  • HonestContainmentTile — props { net:number; gross:number; reopenGap:number; verdict:string; baselineForming:boolean }; renders the net % hero + verdict pill + "leaves out repeat-askers" note; never a bare number.
  • BlendedJourneyBar — props { aiOnly:number; aiAssisted:number; escalated:number }; custom SVG 3-segment bar summing to 100%; plain labels ("AI resolved alone" / "AI assisted, human closed" / "Escalated to human").
  • CostAssumptionModal — pixel3 MpModal* compound (per ai-assist.vue); props { isOpen:boolean; value:{agentHourRate?:number; minutesPerConversation?:number}; canEdit:boolean }; emits save, close; inline validation; hidden editor for non-owner/admin. On save → PUT cost_assumptiontrackEvent(cost_assumption_updated).
  • ForecastPanel — props { trend:{date:string;net:number}[]; forecast?:{projectedNet:number}; flagOn:boolean }; custom SVG line (solid past + dashed projection); projection only when flagOn && forecast.
  • Analytics payloads: AI_AGENT_IMPACT_VIEWED{org_id, role, date_range, has_forecast}; IMPACT_LOAD_FAILED{org_id, reason, latency_ms}; COST_ASSUMPTION_UPDATED{org_id, agent_hour_rate, minutes_per_conversation}; IMPACT_BASELINE_FORMING{org_id, days_of_data, conversation_count}; IMPACT_FORECAST_RENDERED{org_id, projected_containment, baseline_containment} (names are [CHATBOT] ... strings, per repo convention).
  • A11y: every tile has an accessible label; the SVG bar/line has role="img" + aria-label summarizing the values; verdict is text, not color-only; keyboard-focusable Retry + modal focus trap (pixel3 MpModal).

Detail 2.B — Data-Fetching Strategy

  • Library: $apiMain (ofetch) via the service + Pinia pattern (no useFetch/GraphQL).
  • Cache key structure: Pinia store state keyed by {range, flags}; no HTTP cache in MVP (Decision 3).
  • TTL & refetch triggers: refetch on mount, on date-range change, and after a cost-assumption save.
  • Stale-while-revalidate: no — explicit fetchStatus machine (pendingresolved/rejected).
  • Optimistic updates: none for the report (read-only). Cost save is pessimistic (await 200, then refetch report).

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Report pageMpSkeleton tiles + skeleton chartBaseline-forming (no-data.vue, volume only, no %)error+retry (MpBanner+MpButton); impact_report_load_failed loggedper-tile degrade (sentiment/journey "not available") without blocking the restfull grid + verdicts + plain headline (+ forecast if flag on)
CostAssumptionModalsave spinner (MpSpinner), fields disabledfirst-time: blank fields + "why we ask" helperinline validation under field (422)n/amodal closes; report money figure recomputes on next read
ForecastPanelskeleton chartbaseline-forming notechart hidden, rest rendershistorical-only when flag off / insufficienttrend (+ dashed projection when flag on)

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotency key + TTLConsistencyDuplicate handlingStale-read handling
Nightly aggregation upsertone transaction per org-day batchfailed org-day retried next run; per-day upsert is idempotent(organization_id, activity_date) unique index (upsert)eventual (≤24h)upsert overwrites the day's row (no dup)consumers accept ≤24h staleness by design
PUT cost_assumptionsingle-row upsert (primary DB)422 before write on invalid input → nothing persisted(organization_id) unique (upsert)strong (read-after-write)last write winsnone (single row)

Detail 2.E — Concurrency Collision Map

ResourceWritersCollision scenarioResolutionBehavior on conflict
ai_cost_assumptions (org row)Owner/Admin (rare, human)two admins save near-simultaneouslyDB upsert on unique (organization_id)last write wins; both 200; next read reflects the latest
ai_activity_logs (org-day row)nightly job only (+ optional manual backfill)job overlap / re-run for same dayidempotent upsert on unique (organization_id, activity_date) + Sidekiq single-run per cron tickre-run recomputes identical values; no duplication

Detail 2.F — Async Job / Event Consumer Spec

JobTriggerInputRetryDLQ / retentionConcurrencyIdempotency keyPer-msg timeoutPoison handling
AiActivityLogAggregatorWorkersidekiq-cron 0 1 * * * Asia/Jakarta (config/schedule.yml)perform(activity_date = yesterday)sidekiq_options retry: 3 (backoff default)Sidekiq dead set (default retention)queue application_maintenance; 1 logical run/day(organization_id, activity_date) upsertjob-level (bounded by day batch)after retries exhausted → dead set + Rollbar alert; next night re-computes the missed day (idempotent)

Detail 2.F.1 — Responsibility Boundary Matrix

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Write room outcomes (closed_reason, assignment, timestamps)chatbot BE (existing hub flows)message/agent eventsrooms rowsexistingPRD §8 (data source)
2. Write room sentimentchatbot BE / AI-service (existing)summarizationomnichannel_room_summaries.sentimentexisting; missing → coverage notePRD §14 (sentiment dep)
3. Nightly aggregatechatbot BE (this RFC)sidekiq-cronai_activity_logs rowsretry 3 → dead set + RollbarPRD §14 D-datamart
4. Serve reportchatbot BE (this RFC)FE GETJSON report5xx + impact_report_load_failedPRD §8 Behavior 1
5. Render reportchatbot-fe (this RFC)admin opens pageUIerror+retry statePRD §7, §9
6. (Phase 2) migrate aggregate to HologresBI/DataPhase 2 kickoffchatbot_ai.ai_activity_logsout of P1 scopeAnchor Phase Index

No disagreement with the PRD allocation. The PRD lists the datamart as a BI/Data dep; Decision 1 deliberately keeps P1's aggregation in chatbot BE to avoid a blocking cross-squad dep, with the BI/Data Hologres handoff explicitly deferred to Phase 2 (row 6). Surfaced, not silently reallocated.

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
Impact aggregateai_activity_logs.* daily counts0nightly jobGET /v1/reports/ai_agent_impact≤24h
Cost assumptionai_cost_assumptions (present/absent)absent (→ work_absorbed null)PUT cost_assumptionGET .../cost_assumption + report work_absorbednone (strong)
Baseline-formingbaseline_forming boolean (derived)falsereport use-casereport response≤24h
Forecast availabilityforecast present/absent (derived)absentreport use-case (flag)report response≤24h

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
GET /v1/reports/ai_agent_impactsuccess envelope {status,code,message,data:{...}}, snake_case keys, work_absorbed/after_hours/sentiment/forecast nullableFE reads res.data from $apiMain (snake_case as-is), tolerates nulls with per-tile degradeyesnone — confirm no camelCase mapper is imposed (OQ-5); nullability handled by UI State Matrix
GET .../cost_assumption`data: {...}null`FE branches on null → "set assumption" promptyes
PUT .../cost_assumption200 saved / 403 / 422FE handles 403 (hide editor) + 422 (inline error)yesFE must map the exact 422 error.messages to field-level inline text

Detail 2.H — End-to-End Data Flow

  • View report: admin opens page → ai-agent-impact-feature middleware (flag + role) → store action → service → $apiMain GET /v1/reports/ai_agent_impact → Grape endpoint (Ownership + set_role + entitlement) → use-case → repository reads ai_activity_logs + ai_cost_assumptions → success envelope → store sets fetchStatus=resolved → tiles render. Side effects: impact_report_viewed Mixpanel event; on failure impact_report_load_failed + Rollbar. Ownership: FE owns render + gating UX; BE owns metric assembly + auth; Data (Phase 2) owns the datamart.
  • Set cost assumption: modal save → PUT .../cost_assumption → set_role(owner/admin) → validate → upsert ai_cost_assumptions → 200 → FE refetches report → work_absorbed recomputes. Side effect: cost_assumption_updated event.

Detail 2.I — Scope Boundaries

  • FE create: pages/reports/ai-agent-impact/index.vue; modules/report/views/ai-agent-impact.vue
    • modules/report/components/ai-agent-impact/* (tiles, bar, panel, modal); store/ai-agent-impact/* (6 files); common/services/main/v1/ai-agent-impact.ts; middleware/ai-agent-impact-feature.ts; a reusable error+retry component; new keys in common/contants/mixpanel-events.ts.
  • FE modify: common/services/main/endpoint.ts (add v1.ai_agent_impact.*); common/services/main/v1/index.ts (register service); the Reports nav entry (link the page).
  • FE NOT touched: existing /report (Metabase) view; other report modules; auth store internals.
  • BE create: migrations for ai_activity_logs + ai_cost_assumptions; models AiActivityLog, AiCostAssumption; repository Repositories::AiAgentImpact::*; use-cases under app/core/use_cases/api/frontend_service/v1/ai_agent_impact/; endpoints in app/api/frontend_service/v1/report.rb (or a new ai_agent_impact.rb mounted alongside) + Grape entities; app/workers/ai_activity_log_aggregator_worker.rb; config/schedule.yml cron entry.
  • BE modify: config/schedule.yml; API mount (app/api/frontend_service/api.rb) if a new endpoint file.
  • BE NOT touched: rooms write paths; omnichannel_room_summaries writes; existing report endpoints; hub/assignment flows.
  • Shared modules: set_role/Ownership (reused read-only); pixel3 components (consumed, not modified).

Detail 2.J — Asset Inventory

AssetTypeSourceFormat & sizesPath in repo
Baseline-forming illustrationillustrationreuse no-data.vue default (global-no-data)existing SVGcommon/components/error/ (existing)
Verdict pill / tile iconsiconpixel3 MpIcon seticon font/SVG (DS)@mekari/pixel3 (no new asset)
Journey bar / trend line(rendered)custom SVG in-component (no imported asset)inline SVGcomponent files (§2.I)

No new imported design asset is required for MVP; any new illustration from Figma (OQ-6) is flagged for design review.


3. High-Availability & Security

The report is read-only and additive — worst case it fails to load (Error + Retry) and no existing surface is affected. It degrades gracefully: sentiment tile → "not available" if the cross-DB read fails; after-hours tile omitted if no business-hours config; journey → placeholder if unavailable — each without blocking the rest of the report. The nightly job is idempotent and self-heals (a missed day recomputes the next night). Backend pods are stateless (existing Grape API), horizontally scaled behind the LB; a full-restart recovers from Postgres + Sidekiq as today.

Performance Requirement

  • Frontend: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1; bundle delta small (no chart lib — Decision 7); route-level code-split (the report page is its own chunk); no large images; a11y WCAG AA; browser support = existing Qontak web admin matrix (evergreen Chrome/Safari/Firefox/Edge).
  • Backend: report GET p95 ≤ 3s (aggregate read ≤ 2s p95) over a 90-day window; low RPS (admin-initiated, not customer-facing traffic); error rate < 1%. Scalability: indexed daily aggregate keeps reads O(days) per org; read replica for the GET; nightly job batched per org. Load test: 50 concurrent admins × 90-day reads sustained 5 min; verify p95 ≤ 3s and no replica saturation.

Monitoring & Alerting

  • FE: Mixpanel events (impact_report_viewed, impact_report_baseline_forming, cost_assumption_updated, impact_forecast_rendered) via trackEvent; Datadog RUM + Rollbar for JS errors; Core Web Vitals on the existing FE dashboard.
  • BE: Datadog RED metrics on the endpoint (rate/errors/duration, tagged endpoint:ai_agent_impact); AppSignal APM span; Sidekiq job success/failure + duration; Lograge structured logs. Alerts (PRD §11): impact_report_load_failed > 5% of views in 1h → Slack #bot-ai-alerts; p95 render > 3s over 30 min → #bot-ai-alerts; nightly job failure → Rollbar + Slack.
  • Cross-layer: propagate the platform request id FE→API for trace correlation (existing header).
  • SLO: ≥ 99% successful loads / 30d (matches PRD Success Metric).

Logging

  • FE: log report_load_failed with {reason} (no PII).
  • BE: Lograge fields {org_id, endpoint, status, latency_ms, role}; job logs {date, orgs_processed, duration_ms}.
  • PII: none in either — the report is aggregate counts; scrub updated_by (user id) from external logs.

Security Implications

  • Threat model: cross-tenant data leakage (primary risk) and unauthorized viewing (role/entitlement bypass).
  • AuthN/AuthZ: Bearer :api_auth at the API boundary; org derived from session (never client param); role via set_role; entitlement via OrganizationFeature.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityAdditional constraintAudit
Ownerreport GET; cost_assumption GET/PUTGET, PUTown org onlyfull report + cost editorcost_assumption_updated + Lograge
Adminreport GET; cost_assumption GET/PUTGET, PUTown org onlyfull report + cost editorsame
Supervisorreport GET; cost_assumption GETGETown org onlyfull report; cost read-only (no editor)write rejected server-sideimpact_report_viewed
Agentnoneentry not rendered403 on direct call
Any role, non-AI orgnoneentry not rendered403 (entitlement)
  • Ownership validation: organization_id from env['user']['chatbot_organization_id'] (Ownership middleware); every query where(organization_id:).
  • Input validation: agent_hour_rate int ≥ 0 (+ upper bound, OQ-3 for sane cap); minutes_per_conversation int ≥ 0 (+ cap); start_date/end_date valid dates, span ≤ 12 months, start ≤ end.
  • Injection: ActiveRecord parameterized queries only (no string SQL interpolation of user input); no outbound URL from user input (no SSRF surface).
  • Secrets: none new (no new external credential); existing env config.
  • Audit logging: cost-assumption writes logged (actor, org, values) via Lograge; Mixpanel cost_assumption_updated.
  • Rate limiting: inherit platform default; report GET is cheap + read-only.
  • Tenancy isolation: enforced at Ownership middleware + query scoping (Decision 6).
  • Static analysis: Brakeman (repo has config/brakeman.ignore) + RuboCop in CI.
  • Compliance: see Detail 3.D.

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE response on failureCodes aligned?
Report loadError state + Retry; log report_load_failed5xx + impact_report_load_failed; error envelopeyes
No AI add-onentry not rendered (middleware 404 abort)403 entitlementyes (FE hides; never surfaces 403)
Ineligible roleentry not rendered403 set_roleyes
Sentiment source downsentiment tile → "not available", rest rendersfield null + coverage noteyes
Journey data missingplaceholder, rest renderssegment fields nullyes
Cost save invalidinline field error422 validationyes
Cost save unauthorizededitor not shown403yes

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
No AI add-onBE entitlement + FE middlewareaccess denied, entry hiddenno (silent hide)
Ineligible role (Agent)BE set_role + FE middlewareaccess denied, entry hiddenno
Data < minimumBE report use-casebaseline-forming (volume only, no %)impact_report_baseline_formingyes
No cost assumptionBE report use-casework_absorbed=null; FE shows "set assumption" promptyes
Forecast flag OFF / insufficient trendBE (flag) + FEhistorical trend only, no projectionyes
Sentiment coverage low / source downBE aggregator + readreduced-coverage note / "not available"yes
Business-hours config absentBE aggregatorafter-hours tile omitted (OQ-4 mitigation)yes (tile absent)

Detail 3.B — Error Response Catalog (BE)

EndpointError codeHTTPMessageWhenUser-facing?
report GETPERMISSION_DENIED403"Permission denied"ineligible role/entitlementno (FE hides entry)
report GETBAD_REQUEST400"Invalid date range"span > 12mo / start > endyes (guarded in UI)
report GETREPORT_UNAVAILABLE500"We couldn't load your report"aggregate read failure/timeoutyes (Error + Retry)
cost PUTPERMISSION_DENIED403"Permission denied"non-owner/adminno (editor hidden)
cost PUTVALIDATION_ERROR422"agent_hour_rate must be a non-negative number" (etc.)invalid/out-of-boundsyes (inline)

Detail 3.C — Error Message Catalog (FE)

Error codeUser-facing message (hardcoded ID copy — Decision 8)SurfaceUser-facing?
REPORT_UNAVAILABLE"Laporan gagal dimuat. Coba lagi." + Retryinline/banneryes
VALIDATION (cost)"Masukkan angka yang valid." (field-specific)inline under fieldyes
PERMISSION_DENIED— (entry hidden; no message surfaced)noneno

Detail 3.D — Compliance & Data Governance

N/A — no compliance trigger. The report stores only per-org aggregate counts (no message content, no customer PII) and an org-set cost assumption (no sensitive/financial-account data). No payment, health, cross-border, or audit-log data is introduced. Verified against the DDL in §2.3. (updated_by is an internal user identifier; scrubbed from external logs.)

Detail 3.E — Accessibility

  • WCAG AA. Keyboard: Retry, date-range, cost modal all focusable; MpModal traps focus and restores on close. SVG journey bar/trend have role="img" + aria-label value summaries (never color-only meaning — the verdict is text). Contrast verified against pixel3 tokens at design QA (OQ-6). prefers-reduced-motion respected on the skeleton shimmer.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • BE: all endpoints + tables new; no existing contract changes; no API version bump needed (new /v1/ paths).
  • FE: new route + store + service; no saved-state/cache migration; existing /report untouched.
  • Cross-layer: FE reads snake_case JSON directly (no transformation change); nullable fields handled by the UI State Matrix.

Rollout Strategy

  • Deploy order: BE first, then FE. Reasoning: the FE entry is flag-gated and the report is unreachable until the BE endpoints + a first nightly aggregate exist; deploying BE first (migrations → aggregator → endpoints, flag OFF) means FE has a live contract on release.
  • Feature flags (two, independent):
    • ai_agent_impact_reportSystemPreference(group_code:'rollout', code:'ai_agent_impact_report'), default OFF, enabled per account. Gates BE report access + FE entry.
    • ai_agent_impact_report_forecast — same mechanism, default OFF. Gates only the forecast panel (its own §10.5 rollback). Coupling: forecast flag is a no-op unless the report flag is on.
  • Stages (audience per PRD §13; go/no-go evidence): Internal Alpha (5 QA accounts; containment validated vs hand-checked sample, load-fail ≤5%, p95 ≤3s) → Closed Beta (10 orgs; ≥60% view, reliability ≥99%, forecast MAE <10pts) → Open Beta (Pro+Ent on request; reliability ≥99% 1wk, 100% honest-containment shown) → GA (all AI-enabled; gates sustained 2wk + PMM approval).
  • Stop conditions: impact_report_load_failed > 5% of views in any week → investigate/disable; forecast MAE ≥ 10pts over 2 periods → toggle forecast flag OFF (no deploy).
  • Rollback: toggle ai_agent_impact_report OFF (report vanishes, no deploy); migrations are additive (drop tables only on full revert). PICs/timeline live in delivery/ (not this RFC).

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline (no report)
Backend firstOldNewyesFE has no entry yet; new BE endpoints simply unused (flag OFF)
Frontend firstNewOldnoFE would call non-existent endpoints → deploy order forbids this (BE first)
Both deployedNewNewyestarget state (flag-gated rollout)
Backend rollbackNewOld (rolled back)noroll FE flag OFF first, then BE (deploy-order-aware rollback)
Frontend rollbackOld (rolled back)NewyesBE endpoints unused; harmless

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BESystemPreference rollout/ai_agent_impact_reportflagOFFyesops/DB seedno
BESystemPreference rollout/ai_agent_impact_report_forecastflagOFFyesops/DB seedno
BEsidekiq-cron entry ai_activity_log_aggregatorcron0 1 * * * Asia/Jakartayesconfig/schedule.ymlno
FEsubscription feature code for the report / AI add-onentitlementyesBE subscription features[] (OQ-1)no

Detail 4.C — Test Plan (commands sourced from the repos)

LayerCommand (source)What it must prove
FE unit/integrationpnpm testvitest run (source: chatbot-fe/package.json scripts "test":"vitest run")tiles render verdicts (not bare numbers); baseline/error states; store fetch machine; modal validation
FE lintpnpm lint (lint:ts + lint:prettier; CI runs pnpm run lint:ts, bitbucket-pipelines.yml)ESLint clean
FE E2Epnpm test:e2eplaywright test (source: package.json "test:e2e", tests/e2e/)open report as owner → tiles; as agent → no entry; baseline-forming; cost save flow
BE unitRAILS_ENV=test bundle exec rspec spec/core/repositories spec/api/frontend_service/v1 (source: bitbucket-pipelines.yml:74,93)aggregator counts (net/gross, excludes bot_preview/SPAM/bare RESOLVE); journey mapping; reopen self-join; endpoint auth (403 matrix); cost validation (422)
BE lintbundle exec rubocop (source: bitbucket-pipelines.yml:349)RuboCop clean
BE migrationRAILS_ENV=test bundle exec rails db:migrate (source: bitbucket-pipelines.yml:56)tables + unique indexes created; reversible
Cross-layerPlaywright hitting a seeded BE (report GET contract)FE consumes snake_case payload; nullable tiles degrade; 403 hides entry

Note: chatbot-fe has no typecheck script (verified) — type safety rides on ESLint; do not invent a pnpm typecheck step.

Detail 4.D — Agent Execution Plan

OrderLayerChunkFiles to create/modifyCommandsAcceptance criteria
1BEMigrations for ai_activity_logs + ai_cost_assumptionsdb/migrate/<ts>_create_ai_activity_logs.rb, db/migrate/<ts>_create_ai_cost_assumptions.rb (style: disable_ddl_transaction! + concurrent unique index)RAILS_ENV=test bundle exec rails db:migrateboth tables + unique indexes exist; db:rollback reverts cleanly
2BEModels + aggregation repositoryapp/models/ai_activity_log.rb, app/models/ai_cost_assumption.rb, app/core/repositories/ai_agent_impact/aggregate.rbbundle exec rspec spec/core/repositories/ai_agent_impactrspec: on a fixture org, net/gross correct; bot_preview/SPAM/bare RESOLVE excluded; journey sums to 100%; reopen self-join within 48h
3BEAggregator worker + cronapp/workers/ai_activity_log_aggregator_worker.rb, config/schedule.yml (+ sentiment app-level join)bundle exec rspec spec/app/workers/ai_activity_log_aggregator_worker_spec.rbrspec: worker upserts one row per org-day; idempotent re-run; sentiment coverage recorded; missing sentiment excluded
4BECost-assumption endpointsapp/api/frontend_service/v1/report.rb (GET/PUT cost_assumption) + entities; use-case .../ai_agent_impact/cost_assumption/*bundle exec rspec spec/api/frontend_service/v1rspec: PUT owner/admin persists; supervisor/agent → 403; negative/non-numeric → 422 nothing saved
5BEReport endpoint + entitlement + role gateapp/api/frontend_service/v1/report.rb (GET ai_agent_impact) + entity; use-case .../ai_agent_impact/show.rbbundle exec rspec spec/api/frontend_service/v1rspec: owner/admin/supervisor 200; agent 403; non-AI org 403; baseline_forming when <min; work_absorbed null when no assumption
6BERuboCop + full suitebundle exec rubocop; RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1 spec/core/repositorieslint clean; all new specs green
7FEStore + service + endpointstore/ai-agent-impact/* (6 files), common/services/main/v1/ai-agent-impact.ts, common/services/main/endpoint.ts (add v1.ai_agent_impact.*), register in common/services/main/v1/index.tspnpm testvitest: store action sets fetchStatus pending→resolved/rejected; service returns {fetch,controller}
8FEMiddleware (flag + role)middleware/ai-agent-impact-feature.tspnpm testvitest: disabled flag/ineligible role → abortNavigation(404); eligible → passes
9FEPage + view + tiles + error/empty statespages/reports/ai-agent-impact/index.vue, modules/report/views/ai-agent-impact.vue, modules/report/components/ai-agent-impact/*Tile.vue, error+retry component, no-data.vue reuse, mixpanel-events.ts keyspnpm testvitest: tile shows verdict not bare number; baseline-forming volume-only; error+retry renders; AI_AGENT_IMPACT_VIEWED fires on mount
10FEJourney bar + forecast + cost modal.../BlendedJourneyBar.vue, .../ForecastPanel.vue, .../CostAssumptionModal.vuepnpm testvitest: journey segments sum 100% + plain labels; forecast dashed only when flag on; modal validation + owner/admin-only editor
11FELint + E2EReports nav link; tests/e2e/ai-agent-impact.spec.tspnpm lint; pnpm test:e2elint clean; E2E: owner sees tiles; agent no entry; cost save recomputes money

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge (per layer, in order):
    • BE: 1) bundle exec rubocop 2) RAILS_ENV=test bundle exec rails db:migrate 3) RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1 spec/core/repositories
    • FE: 1) pnpm lint 2) pnpm test 3) pnpm build 4) pnpm test:e2e
  • Post-deploy signals:
    • Datadog endpoint:ai_agent_impact error rate < 1% and p95 < 3s over 15 min.
    • Mixpanel impact_report_viewed count > 0 within 1h of enabling an account.
    • Sidekiq: AiActivityLogAggregatorWorker completes nightly with 0 dead-set entries.
    • Slack #bot-ai-alerts shows no impact_report_load_failed > 5% alert.
  • Rollback (deploy-order-aware):
    1. Toggle SystemPreference rollout/ai_agent_impact_report OFF (FE entry + BE access gone; no deploy).
    2. If forecast misbehaves only: toggle ai_agent_impact_report_forecast OFF (rest of report stays live).
    3. If a code revert is needed: revert FE PR first, then BE PR (FE depends on BE).
    4. Full teardown only: rails db:rollback the two additive migrations (no user data lost; recomputable from rooms).
    5. Confirm Datadog error rate returns to baseline and Sidekiq queue drains.

Detail 4.F — Resource & Cost Notes

  • Compute: negligible — one nightly batch job (application_maintenance queue) + low-RPS admin reads on existing pods.
  • DB load: report reads hit the replica (small indexed scans); the nightly job runs off-peak (01:00 WIB).
  • Storage: ai_activity_logs ~2M rows steady-state (13-mo TTL); ai_cost_assumptions 1 row/org — trivial.
  • Network: no cross-region; no new external API egress (Mixpanel/Datadog already in use).
  • New infra: none.

5. Concern, Questions, or Known Limitations

#TypeQuestion / limitationOwnerDeadline
OQ-1Open QuestionExact ChatbotGpt::Feature.code for the "Generative AI / AI Agent" add-on entitlement — mechanism verified (OrganizationFeature.enabled per active order) but the code is DB data, not in source. Both the BE entitlement gate and the FE features[].code middleware key off it.Dimas + BEbefore chunk 5/8
OQ-2Open QuestionCanonical FE role string values (owner/admin/supervisor/agent casing) on authenticationStore().profile.data.role — BE uses lowercase (verified) but FE role is used only for analytics today; role-gating is net-new.BE/auth teambefore chunk 8
OQ-3Open QuestionSource of the per-room message count for turns-to-resolve (histories table / summaries) — the PRD cites "histories"; the exact readable source/DB was not confirmed in grounding. Also confirm sane upper bounds for agent_hour_rate / minutes_per_conversation validation.Dimas + BEbefore chunk 2
OQ-4Open Question / RiskDoes a per-org business-hours config exist (or is it derivable) for the after-hours tile? Not found in grounding. Mitigation (PRD §16): ship P1 without the after-hours tile and add it when the config lands — non-blocking to the rest.Dimas + BE2026-07-18
OQ-5Open QuestionConfirm the FE consumes the BE snake_case JSON directly (no camelCase transformation layer imposed) — grounding found no global mapper; verify per the existing service pattern.baghiz.firdausabefore chunk 7
OQ-6Design gapFigma frames for the report page + CostAssumptionModal + custom viz are pending (PRD Appendix A Stitch prompts bootstrap them). FE layout may proceed against the wireframe; pixel-level visual QA + token/contrast sign-off gated on Figma.Dimas + Designbefore Open Beta
OQ-7AssumptionConfirm plain-language copy language (Indonesian-first per persona) with PMM (no i18n framework exists — copy is hardcoded, Decision 8).Dimas + PMMbefore GA
OQ-8ProcessAssign the infosec approver + BOT tech-lead approver (Metadata Approver row) before this RFC moves to AGREED.Dimasbefore AGREED
OQ-9Open Question / Correctness[rfc-reviewer R1 · REV-1] How is reopened_48h finalized across the nightly daily-batch boundary? The job runs perform(activity_date=yesterday) and stores the reopen count per activity_date, but a same-contact reopen can land up to 48h later (D+1/D+2). So a day's net-containment hero (Success Criteria #1/#5) is over-stated until 48h elapse. Fix: recompute the trailing 2 activity_date rows on each run (the (org, activity_date) idempotent upsert already supports it) or finalize on a 48h lag — state it as a chunk-2/3 acceptance criterion.Dimas + BEbefore chunk 2/3
OQ-10Open Question[rfc-reviewer R1 · REV-2] Forecast derivation method for IMPACT-S05/AC-2 is unspecified — only gating (Decision 5), the output field (forecast:{projected_net}), and the MAE stop-condition are given. Name the algorithm (e.g. least-squares linear fit over the last N daily net points), the minimum trend length, and the projected_net [0,1] bound. Contained by flag-OFF default.Dimas + BEbefore chunk 10
OQ-11Open Question / Risk[rfc-reviewer R1 · REV-4] No supporting index exists on rooms for the nightly aggregation/self-join — verified absent: no contact_id index, no closed_at index, no composite (organization_id, closed_at/created_at). Either plan a covering-index migration or record an explicit "off-peak un-indexed batch is acceptable" note. (Also: the §2.0 evidence wording overstates index coverage and has 3 minor nits — mongoid grep = 1 Devise comment not 0; entitlement uses a valid_until window not a single order_id; the application_maintenance queue lives on other workers, not assign_agent_worker.)Dimas + BEbefore Closed Beta
L-1LimitationSentiment is a free string in omnichannel_room_summaries (values like Netral; not enum-enforced) — the delta normalizes known values and reports coverage; unknown values are excluded (IMPACT-S03/AC-4).
L-2LimitationBare RESOLVE exclusion slightly under-counts genuine human-button resolves that were AI-assisted (PRD Risk #4) — accepted, revisited with reason-persistence in Phase 2.
L-3LimitationAggregate is ≤24h stale by design (no "today so far"); acceptable per PRD §6.

None of OQ-1..OQ-5 block starting the migration/aggregator chunks; they gate the specific chunk noted. OQ-4 is non-blocking (tile-scoped, PRD-mitigated).


6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-08rfc-starter (Claude)Initial full-stack RFC drafted from Phase-1 PRD (READY 9.8) + anchor, grounded in real source across chatbot (Rails 7.1/Grape BE), chatbot-fe (Nuxt 3/Pinia FE), qontak-designer (reviewed — prototype sandbox, no reusable code). Corrected PRD's MongoDB assumption (sentiment is Postgres chatbot_gpt). All mermaid blocks validated with mmdc. Open questions OQ-1..OQ-8 raised.
2026-07-08rfc-reviewer R1 (Claude)Reviewed against real source in all three repos — see phase-1-live-impact-report-review.md. Score 8.0/10 (Strong), verdict PROCEED with notes. ~95% of §2.0 source claims verified exact; 11/11 mermaid blocks parse. Net-new material findings promoted to §5: OQ-9 (REV-1, 48h reopen windowing vs daily batch — over-states the hero), OQ-10 (REV-2, forecast method unspecified), OQ-11 (REV-4, missing rooms indexes + 3 evidence nits). REV-3/5/6 map to existing OQ-3/1/2.

7. Ready for agent execution

  • yes — with 8 targeted open questions that gate specific execution chunks (not the overall design). All infra topology, ADRs, DDL, API contracts, cross-layer verification, and the ordered execution plan are complete and grounded in verified source. Remaining items (OQ-1 feature code, OQ-2 FE role strings, OQ-3 message-count source, OQ-4 business-hours config, OQ-5 casing mapper, OQ-6 Figma, OQ-7 copy language, OQ-8 approvers) are enumerated in §5 with their gating chunk; each is a small confirmation, and the agent can begin at chunk 1 (migrations) immediately.

Execution-readiness gates:

  • §1 Design References (FE half) — surfaces listed; frames n/a — design pending (OQ-6); DS version verified.
  • §1 PRD-to-Schema Derivation (BE half) — every entity/attribute/rule mapped to table.column + endpoint + enforcement.
  • Detail 1.C Per-Story Change Map — all 9 stories mapped (FE+BE columns both filled where FE+BE).
  • Repo Reading Guide — anchors named for both layers; contracts classified; reading order set.
  • Source Verification — every anchor/pattern/contract has concrete evidence (file + identifier/line).
  • Design ↔ Code Mapping (FE) — every component mapped to a file + backing endpoint (tokens gated on OQ-6).
  • Mermaid — topology, service map, repo map, component, ER, state, branch/skip, 4 sequences (incl. failure paths) — all validated with mmdc.
  • DDL — complete with retention/lifecycle; every row traces to a PRD-to-Schema row.
  • APIs — outbound (3) tagged new-with-justification; inbound n/a — reason.
  • Cross-Layer Contract Verification — every endpoint Match? = yes.
  • UI State Matrix / Failure Mode Catalog / Branch & Skip — complete.
  • Cross-Layer Rollout Matrix — complete; deploy order = BE-first; forbidden scenario mitigated.
  • Config Contract — two flags + cron named with defaults.
  • Agent Execution Plan — 11 ordered chunks, each with files + repo-sourced commands + verifiable acceptance criteria.
  • Verification & Rollback Recipe — per-layer commands, named signals, deploy-order-aware rollback.

Optional next step: hand off to rfc-reviewer for a second-pass score.