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 — reasonare 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 readsnot 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
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | YAML status: carries the remapped linter enum draft |
| DRI | Dimas Fauzi Hidayat | Single accountable owner of this RFC. Per-task staffing lives in delivery/. |
| Team | chatbot (BOT — Bot, AI & Automation) | Advisory squad slug from the source PRD / initiative README |
| Author(s) | Dimas Fauzi Hidayat | PM/DRI; eng implementors listed under Reviewers |
| Reviewers | agus.suparman (BE), baghiz.firdausa (FE), BI/Data (datamart) | Tech reviewers across affected squads |
| Approver(s) | TBD — BOT tech lead · TBD — infosec approver | Infosec approver required before AGREED (see §5 OQ-8) |
| Submitted Date | 2026-07-08 | Date RFC opened for discussion |
| Last Updated | 2026-07-08 | Bump on every material edit |
| Target Release | 2026-Q3 | Carried from PRD |
| Target Quarter | 2026-Q3 | Advisory, from source PRD / initiative README |
| Delivery | not yet handed to delivery | Will point to ../delivery/timeline.md once handed off |
| Related | Phase 1 PRD · Anchor | Repo-relative paths that resolve |
| Discussion | TBD — #bot-ai squad channel | Slack thread to be created at RFC open |
Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature
Sections at a Glance
- Overview (§1 Design References — FE half, §1 PRD-to-Schema Derivation — BE half, traceability, per-story change map)
- Technical Design (Repo Reading Guide → topology → ADRs → end-to-end mermaid → DDL → APIs → cross-layer verification)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, §4 Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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
- Correctness of the hero. Honest containment = share of eligible conversations with
rooms.closed_reason='RESOLVE_AI'ANDassign_channel_agent_id IS NULLANDclosed_at IS NOT NULL, minus same-contact_idreopens within 48h; excludeschannel='bot_preview'andclosed_reason='SPAM'. Validated against a hand-checked sample at Internal Alpha (PRD §13 gate). - 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)). - Data freshness ≤ 24h (nightly aggregation).
- Reliability ≥ 99% successful loads within 30 days of GA
(
1 − impact_report_load_failed ÷ impact_report_viewed). - 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).
Related Documents
- 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_logsis the report's read spine. Phase 1 populates it fromrooms(+ 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::UnansweredQuestionis the existing precedent —app/models/hologres/unanswered_question.rb). - A2. The
closed_reasonstring literals the metric depends on (RESOLVE_AI,RESOLVE,ASSIGN_AGENT,ASSIGN_AGENT_AI,WAITING_ASSIGN_AGENT,SPAM) match what the platform writes — verified inconfig/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 toroomsis app-level (omnichannel_room_summaries.room_id↔rooms.channel_room_id, both strings, cross-DB). - A4. BE role strings are lowercase
owner/supervisor/admin— verified fromreport.rb:16set_role(%w[owner supervisor admin]). Cost-assumption write isowner/adminonly. - A5. The "AI add-on / Generative AI" entitlement is a per-org
ChatbotGpt::OrganizationFeaturerecord (enabled, under an active order) — mechanism verified; the exact featurecodeis data, not source → OQ-1.
Dependencies
| # | Dependency | Layer / Owner | Availability | Blocking? |
|---|---|---|---|---|
| D1 | rooms table (Postgres primary) — read source for all room-outcome metrics | chatbot BE (exists) | exists | — |
| D2 | chatbot_gpt.omnichannel_room_summaries.sentiment (Postgres, separate DB) | chatbot BE (exists) | exists | NO — sentiment tile degrades gracefully |
| D3 | ai_activity_logs per-org daily aggregate + nightly job | chatbot BE (new, this RFC) | needs building | YES |
| D4 | ai_cost_assumptions per-org config table + CRUD | chatbot BE (new, this RFC) | needs building | YES (for work-absorbed tile) |
| D5 | Report page + tiles + modal + journey/forecast viz | chatbot-fe (new, this RFC) | needs building | YES |
| D6 | Per-org business-hours config (after-hours tile) | chatbot BE | unknown → OQ-4 | NO — tile ships only if config exists (PRD §16 mitigation) |
| D7 | Reports role gate (set_role) + org-ownership middleware | chatbot BE (exists) | exists — reused | NO — already exists |
| D8 | Subscription features[] array carrying the AI add-on + report flag codes on FE | chatbot BE → chatbot-fe (exists mechanism) | exists mechanism; new code → OQ-1 | YES (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 pendingand 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 surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| 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 |
| CostAssumptionModal | n/a — design pending (OQ-6) | — | @mekari/pixel3@^1.0.12 | TBD (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 / rule | Persisted as (table.column) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
Honest containment = RESOLVE_AI ∧ assign_channel_agent_id IS NULL ∧ closed_at NOT NULL, net of 48h same-contact reopens | ai_activity_logs.contained_ai_net, .contained_ai_gross, .reopened_48h (daily per-org counts) | GET /v1/reports/ai_agent_impact | Nightly 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 & denominator | filter in aggregator; not stored as rows | same endpoint | aggregator WHERE channel <> 'bot_preview' AND closed_reason <> 'SPAM' | IMPACT-S01/AC-3 |
Bare RESOLVE is NOT an AI win | excluded from contained_ai_* | same endpoint | aggregator classification | IMPACT-S01/AC-4 |
| Blended journey (AI-only / AI-assisted-then-human / escalated), room grain | ai_activity_logs.journey_ai_only, .journey_ai_assisted, .journey_escalated | same endpoint | aggregator closed_reason→segment mapping | PRD §9.1 step 6, IMPACT-S02/AC-1..3 |
Reopen rate (same contact_id, new conversation ≤48h) | ai_activity_logs.reopened_48h / .contained_ai_gross | same endpoint | aggregator self-join on contact_id within 48h | IMPACT-S03/AC-1 |
| Sentiment delta (AI-contained vs escalated) | ai_activity_logs.sentiment_contained_*, .sentiment_escalated_*, .sentiment_coverage | same endpoint | aggregator app-level join to omnichannel_room_summaries.sentiment | IMPACT-S03/AC-2, AC-4 |
| Turns-to-resolve (avg messages per AI-contained room) | ai_activity_logs.turns_sum, .turns_count | same endpoint | aggregator message-count source (OQ-3 confirm source) | IMPACT-S03/AC-3 |
| After-hours containment (count + %) | ai_activity_logs.contained_after_hours | same endpoint | aggregator 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 series | same endpoint (trend[]) | report use-case (no new storage) | IMPACT-S05/AC-1 |
| Next-period forecast (flag-gated) | derived at read time; not stored | same endpoint (forecast when flag on) | report use-case behind ai_agent_impact_report_forecast flag | IMPACT-S05/AC-2..3 |
| Baseline-forming (<30d data OR <min conversations) | ai_activity_logs row count / date span | baseline_forming: true flag in response | report use-case | IMPACT-S01/AC-6 |
| Cost assumption (agent-hour rate + minutes/conversation, per org) | ai_cost_assumptions.agent_hour_rate, .minutes_per_conversation, .organization_id | GET/PUT /v1/reports/ai_agent_impact/cost_assumption | Grape 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_net | report endpoint (work_absorbed null until set) | report use-case; null when no assumption | IMPACT-S04/AC-1, AC-3 |
| AI add-on required; else no access | not stored here | endpoint 403 + FE entry hidden | ChatbotGpt::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 id | FE section / component | BE section / endpoint |
|---|---|---|
| IMPACT-S01/AC-1..5 | HonestContainmentTile, ValueDeliveredGrid (§2.A) | GET /v1/reports/ai_agent_impact → contained_ai_net/gross, tiles (§2.4) |
| IMPACT-S01/AC-6 | BaselineForming empty state (no-data.vue, §2.C) | baseline_forming flag (§2.4) |
| IMPACT-S01/ERR-1 | Error+Retry state (§2.C, §3.C) | 5xx/timeout → error envelope; impact_report_load_failed (§3.B, §11) |
| IMPACT-S02/AC-1..3, ERR-1 | BlendedJourneyBar (custom SVG, §2.A) | journey_* fields (§2.4) |
| IMPACT-S03/AC-1..4, ERR-1 | QualityGrid tiles (§2.A) | reopen_rate, sentiment_*, turns_* (§2.4) |
| IMPACT-S04/AC-1..3, ERR-1 | CostAssumptionModal (MpModal*, §2.A) | GET/PUT .../cost_assumption (§2.4) |
| IMPACT-S05/AC-1..4 | ForecastPanel (custom SVG, flag-gated, §2.A) | trend[], forecast (§2.4) |
| IMPACT-S01-NEG/NEG-1 | entry hidden for non-AI accounts (middleware, §2.0) | entitlement 403 (§3 auth matrix) |
| IMPACT-S02-NEG/NEG-2 | entry hidden for Agent role | set_role 403 (§3 auth matrix) |
| IMPACT-S03-NEG/NEG-3 | no CSAT/quality UI (out of scope) | n/a — not built |
| IMPACT-S04-NEG/NEG-4 | web only; not on mobile | n/a — web only |
Reverse (RFC → PRD AC):
| New FE component / BE endpoint / dependency | PRD composite AC id it serves |
|---|---|
GET /v1/reports/ai_agent_impact | IMPACT-S01/AC-1..6, S02/AC-1..3, S03/AC-1..4, S05/AC-1..4 |
GET/PUT /v1/reports/ai_agent_impact/cost_assumption | IMPACT-S04/AC-1..3, ERR-1 |
ai_activity_logs table + AiActivityLogAggregator job | IMPACT-S01/AC-1..4, S02, S03, S05 |
ai_cost_assumptions table | IMPACT-S04/AC-1..3 |
AiAgentImpactPage + pages/reports/ai-agent-impact/index.vue | IMPACT-S01..S05 (surface) |
ai-agent-impact-feature.ts middleware | IMPACT-S01-NEG/NEG-1, S02-NEG/NEG-2 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
AiAgentImpactPage (/reports/ai-agent-impact) | web | GET /v1/reports/ai_agent_impact | — | AiAgentImpactPage view | baseline_forming flag + fetchStatus string |
| CostAssumptionModal | web | GET .../cost_assumption | PUT .../cost_assumption | CostAssumptionModal | has_cost_assumption flag |
| ForecastPanel | web | GET .../ai_agent_impact (forecast field) | — | ForecastPanel | forecast present iff flag on |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| Owner | set_role(%w[owner supervisor admin]) + Ownership middleware; write: set_role(%w[owner admin]) | GET report; GET/PUT cost_assumption | full report + cost editor | no (org-scoped) | Mixpanel cost_assumption_updated; Lograge audit |
| Admin | same as Owner | GET report; GET/PUT cost_assumption | full report + cost editor | no | same |
| Supervisor | set_role (read only) | GET report; GET cost_assumption | full report; cost figure read-only (no editor) | no | Mixpanel impact_report_viewed |
| Agent | set_role rejects (403) | none | entry not rendered | no | — |
| Any role on non-AI account | entitlement check rejects (403) | none | entry not rendered | no | — |
PRD Section Coverage
| PRD § | Title | Where covered |
|---|---|---|
| Scope Changes | Backend · Frontend · Data | §1 Overview, §2 (all) |
| 2 | One-liner + Problem | §1 Overview |
| 3 | What if we don't ship | §1 Overview (motivation) |
| 4 | Target Users + Persona | §1 Overview; plain-language copy (Decision 8) |
| 5 | Non-Goals | §1 Out of Scope |
| 6 | Constraints | §1 Success Criteria, §3 Performance, §4.B Config |
| 6.7 | Data Lifecycle | §2.3 per-status/retention table |
| 7 | New Features (component tree, UI states) | §2.A UI Contract, §2.C UI State Matrix |
| 8 | API & Webhook Behavior | §2.4 APIs |
| 9.1 | System Flow | §2.2 Sequence diagrams |
| 9.2 | User Stories + ACs | Detail 1.A + 1.C |
| 10 | Rollout | §4 Rollout Strategy |
| 10.5 | Semantic Regression Rollback (forecast) | §4 Rollout (forecast flag), §4.E rollback |
| 11 | Observability | §3 Monitoring, §3.B events |
| 11.1 | Post-Launch Monitoring Cadence | §4 Rollout (stop conditions) |
| 12 | Success Metrics | §1 Success Criteria |
| 13 | Launch Plan & Stage Gates | §4 Rollout stages |
| 14 | Dependencies | §1 Dependencies + §2.F.1 Responsibility Boundary |
| 15 | Key Decisions + Alternatives | §1 Detail 1.B + §2 Technical Decisions |
| 16 | Open Questions | §5 |
| Appendix A | Stitch UI Prompts | §1 Design References (design pending) |
Detail 1.B — Decisions Closed (cross-layer)
| Decision | Chosen option | Alternatives rejected | Why rejected | Layer |
|---|---|---|---|---|
| D1 Aggregate ownership & source | Chatbot-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 definition | RESOLVE_AI ∧ no human ∧ closed, net of 48h reopens; exclude bare RESOLVE, bot_preview, SPAM | gross containment as hero | over-counts idle-timeout/abandonment; fails credibility bar | BE |
| D3 Sentiment source | Postgres chatbot_gpt.omnichannel_room_summaries.sentiment, app-level join | MongoDB (PRD assumption) | no Mongoid in repo — MongoDB does not exist here | BE |
| D4 Money model | admin-set assumption (ai_cost_assumptions), no default rate | Qontak-audited rupiah | no rate/benchmark in BE; contestable; Phase 3 | BE + FE |
| D5 Forecast gating | own flag ai_agent_impact_report_forecast, OFF by default | ship forecast unconditionally | projection can mislead; §10.5 rollback needs a kill-switch | BE + FE |
| D6 Read spine | pre-computed daily aggregate, not request-time SQL over rooms | Metabase iframe (existing /report) | can't host cost toggle / forecast / config; native surface needed | BE + FE |
| D7 Trend/journey visualization | custom SVG/CSS (Tailwind + pixel3) | add a chart lib (chart.js/echarts/apexcharts) | no chart lib exists; MVP avoids new dep + bundle cost | FE |
| D8 Copy / localization | hardcoded Indonesian-first plain-language copy inline | introduce an i18n framework | no i18n exists in chatbot-fe; net-new infra out of scope | FE |
| D9 Route path | /reports/ai-agent-impact (pages/reports/ai-agent-impact/index.vue) | reuse singular /report namespace | PRD commits the URL (appears in NEG-1); existing /report is a separate Metabase LP | FE |
| D10 Reuse-vs-new (report endpoints) | new-with-justification — new report shape; reuse set_role+Ownership middleware, Grape entity + envelope patterns | extend existing V1::Report (metabase) / V1::CustomReport | those serve a Metabase URL / generic room counts, not this metric set | BE |
| D11 Per-status lifecycle | ai_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 enum | soft-delete/status columns | no lifecycle states surfaced to users; TTL cleanup only | BE |
| D12 Sync vs async | aggregation is async (nightly sidekiq-cron); report read + cost write are sync | compute metrics synchronously at request | request-time cross-DB aggregation blows the ≤2s p95 budget | BE |
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 id | Title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
| IMPACT-S01 | Honest containment + value delivered | FE + BE | HonestContainmentTile, VolumeTile, AfterHoursTile, WorkAbsorbedTile, ValueDeliveredGrid, PlainHeadlineSummary; baseline-forming via no-data.vue; error+retry state | ai_activity_logs + aggregator (contained_ai_net/gross, reopened_48h, after_hours, volume); GET /v1/reports/ai_agent_impact report use-case | IMPACT-S01/AC-1..6, ERR-1 | rspec: 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-S02 | AI + human journey | FE + BE | BlendedJourneyBar (custom SVG 3-segment), plain labels | journey_ai_only/ai_assisted/escalated in aggregator + response | IMPACT-S02/AC-1..3, ERR-1 | rspec: 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-S03 | Is the AI doing a good job | FE + BE | ReopenRateTile, SentimentDeltaTile, TurnsTile (QualityGrid), per-tile degrade | reopen_rate, sentiment_* (app-level join, coverage note), turns_* in aggregator | IMPACT-S03/AC-1..4, ERR-1 | rspec: 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-S04 | Cost assumption → money saved | FE + BE | CostAssumptionModal (MpModal*), inline validation, work-absorbed prompt when unset | ai_cost_assumptions table; GET/PUT .../cost_assumption; set_role(%w[owner admin]); bounds validation | IMPACT-S04/AC-1..3, ERR-1 | rspec: 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-S05 | Trend + next-period forecast | FE + BE | ForecastPanel (custom SVG, solid past + dashed projection), flag-gated | trend[] derived; forecast when ai_agent_impact_report_forecast on | IMPACT-S05/AC-1..4 | rspec: 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-NEG | No AI add-on | Config + BE | entry not rendered (middleware) | entitlement check → 403 | IMPACT-S01-NEG/NEG-1 | rspec: non-AI org → 403; e2e: no report entry rendered | §3 auth matrix, §2.0 middleware · §4.D chunk 5 |
| IMPACT-S02-NEG | Ineligible role | Config + BE | entry not rendered for Agent | set_role → 403 | IMPACT-S02-NEG/NEG-2 | rspec: Agent role → 403; e2e: no entry, no surfaced 403 | §3 auth matrix · §4.D chunk 5 |
| IMPACT-S03-NEG | Out-of-scope metrics | n/a — not built | no CSAT/quality UI | none | IMPACT-S03-NEG/NEG-3 | manual: Phase-1 report shows no CSAT/quality section | §1 Out of Scope |
| IMPACT-S04-NEG | Mobile app | n/a — web only | not on mobile | none | IMPACT-S04-NEG/NEG-4 | manual: 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-designerwas 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; itsvoice-of-customerandcommerceanalytics 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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE | app/api/frontend_service/v1/report.rb | the report endpoint to mirror | Grape endpoint + set_role(%w[owner supervisor admin]) + Middlewares::Ownership + Grape entity response |
| BE | app/api/frontend_service/helpers/authorization_helpers.rb | role gate | set_role(roles) raises ErrorException(code:403) if role not included |
| BE | app/api/frontend_service/middlewares/ownership.rb | org scoping | 403 unless env['user']['chatbot_organization_id'] |
| BE | app/core/repositories/custom_report/generate.rb | the containment-style aggregation | Room.select("COUNT(id) FILTER (WHERE ...)")....group("DATE(created_at)") scoped by organization_id |
| BE | app/models/room.rb + db/schema.rb:1761 | read schema | AR model on primary PG; closed_reason, assign_channel_agent_id, closed_at, contact_id, channel, channel_room_id columns + indexes |
| BE | app/models/hologres/unanswered_question.rb + app/models/hologres_record.rb | datamart precedent (Phase 2 path) | HologresRecord connects_to + schema-qualified self.table_name |
| BE | app/models/chatbot_gpt/omnichannel_room_summary.rb + db/chatbot_gpt_schema.rb | sentiment source | separate chatbot_gpt PG DB; sentiment string; join key room_id↔rooms.channel_room_id |
| BE | app/models/system_preference.rb + app/core/repositories/knowledge_stores/create.rb:36 | rollout flag | SystemPreference.find_by(group_code:'rollout', code:..., enabled:true) |
| BE | app/core/repositories/gpt/subscription/subscription_detail.rb:36-51 | AI add-on entitlement | ChatbotGpt::OrganizationFeature.where(company_id:, order_id: <active>, enabled:true) |
| BE | app/workers/assign_agent_worker.rb + config/schedule.yml | worker + cron | include Sidekiq::Worker + sidekiq_options; sidekiq-cron entry cron: "0 1 * * * Asia/Jakarta" |
| BE | db/migrate/20260624000001_add_related_key_and_related_type_to_attachments.rb | migration style | ActiveRecord::Migration[7.1] + disable_ddl_transaction! + add_index algorithm: :concurrently |
| FE | modules/report/views/bot-peformance.vue | existing report view | thin page → module view; fetchStatus string machine; 403 → redirect |
| FE | store/report/ (index/state/getters/actions/interface/types) | store convention | 6-file Pinia store, extractStore, action constants, persist:true |
| FE | common/services/main/v1/report.ts + common/services/main/endpoint.ts:62 | service layer | service returns { fetch, controller }; endpoints centralized under v1.<domain> |
| FE | plugins/api/apiMain.ts + api/mainResources.ts | HTTP client | $apiMain (ofetch) injects Bearer token; returns response._data |
| FE | middleware/ai-assist-dynamic-kb-feature.ts | feature-flag gate | subscriptionStore().$state.subscriptionData.features.find(code===...).enabled → abortNavigation(createError({statusCode:404})) |
| FE | modules/settings/views/ai-assist.vue:474-513 | modal pattern | MpModal/MpModalContent/Header/Body/Footer/Overlay compound; local ref open state |
| FE | common/components/error/no-data.vue | empty state | reusable empty/baseline component (title/description/svgName) |
| FE | common/utils/tracking.ts + common/contants/mixpanel-events.ts | analytics | trackEvent(MIXPANEL_EVENTS.X, props, jimo?); names are [CHATBOT] ... strings |
| FE | authenticationStore().profile.data.role (via common/utils/tracking.ts) | role source | role is a string on the auth profile (used for analytics today) |
Existing Contracts to Reuse, Extend, or Replace (BE)
| Contract | Status | Justification | Owner |
|---|---|---|---|
set_role helper + Middlewares::Ownership | reuse | exact role + org gate the report needs | chatbot BE |
Grape success envelope / ErrorException error shape | reuse | consistent API contract | chatbot BE |
Room.select("COUNT FILTER") aggregation idiom | reuse (pattern) | proven containment-style aggregation | chatbot BE |
sidekiq-cron (config/schedule.yml) | reuse (pattern) | nightly aggregation job | chatbot BE |
GET /v1/reports/ai_agent_impact | new-with-justification | no existing endpoint returns this metric set; V1::Report serves a Metabase URL, V1::CustomReport serves generic room counts | chatbot BE |
GET/PUT /v1/reports/ai_agent_impact/cost_assumption | new-with-justification | no per-org cost-assumption store exists | chatbot BE |
ai_activity_logs table + aggregator | new-with-justification | table does not exist (verified NOT FOUND) | chatbot BE |
ai_cost_assumptions table | new-with-justification | no such table/columns exist | chatbot BE |
Patterns to Follow
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| FE | State management | 6-file Pinia store + extractStore | store/report/ | none |
| FE | Data fetching | service {fetch,controller} → store action tracks fetchStatus | common/services/main/v1/report.ts, store/report/actions.ts | none |
| FE | Error / retry | none reusable — build with MpBanner + MpButton | common/components/error/no-data.vue (empty only) | yes — new error+retry component |
| FE | Modal | pixel3 MpModal* compound | modules/settings/views/ai-assist.vue | none |
| FE | Charts | none — hand-built SVG/CSS | n/a (no chart lib) | yes — custom viz (Decision 7) |
| FE | Role gate | none — new middleware from flag-gate template | middleware/ai-assist-dynamic-kb-feature.ts | yes — role predicate is new (Decision, OQ-2) |
| BE | Grape handler shape | endpoint → use-case (dry-monads) → repository | app/api/frontend_service/v1/report.rb | none |
| BE | Repository / DB access | AR select/where/group, org-scoped | app/core/repositories/custom_report/generate.rb | none |
| BE | Scheduled job | Sidekiq worker + sidekiq-cron | app/workers/assign_agent_worker.rb, config/schedule.yml | none |
| BE | Error response shape | ErrorException(message:, code:, errors:) | app/api/frontend_service/helpers/error_response_helpers.rb | none |
| BE | Logging / tracing | Lograge structured + Datadog + Rollbar; Mixpanel product events (dot/[CHATBOT]) | config/initializers/{lograge,datadog,mixpanel}.rb | none |
| Cross | snake_case API → camelCase FE | FE reads raw JSON keys from $apiMain response _data | store/report/actions.ts (res.data) | none — FE consumes snake_case as-is (confirm mapper, OQ-5) |
Reading Order for the Agent
app/api/frontend_service/v1/report.rb— how a report endpoint is mounted + gated.app/core/repositories/custom_report/generate.rb— the containment-style SQL aggregation to reuse.app/models/room.rb+db/schema.rb:1761— exact columns + indexes the metric touches.config/locales/en.yml:346-350— canonicalclosed_reasonstring values.app/models/chatbot_gpt/omnichannel_room_summary.rb+db/chatbot_gpt_schema.rb— sentiment source + join key.app/workers/assign_agent_worker.rb+config/schedule.yml— worker + nightly cron pattern.db/migrate/20260624000001_*.rb— migration house style (disable_ddl_transaction!, concurrent index).store/report/+common/services/main/v1/report.ts— FE store + service pattern.middleware/ai-assist-dynamic-kb-feature.ts— flag-gate middleware to extend with a role check.modules/settings/views/ai-assist.vue+common/components/error/no-data.vue— modal + empty-state components.
Source Verification
| Layer | Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|---|
| BE | report.rb role gate | read | report.rb:16 set_role(%w[owner supervisor admin]); report.rb:6 use Middlewares::Ownership |
| BE | set_role semantics | read | authorization_helpers.rb raises ErrorException(message:['Permission denied'], code:403) unless role included |
| BE | Ownership org scope | read | ownership.rb 403 ['Organization not found'] unless env['user']['chatbot_organization_id'] |
| BE | containment aggregation idiom | read | custom_report/generate.rb:100-118 Room.select("COUNT(id) FILTER (WHERE resolved_at IS NOT NULL) AS resolved ...").group("DATE(created_at)") |
| BE | rooms columns | read | db/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 |
| BE | closed_reason values | read | config/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 |
| BE | bot_preview channel value | read | en.yml:279,533 bot_preview; compared at assign_agent_round_robin.rb:31 room.channel == I18n.t('bot_preview.channel') |
| BE | sentiment source (not Mongo) | read/grep | omnichannel_room_summary.rb < ChatbotGptRecord; chatbot_gpt_schema.rb t.string "sentiment"; grep -ric mongoid → 0 |
| BE | join key | read | omnichannel_room_summaries.room_id (string) ↔ rooms.channel_room_id (string); different PG DBs → app-level join |
| BE | datamart precedent | read | hologres_record.rb:6 connects_to database:{reading::hologres,...}; hologres/unanswered_question.rb self.table_name='chatbot_ai.qontak_chatbot_ai_unanswered_questions' |
| BE | ai_activity_logs absent | grep | grep -rin "ai_activity_log|containment|impact_report" → 0 hits (must be built) |
| BE | rollout flag mechanism | read | system_preference.rb; knowledge_stores/create.rb:36 SystemPreference.find_by(group_code:'rollout', code:'...', enabled:true) |
| BE | AI add-on entitlement | read | subscription_detail.rb:36-51 ChatbotGpt::OrganizationFeature.where(company_id:, order_id:<active>, enabled:true); exact code is data → OQ-1 |
| BE | worker + cron | read | assign_agent_worker.rb include Sidekiq::Worker; config/schedule.yml cron:"0 1 * * * Asia/Jakarta" |
| BE | migration style | read | 20260624000001_*.rb ActiveRecord::Migration[7.1] + disable_ddl_transaction! + add_index ..., algorithm: :concurrently |
| BE | test/lint commands | read | bitbucket-pipelines.yml:74 bundle exec rspec ...; :349 bundle exec rubocop |
| FE | Nuxt/Vue/pnpm | read | package.json "nuxt":"^3.12.2", "pinia":"^2.1.7", "@mekari/pixel3":"^1.0.12", "packageManager":"pnpm@10.15.1" |
| FE | existing report view | read | pages/report/index.vue → modules/report/views/bot-peformance.vue; fetchStatus "idle"/"pending"/"resolved"/"rejected" |
| FE | store convention | read | store/report/index.ts defineStore("report", ...extractStore(useState()) ...); types.ts action constants |
| FE | service + endpoint | read | common/services/main/v1/report.ts returns {fetch,controller}; endpoint.ts:62-63 report:{get_metabse_url:"v1/reports"} |
| FE | HTTP client | read | plugins/api/apiMain.ts provide("apiMain", customFetch); api/mainResources.ts ofetch.create injects Bearer |
| FE | flag-gate middleware | read | ai-assist-dynamic-kb-feature.ts features.find(code==="ai_assist_dynamic_kb").enabled → abortNavigation(createError({statusCode:404})) |
| FE | modal pattern | read | modules/settings/views/ai-assist.vue:474-513 MpModal+compound; import from @mekari/pixel3 |
| FE | empty state | read | common/components/error/no-data.vue props title/description/svgName |
| FE | analytics | read | common/utils/tracking.ts trackEvent(name, props, jimo); mixpanel-events.ts [CHATBOT] ... |
| FE | role source | read | common/utils/tracking.ts reads authentication.profile.data.role (string; analytics only; no gate) → OQ-2 |
| FE | no chart lib / no i18n / no typecheck | grep/read | no 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 pendingand confirmed at design QA. Tokens listed are the pixel3 families the components will draw from.
| Frame / component | Implementing file | Reuse vs new | Design tokens (pixel3) | Backing API endpoint | Deviation |
|---|---|---|---|---|---|
| AiAgentImpactPage | modules/report/views/ai-agent-impact.vue | new | text.*, background.stage, pixel3 spacing | GET /v1/reports/ai_agent_impact | n/a — design pending |
| HonestContainmentTile (hero) | modules/report/components/ai-agent-impact/HonestContainmentTile.vue | new (MpBox/MpText/MpTag) | brand color on hero number only | same | n/a — design pending |
| Value/Quality tiles | .../components/ai-agent-impact/*Tile.vue | new (MpBox/MpText) | text.default/text.secondary | same | n/a — design pending |
| BlendedJourneyBar | .../components/ai-agent-impact/BlendedJourneyBar.vue | new (custom SVG) | 3 segment colors | same (journey_*) | custom viz (Decision 7) |
| ForecastPanel | .../components/ai-agent-impact/ForecastPanel.vue | new (custom SVG) | line + dashed projection | same (trend[],forecast) | custom viz (Decision 7) |
| CostAssumptionModal | .../components/ai-agent-impact/CostAssumptionModal.vue | new (MpModal*) | pixel3 modal tokens | GET/PUT .../cost_assumption | n/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_logstable in primary Postgres.- Pros: fully in-repo, unblocked, agent-executable in one repo; reuses the proven
COUNT FILTERidiom + sidekiq-cron; satisfies "pre-computed, not request-time read". - Cons: duplicates data BI may later own; chatbot owns the aggregation logic.
- Pros: fully in-repo, unblocked, agent-executable in one repo; reuses the proven
- 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
roomsaggregation 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_reasonis 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 styledisable_ddl_transaction!+add_index algorithm: :concurrently(per20260624000001_*.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_byis an internal user identifier (treat as low-sensitivity, scrub from external logs). - Retention:
ai_activity_logsrolling 13 months (TTL fromcomputed_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:
| Table | Retention | Cleanup trigger | User-visible effect | Restore |
|---|---|---|---|---|
ai_activity_logs | 13 months rolling | nightly TTL on computed_at | older ranges simply unavailable beyond the 12-month selector | not applicable (recomputable from rooms while rooms retained) |
ai_cost_assumptions | account lifetime; overwritten on edit | manual edit (upsert) | new rupiah figures on next view | last 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)
| Endpoint | Method | AuthN/AuthZ | Request | Response | Status | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/v1/reports/ai_agent_impact | GET | :api_auth Bearer + Middlewares::Ownership (org from session) + set_role(%w[owner supervisor admin]) + AI-add-on entitlement | query: 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_assumption | GET | Bearer + Ownership + set_role(%w[owner supervisor admin]) | — | data: `{ agent_hour_rate, minutes_per_conversation, updated_by } | null` | 200, 403 | safe GET | /v1/ |
/v1/reports/ai_agent_impact/cost_assumption | PUT | Bearer + Ownership + set_role(%w[owner admin]) | { agent_hour_rate:int≥0, minutes_per_conversation:int≥0 } | data: saved record | 200, 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):
- AiAgentImpactPage —
modules/report/views/ai-agent-impact.vue. State:ai-agent-impactPinia store (report,fetchStatus,costAssumption). Fetches on mount via store action; stashesAbortController(abort on unmount, perplugins/api/request.ts). FirestrackEvent(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 (perai-assist.vue); props{ isOpen:boolean; value:{agentHourRate?:number; minutesPerConversation?:number}; canEdit:boolean }; emitssave,close; inline validation; hidden editor for non-owner/admin. On save →PUT cost_assumption→trackEvent(cost_assumption_updated). - ForecastPanel — props
{ trend:{date:string;net:number}[]; forecast?:{projectedNet:number}; flagOn:boolean }; custom SVG line (solid past + dashed projection); projection only whenflagOn && 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-labelsummarizing the values; verdict is text, not color-only; keyboard-focusable Retry + modal focus trap (pixel3MpModal).
Detail 2.B — Data-Fetching Strategy
- Library:
$apiMain(ofetch) via the service + Pinia pattern (nouseFetch/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
fetchStatusmachine (pending→resolved/rejected). - Optimistic updates: none for the report (read-only). Cost save is pessimistic (await 200, then refetch report).
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Report page | MpSkeleton tiles + skeleton chart | Baseline-forming (no-data.vue, volume only, no %) | error+retry (MpBanner+MpButton); impact_report_load_failed logged | per-tile degrade (sentiment/journey "not available") without blocking the rest | full grid + verdicts + plain headline (+ forecast if flag on) |
| CostAssumptionModal | save spinner (MpSpinner), fields disabled | first-time: blank fields + "why we ask" helper | inline validation under field (422) | n/a | modal closes; report money figure recomputes on next read |
| ForecastPanel | skeleton chart | baseline-forming note | chart hidden, rest renders | historical-only when flag off / insufficient | trend (+ dashed projection when flag on) |
Detail 2.D — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency key + TTL | Consistency | Duplicate handling | Stale-read handling |
|---|---|---|---|---|---|---|
| Nightly aggregation upsert | one transaction per org-day batch | failed 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_assumption | single-row upsert (primary DB) | 422 before write on invalid input → nothing persisted | (organization_id) unique (upsert) | strong (read-after-write) | last write wins | none (single row) |
Detail 2.E — Concurrency Collision Map
| Resource | Writers | Collision scenario | Resolution | Behavior on conflict |
|---|---|---|---|---|
ai_cost_assumptions (org row) | Owner/Admin (rare, human) | two admins save near-simultaneously | DB 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 day | idempotent upsert on unique (organization_id, activity_date) + Sidekiq single-run per cron tick | re-run recomputes identical values; no duplication |
Detail 2.F — Async Job / Event Consumer Spec
| Job | Trigger | Input | Retry | DLQ / retention | Concurrency | Idempotency key | Per-msg timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
AiActivityLogAggregatorWorker | sidekiq-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) upsert | job-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 / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
1. Write room outcomes (closed_reason, assignment, timestamps) | chatbot BE (existing hub flows) | message/agent events | rooms rows | existing | PRD §8 (data source) |
| 2. Write room sentiment | chatbot BE / AI-service (existing) | summarization | omnichannel_room_summaries.sentiment | existing; missing → coverage note | PRD §14 (sentiment dep) |
| 3. Nightly aggregate | chatbot BE (this RFC) | sidekiq-cron | ai_activity_logs rows | retry 3 → dead set + Rollbar | PRD §14 D-datamart |
| 4. Serve report | chatbot BE (this RFC) | FE GET | JSON report | 5xx + impact_report_load_failed | PRD §8 Behavior 1 |
| 5. Render report | chatbot-fe (this RFC) | admin opens page | UI | error+retry state | PRD §7, §9 |
| 6. (Phase 2) migrate aggregate to Hologres | BI/Data | Phase 2 kickoff | chatbot_ai.ai_activity_logs | out of P1 scope | Anchor 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
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Impact aggregate | ai_activity_logs.* daily counts | 0 | nightly job | GET /v1/reports/ai_agent_impact | ≤24h |
| Cost assumption | ai_cost_assumptions (present/absent) | absent (→ work_absorbed null) | PUT cost_assumption | GET .../cost_assumption + report work_absorbed | none (strong) |
| Baseline-forming | baseline_forming boolean (derived) | false | report use-case | report response | ≤24h |
| Forecast availability | forecast present/absent (derived) | absent | report use-case (flag) | report response | ≤24h |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|---|---|---|---|
GET /v1/reports/ai_agent_impact | success envelope {status,code,message,data:{...}}, snake_case keys, work_absorbed/after_hours/sentiment/forecast nullable | FE reads res.data from $apiMain (snake_case as-is), tolerates nulls with per-tile degrade | yes | none — 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" prompt | yes |
PUT .../cost_assumption | 200 saved / 403 / 422 | FE handles 403 (hide editor) + 422 (inline error) | yes | FE 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-featuremiddleware (flag + role) → store action → service →$apiMain GET /v1/reports/ai_agent_impact→ Grape endpoint (Ownership + set_role + entitlement) → use-case → repository readsai_activity_logs+ai_cost_assumptions→ success envelope → store setsfetchStatus=resolved→ tiles render. Side effects:impact_report_viewedMixpanel event; on failureimpact_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 → upsertai_cost_assumptions→ 200 → FE refetches report →work_absorbedrecomputes. Side effect:cost_assumption_updatedevent.
Detail 2.I — Scope Boundaries
- FE create:
pages/reports/ai-agent-impact/index.vue;modules/report/views/ai-agent-impact.vuemodules/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 incommon/contants/mixpanel-events.ts.
- FE modify:
common/services/main/endpoint.ts(addv1.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; modelsAiActivityLog,AiCostAssumption; repositoryRepositories::AiAgentImpact::*; use-cases underapp/core/use_cases/api/frontend_service/v1/ai_agent_impact/; endpoints inapp/api/frontend_service/v1/report.rb(or a newai_agent_impact.rbmounted alongside) + Grape entities;app/workers/ai_activity_log_aggregator_worker.rb;config/schedule.ymlcron entry. - BE modify:
config/schedule.yml; API mount (app/api/frontend_service/api.rb) if a new endpoint file. - BE NOT touched:
roomswrite paths;omnichannel_room_summarieswrites; existing report endpoints; hub/assignment flows. - Shared modules:
set_role/Ownership (reused read-only); pixel3 components (consumed, not modified).
Detail 2.J — Asset Inventory
| Asset | Type | Source | Format & sizes | Path in repo |
|---|---|---|---|---|
| Baseline-forming illustration | illustration | reuse no-data.vue default (global-no-data) | existing SVG | common/components/error/ (existing) |
| Verdict pill / tile icons | icon | pixel3 MpIcon set | icon font/SVG (DS) | @mekari/pixel3 (no new asset) |
| Journey bar / trend line | (rendered) | custom SVG in-component (no imported asset) | inline SVG | component 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) viatrackEvent; 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_failedwith{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_authat the API boundary; org derived from session (never client param); role viaset_role; entitlement viaOrganizationFeature.
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Additional constraint | Audit |
|---|---|---|---|---|---|---|
| Owner | report GET; cost_assumption GET/PUT | GET, PUT | own org only | full report + cost editor | — | cost_assumption_updated + Lograge |
| Admin | report GET; cost_assumption GET/PUT | GET, PUT | own org only | full report + cost editor | — | same |
| Supervisor | report GET; cost_assumption GET | GET | own org only | full report; cost read-only (no editor) | write rejected server-side | impact_report_viewed |
| Agent | none | — | — | entry not rendered | 403 on direct call | — |
| Any role, non-AI org | none | — | — | entry not rendered | 403 (entitlement) | — |
- Ownership validation:
organization_idfromenv['user']['chatbot_organization_id'](Ownership middleware); every querywhere(organization_id:). - Input validation:
agent_hour_rateint ≥ 0 (+ upper bound, OQ-3 for sane cap);minutes_per_conversationint ≥ 0 (+ cap);start_date/end_datevalid 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)
| Surface | FE behavior on failure | BE response on failure | Codes aligned? |
|---|---|---|---|
| Report load | Error state + Retry; log report_load_failed | 5xx + impact_report_load_failed; error envelope | yes |
| No AI add-on | entry not rendered (middleware 404 abort) | 403 entitlement | yes (FE hides; never surfaces 403) |
| Ineligible role | entry not rendered | 403 set_role | yes |
| Sentiment source down | sentiment tile → "not available", rest renders | field null + coverage note | yes |
| Journey data missing | placeholder, rest renders | segment fields null | yes |
| Cost save invalid | inline field error | 422 validation | yes |
| Cost save unauthorized | editor not shown | 403 | yes |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
| No AI add-on | BE entitlement + FE middleware | access denied, entry hidden | — | no (silent hide) |
| Ineligible role (Agent) | BE set_role + FE middleware | access denied, entry hidden | — | no |
| Data < minimum | BE report use-case | baseline-forming (volume only, no %) | impact_report_baseline_forming | yes |
| No cost assumption | BE report use-case | work_absorbed=null; FE shows "set assumption" prompt | — | yes |
| Forecast flag OFF / insufficient trend | BE (flag) + FE | historical trend only, no projection | — | yes |
| Sentiment coverage low / source down | BE aggregator + read | reduced-coverage note / "not available" | — | yes |
| Business-hours config absent | BE aggregator | after-hours tile omitted (OQ-4 mitigation) | — | yes (tile absent) |
Detail 3.B — Error Response Catalog (BE)
| Endpoint | Error code | HTTP | Message | When | User-facing? |
|---|---|---|---|---|---|
| report GET | PERMISSION_DENIED | 403 | "Permission denied" | ineligible role/entitlement | no (FE hides entry) |
| report GET | BAD_REQUEST | 400 | "Invalid date range" | span > 12mo / start > end | yes (guarded in UI) |
| report GET | REPORT_UNAVAILABLE | 500 | "We couldn't load your report" | aggregate read failure/timeout | yes (Error + Retry) |
| cost PUT | PERMISSION_DENIED | 403 | "Permission denied" | non-owner/admin | no (editor hidden) |
| cost PUT | VALIDATION_ERROR | 422 | "agent_hour_rate must be a non-negative number" (etc.) | invalid/out-of-bounds | yes (inline) |
Detail 3.C — Error Message Catalog (FE)
| Error code | User-facing message (hardcoded ID copy — Decision 8) | Surface | User-facing? |
|---|---|---|---|
| REPORT_UNAVAILABLE | "Laporan gagal dimuat. Coba lagi." + Retry | inline/banner | yes |
| VALIDATION (cost) | "Masukkan angka yang valid." (field-specific) | inline under field | yes |
| PERMISSION_DENIED | — (entry hidden; no message surfaced) | none | no |
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;
MpModaltraps focus and restores on close. SVG journey bar/trend haverole="img"+aria-labelvalue summaries (never color-only meaning — the verdict is text). Contrast verified against pixel3 tokens at design QA (OQ-6).prefers-reduced-motionrespected 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
/reportuntouched. - 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_report—SystemPreference(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_reportOFF (report vanishes, no deploy); migrations are additive (drop tables only on full revert). PICs/timeline live indelivery/(not this RFC).
Detail 4.A — Cross-Layer Rollout Compatibility Matrix
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline (no report) |
| Backend first | Old | New | yes | FE has no entry yet; new BE endpoints simply unused (flag OFF) |
| Frontend first | New | Old | no | FE would call non-existent endpoints → deploy order forbids this (BE first) |
| Both deployed | New | New | yes | target state (flag-gated rollout) |
| Backend rollback | New | Old (rolled back) | no | roll FE flag OFF first, then BE (deploy-order-aware rollback) |
| Frontend rollback | Old (rolled back) | New | yes | BE endpoints unused; harmless |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
| BE | SystemPreference rollout/ai_agent_impact_report | flag | OFF | yes | ops/DB seed | no |
| BE | SystemPreference rollout/ai_agent_impact_report_forecast | flag | OFF | yes | ops/DB seed | no |
| BE | sidekiq-cron entry ai_activity_log_aggregator | cron | 0 1 * * * Asia/Jakarta | yes | config/schedule.yml | no |
| FE | subscription feature code for the report / AI add-on | entitlement | — | yes | BE subscription features[] (OQ-1) | no |
Detail 4.C — Test Plan (commands sourced from the repos)
| Layer | Command (source) | What it must prove |
|---|---|---|
| FE unit/integration | pnpm test — vitest 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 lint | pnpm lint (lint:ts + lint:prettier; CI runs pnpm run lint:ts, bitbucket-pipelines.yml) | ESLint clean |
| FE E2E | pnpm test:e2e — playwright test (source: package.json "test:e2e", tests/e2e/) | open report as owner → tiles; as agent → no entry; baseline-forming; cost save flow |
| BE unit | RAILS_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 lint | bundle exec rubocop (source: bitbucket-pipelines.yml:349) | RuboCop clean |
| BE migration | RAILS_ENV=test bundle exec rails db:migrate (source: bitbucket-pipelines.yml:56) | tables + unique indexes created; reversible |
| Cross-layer | Playwright hitting a seeded BE (report GET contract) | FE consumes snake_case payload; nullable tiles degrade; 403 hides entry |
Note:
chatbot-fehas notypecheckscript (verified) — type safety rides on ESLint; do not invent apnpm typecheckstep.
Detail 4.D — Agent Execution Plan
| Order | Layer | Chunk | Files to create/modify | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | BE | Migrations for ai_activity_logs + ai_cost_assumptions | db/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:migrate | both tables + unique indexes exist; db:rollback reverts cleanly |
| 2 | BE | Models + aggregation repository | app/models/ai_activity_log.rb, app/models/ai_cost_assumption.rb, app/core/repositories/ai_agent_impact/aggregate.rb | bundle exec rspec spec/core/repositories/ai_agent_impact | rspec: on a fixture org, net/gross correct; bot_preview/SPAM/bare RESOLVE excluded; journey sums to 100%; reopen self-join within 48h |
| 3 | BE | Aggregator worker + cron | app/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.rb | rspec: worker upserts one row per org-day; idempotent re-run; sentiment coverage recorded; missing sentiment excluded |
| 4 | BE | Cost-assumption endpoints | app/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/v1 | rspec: PUT owner/admin persists; supervisor/agent → 403; negative/non-numeric → 422 nothing saved |
| 5 | BE | Report endpoint + entitlement + role gate | app/api/frontend_service/v1/report.rb (GET ai_agent_impact) + entity; use-case .../ai_agent_impact/show.rb | bundle exec rspec spec/api/frontend_service/v1 | rspec: owner/admin/supervisor 200; agent 403; non-AI org 403; baseline_forming when <min; work_absorbed null when no assumption |
| 6 | BE | RuboCop + full suite | — | bundle exec rubocop; RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1 spec/core/repositories | lint clean; all new specs green |
| 7 | FE | Store + service + endpoint | store/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.ts | pnpm test | vitest: store action sets fetchStatus pending→resolved/rejected; service returns {fetch,controller} |
| 8 | FE | Middleware (flag + role) | middleware/ai-agent-impact-feature.ts | pnpm test | vitest: disabled flag/ineligible role → abortNavigation(404); eligible → passes |
| 9 | FE | Page + view + tiles + error/empty states | pages/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 keys | pnpm test | vitest: tile shows verdict not bare number; baseline-forming volume-only; error+retry renders; AI_AGENT_IMPACT_VIEWED fires on mount |
| 10 | FE | Journey bar + forecast + cost modal | .../BlendedJourneyBar.vue, .../ForecastPanel.vue, .../CostAssumptionModal.vue | pnpm test | vitest: journey segments sum 100% + plain labels; forecast dashed only when flag on; modal validation + owner/admin-only editor |
| 11 | FE | Lint + E2E | Reports nav link; tests/e2e/ai-agent-impact.spec.ts | pnpm lint; pnpm test:e2e | lint 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 rubocop2)RAILS_ENV=test bundle exec rails db:migrate3)RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1 spec/core/repositories - FE: 1)
pnpm lint2)pnpm test3)pnpm build4)pnpm test:e2e
- BE: 1)
- Post-deploy signals:
- Datadog
endpoint:ai_agent_impacterror rate < 1% and p95 < 3s over 15 min. - Mixpanel
impact_report_viewedcount > 0 within 1h of enabling an account. - Sidekiq:
AiActivityLogAggregatorWorkercompletes nightly with 0 dead-set entries. - Slack
#bot-ai-alertsshows noimpact_report_load_failed > 5%alert.
- Datadog
- Rollback (deploy-order-aware):
- Toggle
SystemPreference rollout/ai_agent_impact_reportOFF (FE entry + BE access gone; no deploy). - If forecast misbehaves only: toggle
ai_agent_impact_report_forecastOFF (rest of report stays live). - If a code revert is needed: revert FE PR first, then BE PR (FE depends on BE).
- Full teardown only:
rails db:rollbackthe two additive migrations (no user data lost; recomputable fromrooms). - Confirm Datadog error rate returns to baseline and Sidekiq queue drains.
- Toggle
Detail 4.F — Resource & Cost Notes
- Compute: negligible — one nightly batch job (
application_maintenancequeue) + 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_assumptions1 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
| # | Type | Question / limitation | Owner | Deadline |
|---|---|---|---|---|
| OQ-1 | Open Question | Exact 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 + BE | before chunk 5/8 |
| OQ-2 | Open Question | Canonical 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 team | before chunk 8 |
| OQ-3 | Open Question | Source 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 + BE | before chunk 2 |
| OQ-4 | Open Question / Risk | Does 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 + BE | 2026-07-18 |
| OQ-5 | Open Question | Confirm 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.firdausa | before chunk 7 |
| OQ-6 | Design gap | Figma 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 + Design | before Open Beta |
| OQ-7 | Assumption | Confirm plain-language copy language (Indonesian-first per persona) with PMM (no i18n framework exists — copy is hardcoded, Decision 8). | Dimas + PMM | before GA |
| OQ-8 | Process | Assign the infosec approver + BOT tech-lead approver (Metadata Approver row) before this RFC moves to AGREED. | Dimas | before AGREED |
| OQ-9 | Open 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 + BE | before chunk 2/3 |
| OQ-10 | Open 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 + BE | before chunk 10 |
| OQ-11 | Open 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 + BE | before Closed Beta |
| L-1 | Limitation | Sentiment 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-2 | Limitation | Bare 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-3 | Limitation | Aggregate 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-08 | rfc-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-08 | rfc-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; inboundn/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-reviewerfor a second-pass score.