RFC: Midtrans Native Integration — Phase 1 (P0 Core Actions)
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. Mark sections
N/A — reasonwhen truly inapplicable rather than deleting them.It is also agent-execution-ready: the §1 Design References (FE half) + §1 PRD-to-Schema Derivation (BE half), §2 Repo Reading Guide (Detail 2.0) for both layers, mermaid diagrams, the §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.
Delivery & project management live elsewhere. This RFC is the technical artifact only. Staffing, effort, timeline and rollout schedule live in the initiative's
delivery/folder. Until this RFC is handed to delivery, the Delivery row readsnot yet handed to delivery.The YAML frontmatter at the very top is the machine-readable index. The metadata table below is the human-readable governance record. Both must agree.
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | RFC (IDEA) | Human label; YAML status: carries the remapped linter enum draft |
| DRI | Dimas Fauzi Hidayat | Single accountable owner (frontmatter dri). Staffing lives in delivery/. |
| Team | chatbot | Advisory squad slug carried from the source PRD / initiative README |
| Author(s) | Dimas Fauzi Hidayat | Primary author |
| Reviewers | BOT Squad Backend Lead, BOT Squad Frontend Lead | Tech reviewers across affected layers |
| Approver(s) | BOT Squad Tech Lead, InfoSec Approver | Tech leaders + infosec |
| Submitted Date | 2026-06-20 | Date RFC opened for discussion |
| Last Updated | 2026-06-20 | Bump on every material edit |
| Target Release | 2026-Q2 | Quarter |
| Target Quarter | 2026-Q2 | Advisory, carried from source PRD |
| Delivery | not yet handed to delivery | Pointer to delivery/ artifacts once handed off |
| Related | ../prds/midtrans-phase-1-p0-core-actions.md, ../native-integration-anchor.md | Source PRD + initiative ANCHOR |
| Discussion | #bot-squad | Slack channel |
Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature
Sections at a Glance
- Overview (Design References — FE half, and PRD-to-Schema Derivation — BE half)
- Technical Design (Repo Reading Guide → topology → ADRs → diagrams → DDL → APIs → cross-layer contract)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- Ready for agent execution
⚠️ Critical grounding note — PRD ⇆ shipped-code reconciliation
The PRD §5/§13 describe BOT-4243 as delivering a
midtrans_oauthauth type with fieldsserver_key, client_key, environment, partner_id, merchant_id, a~15 minRedis TTL, and a token endpointPOST https://api.midtrans.com/v1/access-token. The code that actually shipped under BOT-4243 differs materially. The merged descriptorchatbot/config/auth_providers/midtrans_oauth2.yml(in thechatbotbackend repo) declares:
PRD claim Shipped reality (verified in code) auth_type key midtrans_oauthmidtrans_oauth2(registered inOrganizationConnection#auth_type_enum)fields server_key, client_key, partner_id, merchant_idenvironment,client_id,client_secret(no server/client-key, no partner/merchant id)token endpoint /v1/access-token…/v1/oauth/token(sandbox + production map)~15 minTTLttl_seconds: 3300(~55 min)(unspecified flow) oauth2_client_credentials(Basic-auth client id/secret → Bearer)This RFC grounds in the shipped code (the source of truth), not the PRD prose. The credential the AI Agent uses is a
midtrans_oauth2OrganizationConnectionwith{environment, client_id, client_secret}. The divergence is tracked as OQ-1 / OQ-2 in §5 and must be reconciled with the PM before the FE credential form copy is finalised. It does not block backend execution-layer work.
1. Overview
This RFC specifies the engineering design for Phase 1 (P0) of the Midtrans native integration: four AI-Agent-invokable payment actions — check payment status, create payment link, cancel transaction, and request refund — plus the Admin/SPV configuration surface that connects a Midtrans credential and exposes the actions in the Bot & Automation action drawer.
It sits under the Native Integration ANCHOR.
Midtrans is the inaugural payment provider. The OAuth/provider-token connectivity
foundation is already shipped (BOT-4243 midtrans_oauth2, BOT-4278 Google OAuth2,
BOT-4285 server-managed OAuth2); what remains is the action-executor layer and the
config UI, which this RFC delivers by reusing the existing node-execution runtime
(the same machinery that powers the generic API Integration and Google Sheets actions).
Success Criteria
- All four P0 actions execute end-to-end in a live conversation within the 60 s
budget (PRD §5), returning a natural-language result (or a WhatsApp CTA-URL button
for
create_payment_link). cancel_transactionandrequest_refundnever call the mutating Midtrans endpoint without (a) a status pre-check confirming eligibility and (b) an explicit customer confirmation.- A single customer confirmation maps to exactly one mutating POST (refund
idempotency via
refund_key+ in-conversation dedupe). - AI Agent Midtrans action success rate ≥ 95 % (PRD §11) — measured from the
midtrans_action_executed→midtrans_action_successevent ratio. - Net-new feature, flag-gated default OFF; zero change to existing action behavior.
Out of Scope
- Midtrans dashboard UI embedded in Qontak (PRD §4.1).
- Multiple Midtrans accounts per workspace — single connection only (PRD §4.2).
- Manual trigger by human agents — all P0 actions are AI-Agent-initiated (PRD §4.3).
- P1 billing/subscription actions and P2 advanced actions (PRD §4.4–§4.5).
- Inbound Midtrans webhooks / payment notification handling — out of scope; all four actions are outbound request/response. (PRD names no webhook receiver.)
- Re-implementing the OAuth/token foundation — already shipped under BOT-4243.
Related Documents
- PRD:
../prds/midtrans-phase-1-p0-core-actions.md(v1.2) - Initiative ANCHOR:
../native-integration-anchor.md - Midtrans public API docs (transaction status / cancel / refund; payment-links) — external, referenced per-action.
Assumptions
- The shipped
midtrans_oauth2descriptor + token manager (BOT-4243) are correct and theOrganizationConnectioncredential machinery is production-stable. - The standard Midtrans v2 transaction endpoints (
/v2/{order_id}/status|cancel|refund) and Payment Links v1 (/v1/payment-links) accept the Bearer access token minted by theoauth2_client_credentialsflow. (Confirm — OQ-3: Midtrans Core/v2 APIs historically use HTTP Basic withserver_key; if the v2 endpoints do not accept the OAuth2 client-credentials Bearer token, a secondmidtrans_basicauth descriptor is required. This is the single highest-risk assumption in the RFC.) - The AI Agent runtime resolves the customer-supplied
order_idfrom the conversation and passes it to the executor as an argument (the sameargumentschannel the API and Google Sheets executors already consume). - Plan entitlement (Sales Suite Plus / Ultimate / Qontak 360) is resolvable via the
existing
QontakBilling::GetSubscriptionfeature list.
Dependencies
| Dependency | Layer / Owner | Availability | Blocking? |
|---|---|---|---|
midtrans_oauth2 auth descriptor + Auth::TokenManager (BOT-4243) | BE / BOT Squad | exists — config/auth_providers/midtrans_oauth2.yml, lib/auth/token_manager.rb | No |
Auth::AuthorizedHttpClient (Bearer + redis cache + 401/403 retry) | BE / BOT Squad | exists — lib/auth/authorized_http_client.rb | No |
Node-execution runtime (NodeTypeRegistry, ActionExecutorFactory, NodeRegistry) | BE / BOT Squad | exists | No |
SendMessageInteractive (WhatsApp CTA-URL button) | BE / BOT Squad | exists — app/core/use_cases/system/hub/send_message_interactive.rb | No |
Mixpanel event pipeline (SendMixpanelEventWorker) | BE / BOT Squad | exists | No |
| FE action drawer + dynamic-field form + credential selector | FE / BOT Squad | exists — modules/bot-automation/** | No |
Midtrans sandbox credentials (client_id/client_secret) | PM / Midtrans | obtain in parallel | No |
| Confirmation that v2 endpoints accept the OAuth2 Bearer token (OQ-3) | BE / Midtrans docs | needs confirming | Yes — gates Chunk 3–6 |
Design References (frontend half)
The PRD §HEADER declares Figma Master: N/A — no UI changes in this phase (follows Google Calendar native integration pattern). The config surface is built by reusing the production dynamic-field action form + credential machinery, so there are no new design frames. Per the skill, this is recorded explicitly rather than fabricating frames.
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
MidtransActionConfigPanel (action config) | n/a — reuses existing action-config layout; no new frame (PRD §HEADER "Figma Master: N/A") | n/a | @mekari/pixel3 (chatbot-fe package.json) | BOT Squad design | Built from the generic CreateActionForm/ActionIntegrationForm rendering NodeRegistry properties[] |
MidtransAuthBlock (connect/reauthorize/disconnect) | n/a — design pending | n/a | @mekari/pixel3 | BOT Squad design | No connect/disconnect UI exists in production chatbot-fe today (verified). The credential is a midtrans_oauth2 OrganizationConnection created via /v1/api_connections. A minimal credential form is net-new FE work — see OQ-4. |
If a frame is required for the credential form, add it to §5 OQ-4 before starting the FE credential-form chunk; do not implement bespoke design against imagined frames.
PRD-to-Schema Derivation (backend half)
The backend half derives entities, attributes and business rules from the PRD as a domain spec. Phase 1 persists no new transaction data — Midtrans is the system of record for payments. The only persisted entities are the action configuration (
AiAgentAction), the action catalog (NodeRegistryrows), and the credential (OrganizationConnection, auth_typemidtrans_oauth2) — all of which already exist.
| PRD entity / attribute / rule | Persisted as (table.column) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
| 4 Midtrans action types appear in the action drawer | node_registries rows (node_type, node_type_group='midtrans', properties, categories, enabled) | GET /v1/node_registries | DB seed/migration + NodeRegistry model validation | PRD §6 |
| Action config (name, trigger desc, dynamic fields, credential) | ai_agent_actions (action_type, name, description, parameters, credentials) | POST/PUT /v1/ai_agent_tools | AiAgentAction model + ActionExecute use case | PRD §6, S01 |
Midtrans connection credential (environment, client_id, client_secret) | organization_connections (auth_type='midtrans_oauth2', auth_data encrypted) | POST/PUT /v1/api_connections | Auth::Registry GenericValidator + PayloadEncryptor (Lockbox) | PRD §5, S01 |
| Bearer token cached ~TTL, refreshed automatically | redis key auth:{env}:midtrans_oauth2:token:{credential.id} (TTL 3300 s) | (internal) Auth::TokenManager#fetch | lib/auth/token_stores/redis_store.rb | PRD §5 (TTL differs — see grounding note) |
| Check payment status (read-only) | none — read-through to Midtrans | executor Midtrans::Execute (midtrans_check_payment_status) → GET /v2/{order_id}/status | AuthorizedHttpClient | PRD §6 A1, S02 |
| Create payment link → URL as WhatsApp CTA-URL button | none — URL returned to conversation | executor → POST /v1/payment-links; result via SendMessageInteractive#send_button_message(link:) | executor + hub send use case | PRD §6 A2, S03 |
| Cancel only when status ∈ {pending, authorize}; confirm first | none (state read live from Midtrans) | executor (midtrans_cancel_transaction): status pre-check → confirm → POST /v2/{order_id}/cancel | executor branch logic | PRD §7 #3, S04 |
| Refund only when status = settlement; confirm first; idempotent | refund_key generated per attempt (passed in request body; not persisted in P1) | executor (midtrans_request_refund): eligibility check → confirm → POST /v2/{order_id}/refund | executor + in-conversation dedupe | PRD §7 idempotency, S05 |
| All actions flag-gated + plan-scoped | system_preferences (feature flag) + QontakBilling::GetSubscription features | flag check in ActionExecute/registry visibility | FeatureFlag.enabled? + subscription gate | PRD §5, §9 |
| Observability events | none (emitted to Mixpanel) | SendMixpanelEventWorker.perform_async(org_id, '<event>', props) | executor + hub | PRD §10 |
Every §2.3 DDL row and §2.4 endpoint traces back to a row here. Where a PRD entity has no persistence (transaction state lives in Midtrans), it is marked "none — read-through".
Detail 1.A — PRD Traceability Matrix (cross-layer)
Cite PRD composite AC ids (<STORY-ID>/AC-n, ERR-n, NEG-n).
Forward (PRD AC → RFC):
| PRD composite AC id | FE section / component | BE section / endpoint |
|---|---|---|
MIDTRANS-S01/AC-1 | §2.A Action config panel (action-type selector → form) | GET /v1/node_registries (4 midtrans rows) |
MIDTRANS-S01/AC-2 | §2.A MidtransAuthBlock connect → credential created | POST /v1/api_connections (auth_type midtrans_oauth2); validate = token fetch |
MIDTRANS-S01/AC-3 | §2.A Save | POST /v1/ai_agent_tools |
MIDTRANS-S01/AC-4 | §2.A Reauthorize | PUT /v1/api_connections/:id (re-encrypt auth_data, invalidate token cache) |
MIDTRANS-S01/ERR-1 | §3.C FE error copy | token-fetch failure → midtrans_action_failed reason auth_error |
MIDTRANS-S01/ERR-2 | §3.C "connection required" | Save validation |
MIDTRANS-S01/ERR-3 | §3.C load error + Retry | GET /v1/api_connections/:id failure |
MIDTRANS-S02/AC-1..3, ERR-1..3 | §2.C conversation states | Midtrans::Execute midtrans_check_payment_status → GET /v2/{order_id}/status |
MIDTRANS-S03/AC-1..3, ERR-1..3 | §2.C; CTA-URL button | midtrans_create_payment_link → POST /v1/payment-links + send_button_message |
MIDTRANS-S04/AC-1..3, ERR-1..4 | §2.C confirmation prompt | midtrans_cancel_transaction (status pre-check → confirm → POST /v2/{order_id}/cancel) |
MIDTRANS-S05/AC-1..3, ERR-1..4 | §2.C confirmation prompt | midtrans_request_refund (eligibility → confirm → POST /v2/{order_id}/refund) |
NEG-1 | §2.A single-connection (no "add another") | organization_connections uniqueness per org |
NEG-2 | §2.C read-only result, no re-trigger control | no manual-trigger endpoint |
NEG-3 | §2.A only 4 P0 rows in selector | only 4 enabled node_registries rows |
NEG-4 | §3 web-only gate | n/a — config endpoints unchanged |
Reverse (RFC → PRD AC):
| New FE component / BE endpoint / dependency | PRD composite AC id it serves |
|---|---|
Midtrans::Execute executor (4 node types) | MIDTRANS-S02..S05/AC-* |
4 node_registries rows (node_type_group='midtrans') | MIDTRANS-S01/AC-1, NEG-3 |
midtrans_oauth2 credential create/edit FE form | MIDTRANS-S01/AC-2, AC-4, NEG-1 |
| Confirmation + status-precheck branch logic | MIDTRANS-S04/AC-1..2, MIDTRANS-S05/AC-1..2 |
refund_key + in-conversation dedupe | PRD §7 idempotency note (no dedicated AC — RFC-introduced) |
| Mixpanel event emission | PRD §10 (observability) |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
Action config panel (/bot-automation/actions) | web (Admin/SPV) | GET /v1/node_registries, GET /v1/api_connections | POST/PUT /v1/ai_agent_tools, POST/PUT /v1/api_connections | NewActionDrawer → CreateActionForm/ActionIntegrationForm | credential connected/not-connected (presence of midtrans_oauth2 connection) |
| In-conversation result (WhatsApp + other) | chatbot (customer) | n/a — fully covered by executor result | n/a (Midtrans is external) | hub message render (CTA-URL button / text) | Midtrans transaction status (read live) |
| Mobile Bot & Automation | mobile | n/a — config menu not rendered (web-only) | n/a | n/a (NEG-4) | n/a |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| Admin / SPV | Existing Bot & Automation auth (JWT/session) + plan entitlement + flag | GET/POST/PUT /v1/node_registries (read), /v1/api_connections, /v1/ai_agent_tools | Config drawer visible (eligible plan + flag ON, web) | No — organization_id scoped | paper_trail on OrganizationConnection, AiAgentAction |
| AI Agent (runtime) | Internal service auth; executes configured action | internal ActionExecute use case (no public route) | n/a (not a UI actor) | No — organization_id scoped | Mixpanel events |
| CS Agent / human | read-only | none (no manual-trigger route exists) | result is read-only in conversation (NEG-2) | No | n/a |
PRD Section Coverage
| PRD § | Title | Where covered |
|---|---|---|
| HEADER | Header block | Metadata |
| 2 | One-liner + Problem | §1 Overview |
| 3 | Target Users + Persona | §1 (Role Coverage) |
| 4 | Non-Goals | §1 Out of Scope |
| 5 | Constraints | §1 Dependencies, §3 (perf/security), §4 (flag) |
| 6 | New Features (4 actions + config) | §1 PRD-to-Schema, §2.A, §2.4 |
| 7 | API & Webhook Behavior | §2.2 sequences, §3.A failure catalog, §2.D integrity (idempotency) |
| 8 | System Flow + Stories + ACs | §1 Detail 1.A/1.C, §2.2 |
| 9 | Rollout | §4 Rollout Strategy |
| 10 | Observability | §3 Monitoring & Alerting |
| 11 | Success Metrics | §1 Success Criteria, §3 monitoring |
| 12 | Launch Plan & Stage Gates | n/a — delivery/ artifact (rollout schedule out of RFC scope); gate signals in §4 |
| 13 | Dependencies | §1 Dependencies |
| 14 | Key Decisions + Alternatives | §1 Detail 1.B, §2 Technical Decisions |
| 15 | Open Questions | §5 |
Detail 1.B — Decisions Closed (cross-layer)
| Decision | Chosen option | Alternatives rejected | Why rejected | Layer |
|---|---|---|---|---|
| Action executor structure | One group executor Midtrans::Execute (node_type_group='midtrans') dispatching on node_type | 4 separate executor classes | Mirrors GoogleSheets::Execute (3 node types, one class); shared auth/HTTP/error code | BE |
| Outbound HTTP seam | Auth::AuthorizedHttpClient | Raw ::Http + manual token | AuthorizedHttpClient already does Bearer + redis cache + 401/403 retry-once | BE |
| Auth credential model | Reuse shipped midtrans_oauth2 OrganizationConnection | New Midtrans-specific credential table | Foundation shipped (BOT-4243); descriptor-driven, encrypted, cached | BE |
| Auth header for v2 calls (Decision 8) | Default Bearer (midtrans_oauth2); spec'd midtrans_basic fallback | Bearer-only (assume it works); Basic-only | Both Midtrans conventions are real; executor is auth-agnostic via AuthorizedHttpClient, so it's a config swap (REV-1/OQ-3) | BE |
| Sync vs async execution | Synchronous (AI Agent awaits result ≤60 s) | Queue the action call | PRD requires in-conversation result within 60 s; queueing adds latency + a result-delivery callback | BE |
| Cancel/refund safety | Status pre-check + explicit customer confirmation before mutating call | Direct mutate | Irreversible actions; PRD §14 decision | BE |
| Refund idempotency | refund_key per attempt + in-conversation dedupe window | Rely on Midtrans dedup | Refund is not naturally idempotent; flaky WhatsApp can double-confirm (PRD §7) | BE |
| FE config form | Reuse data-driven CreateActionForm/ActionIntegrationForm (NodeRegistry properties[] + credential selector) | Build bespoke MidtransActionConfigPanel tree per PRD §6 | Production already renders dynamic fields from registry; bespoke duplicates it. Reconciled with PRD as OQ-4. | FE |
| Feature flag | FeatureFlag.enabled?('rollout','midtrans_native_action') (proposed name) | Per-account env var | Matches existing rollout-flag pattern (app/core/repositories/system_preferences/rollout/) | both |
| Transaction persistence | None — read-through to Midtrans | Mirror transactions locally | Midtrans is system of record; PRD persists no transactions | BE |
| Inbound webhook | None this phase | Build notification receiver | PRD names no webhook; all 4 actions are request/response | BE |
Detail 1.C — Per-Story Change Map
| Story id | Title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
MIDTRANS-S01 | Admin configures a Midtrans action | FE + BE | drawer rows for 4 actions (auto from registry); credential connect/reauthorize/disconnect form (midtrans_oauth2); Save wires ai_agent_tools | 4 node_registries rows (properties[]); credential create/update via /v1/api_connections; token-fetch validation on connect | S01/AC-1..4, ERR-1..3, NEG-1 | rspec: connection create encrypts auth_data; vitest: form renders 4 actions, Save disabled until connected; connect with bad creds → error copy | §2.A · §2.4 · §4.D chunk 1,2,7 · §1 PRD-to-Schema rows 1–4 |
MIDTRANS-S02 | AI Agent checks payment status | BE-only | n/a — result rendered by existing hub text path | Midtrans::Execute#midtrans_check_payment_status → GET /v2/{order_id}/status; prompt for order_id if missing; events | S02/AC-1..3, ERR-1..3 | rspec (webmock): 200 → status mapped to NL; 404 → "not found"; >60 s → timeout copy + midtrans_action_failed reason timeout | §2.2 seq-1 · §2.4 · §4.D chunk 3 |
MIDTRANS-S03 | AI Agent creates a payment link | BE-only (+ existing hub) | n/a — uses existing CTA-URL button render | midtrans_create_payment_link → POST /v1/payment-links; WhatsApp → send_button_message(link:), else text; events | S03/AC-1..3, ERR-1..3 | rspec (webmock): 201 → URL extracted; WhatsApp path calls send_button_message; 400 → invalid copy + reason invalid_request | §2.2 seq-2 · §2.4 · §4.D chunk 4 |
MIDTRANS-S04 | AI Agent cancels a transaction (confirm) | BE-only | n/a — confirmation prompt is conversation text | midtrans_cancel_transaction: status pre-check → if pending/authorize confirm → POST /v2/{order_id}/cancel; else not-cancellable copy, no POST | S04/AC-1..3, ERR-1..4 | rspec: non-cancellable status → cancel endpoint NOT hit (webmock asserts 0 calls); decline → "not cancelled"; confirm → success | §2.1 state · §2.2 seq-3 · §4.D chunk 5 |
MIDTRANS-S05 | AI Agent requests a refund (confirm) | BE-only | n/a — confirmation prompt is conversation text | midtrans_request_refund: eligibility (settlement) → confirm → POST /v2/{order_id}/refund with refund_key; dedupe one confirm→one POST | S05/AC-1..3, ERR-1..4 | rspec: non-eligible → refund NOT hit; duplicate confirm within window → single POST (idempotency); confirm → success | §2.1 state · §2.2 seq-4 · §2.D · §4.D chunk 6 |
Coverage rule satisfied: every PRD story has exactly one row.
FE + BErows (S01) have both columns filled; S02–S05 areBE-only(results render through existing hub paths).
2. Technical Design
Infrastructure Topology
Deployment topology
flowchart TB
customer([Customer / WhatsApp etc.]) -->|inbound msg| channel[Channel gateway]
admin([Admin / SPV browser]) -->|HTTPS| lb[Ingress / Load Balancer]
lb -->|HTTP| fe["chatbot-fe (Nuxt SSR pods)"]
fe -->|REST| be["chatbot API pods (Rails / Grape) ×N"]
channel --> be
be -->|read/write encrypted creds| db[(Postgres primary)]
be -->|read| db_r[(Postgres replica)]
be -->|token cache get/set TTL 3300s| redis[(Redis — auth token store)]
be -->|enqueue| sidekiq[[Sidekiq queue]]
sidekiq --> worker["chatbot worker pods (Sidekiq) ×M"]
worker -->|track event| mixpanel(["Mixpanel (events)"])
be -->|HTTPS Bearer| midtrans(["Midtrans API\n/v2/* + /v1/payment-links + /v1/oauth/token"])
worker -->|HTTPS send| channel
Per-service responsibility
flowchart LR
subgraph chatbot["chatbot (Rails) — BOT squad"]
ae["ActionExecute use case\n(internal_service/v1/ai_agent)"]
me["Midtrans::Execute\n(4 node types)"]
tm["Auth::TokenManager + AuthorizedHttpClient"]
hub["SendMessageInteractive\n(CTA-URL button)"]
cred["api_connections (OrganizationConnection CRUD)"]
reg["node_registries (action catalog)"]
end
ae --> me
me -->|"AuthorizedHttpClient.call"| tm
tm -->|"HTTPS Bearer"| midtrans(["Midtrans API"])
tm -->|"GET/SET token"| redis[(Redis)]
me -->|"result"| hub
hub -->|"async send"| channel(["Channel gateway"])
me -->|"perform_async"| mix(["SendMixpanelEventWorker → Mixpanel"])
cred -->|"encrypt auth_data (Lockbox)"| pg[(Postgres)]
reg --> pg
Technical Decisions (ADR-format)
Decision 1: Single group executor Midtrans::Execute over four classes
Context — Four P0 actions share the same credential, HTTP seam, error mapping and
event emission. The runtime resolves an executor per node_type via
ActionExecutorFactory.for(action_type), which maps NodeRegistry#node_type_group →
NodeTypeRegistry::GROUP[group] class.
Options considered
- Option A — one
Midtrans::Execute(groupmidtrans) dispatching on@node_type.- Pros: mirrors
GoogleSheets::Execute(handlesgoogle_sheet_row_get/create/update); one place for shared auth/HTTP/error/event code; one registry-group entry. - Cons: a single class branches on 4 node types (slightly larger file).
- Pros: mirrors
- Option B — four executor classes, four
NodeTypeRegistry::GROUPentries.- Pros: smaller classes.
- Cons: 4× duplicated auth/HTTP/error/event boilerplate; diverges from the established group pattern.
Decision: Option A.
Rationale — Directly follows the shipped GoogleSheets::Execute precedent
(app/core/repositories/node_executions/nodes/google_sheets/execute.rb dispatches on
@node_type). Shared confirmation/idempotency logic lives once.
Consequences — Midtrans::Execute owns a case @node_type dispatch; each P0 action
is a private method. Adding P1/P2 actions extends the same class/group.
Reversibility — Splitting into per-action classes later is mechanical: add
NodeTypeRegistry::GROUP entries and move methods. Low cost.
Decision 2: Outbound calls via Auth::AuthorizedHttpClient
Context — Every Midtrans call needs a valid Bearer token; tokens are minted via the
oauth2_client_credentials flow and cached in Redis (TTL 3300 s). On 401/403 the cache
must be invalidated and the call retried.
Options considered
- Option A —
Auth::AuthorizedHttpClient.call(credential:, request:).- Pros: already builds auth headers via
Auth::Registry.lookup(auth_type), applies the descriptor's bearer strategy, retries once on 401/403, returns aDry::Monads::Result. Zero token handling in the executor. - Cons: returns
Failure([:auth_failed|:http_error, …])monads the executor must unwrap.
- Pros: already builds auth headers via
- Option B — raw
::Http+ manualTokenManager#fetch.- Pros: full control of timeout/headers.
- Cons: re-implements token fetch, caching, and 401/403 retry already centralised.
Decision: Option A.
Rationale — lib/auth/authorized_http_client.rb is documented as "the single seam for
outbound HTTP requests that need an OrganizationConnection credential's auth applied." It
is exactly the integration point for credentialed third-party calls.
Consequences — Executor maps Failure([:auth_failed, _]) → auth_error event and
Failure([:http_error, _]) → timeout/api_error. The 60 s budget is enforced via the
underlying ::Http timeout (see Decision 3).
Reversibility — Swapping to raw ::Http is contained to the executor's request helper.
Decision 3: 60 s timeout strategy
Context — PRD §5: an action must complete within 60 s; the default ::Http open/read
timeout is 20 s (lib/http.rb). ::Http raises 60 s only when the tag includes qontak,
or honours ENV['VENDOR_API_TIMEOUT'] as an upper bound.
Options considered
- Option A — pass explicit
open_timeout: 60, read_timeout: 60throughAuthorizedHttpClientcall_optionsto the underlying::Http#call.- Pros: per-call, explicit, matches PRD 60 s.
- Cons: must confirm
call_optionsis forwarded to::Http#call(verify insend_request).
- Option B — set
ENV['VENDOR_API_TIMEOUT']=60globally.- Cons: affects all vendor calls; too broad.
Decision: Option A (per-call 60 s via call_options), with the cancel/refund
status pre-check + mutate both counted inside the 60 s budget for the user-facing turn.
Rationale — Localises the timeout to Midtrans calls without changing global behavior.
Consequences — On Net::OpenTimeout/ReadTimeout, ::Http returns synthetic 504/408;
the executor maps these to the PRD timeout copy + midtrans_action_failed reason timeout.
Reversibility — Trivial — change the numeric option.
Decision 4: Refund idempotency — refund_key + in-conversation dedupe
Context — Cancel is naturally idempotent (the status pre-check rejects a second cancel on an already-cancelled order). Refund is not; a flaky WhatsApp connection can deliver a duplicated "yes", risking a double refund POST (PRD §7).
Options considered
- Option A — generate a stable
refund_keyper logical refund + a short dedupe window keyed on(conversation_id, order_id)in Redis (e.g. 120 s) so a re-delivered confirm maps to the samerefund_keyand is suppressed before the POST.- Pros: defends both at the Midtrans layer (refund_key dedup) and locally (no second POST).
- Cons: needs a Redis dedupe entry + TTL.
- Option B — rely on Midtrans to reject duplicate
refund_key.- Cons: still emits a second POST; depends on Midtrans behavior; no local guard.
Decision: Option A.
Rationale — Belt-and-braces: the in-conversation dedupe prevents the second POST; the
refund_key makes the Midtrans call itself idempotent if a POST does slip through.
Consequences — A Redis key midtrans:refund:dedupe:{conversation_id}:{order_id} (TTL
~120 s) gates the POST; refund_key (UUID per logical refund, stored in the dedupe value)
is sent in the request body. P0 default: full refund only (amount omitted) unless
OQ-5 decides otherwise.
Reversibility — Removing dedupe is a code deletion; refund_key remains harmless.
Decision 5: Synchronous execution; events async
Context — The AI Agent must deliver a result in the same conversational turn within 60 s.
Decision — The action call is synchronous within ActionExecute. Only
observability events are async (SendMixpanelEventWorker.perform_async), and the
WhatsApp send goes through the existing SendMessageInteractiveWorker (Sidekiq) — i.e. the
result delivery uses the platform's normal async send path, but the Midtrans call/result
computation is synchronous.
Rationale — Queueing the Midtrans call would require a callback to resume the agent turn; the 60 s budget makes synchronous request/response the correct model.
Consequences — A pod is blocked up to 60 s per in-flight Midtrans action. Sized by concurrency expectations (low volume P0; target ≥5 CIDs). Reversibility: high.
Decision 6: Feature flag + plan gating
Context — PRD §5/§9: flag default OFF; plans Sales Suite Plus / Ultimate / Qontak 360. OQ-1 leaves the flag name TBD.
Decision — Use FeatureFlag.enabled?('rollout', 'midtrans_native_action', default: false)
(app/core/repositories/system_preferences/feature_flag.rb), proposed group/code
rollout / midtrans_native_action, plus plan entitlement via
QontakBilling::GetSubscription feature list. Both checked at (a) registry visibility for
the FE drawer and (b) ActionExecute before dispatch.
Rationale — Matches the existing rollout-flag directory pattern. Reversibility — Flag delete + entitlement check removal.
Decision 7: Multi-tenancy isolation
Context — Credentials and actions are per-organization.
Decision — OrganizationConnection and AiAgentAction are organization_id-scoped
(existing model scoping); Midtrans::Execute receives organization_id and resolves the
credential within that org. Token cache key includes credential.id.
Rationale / Consequences / Reversibility — Inherits the platform's existing tenant scoping; no new isolation mechanism. n/a to reverse.
Decision 8: Auth header strategy for Midtrans calls — Bearer, with a fully-specified Basic fallback
Context — The shipped midtrans_oauth2 descriptor mints an OAuth2 Bearer token. But
Midtrans's Core/v2 transaction APIs (/v2/{order_id}/status|cancel|refund) and Payment Links
v1 are historically authenticated with HTTP Basic (server_key as username, empty
password). It is unconfirmed whether the OAuth2 Bearer token is accepted on /v2/*
(OQ-3/REV-1). Rather than leave this dangling, both paths are specified so the agent has a
deterministic plan and only needs a one-line confirmation before wiring the live header.
Options considered
- Option A — Bearer via existing
midtrans_oauth2(AuthorizedHttpClientapplies it automatically).- Pros: zero new auth code; reuses the shipped descriptor + redis cache + 401/403 retry.
- Cons: only correct if
/v2/*accepts the OAuth2 token.
- Option B — Basic via a new
midtrans_basicdescriptor (server_keyas Basic user).- Pros: matches documented Midtrans Core/v2 auth; no token mint round-trip.
- Cons: a second credential field set (
server_key) the Admin must supply; new descriptor file.
Decision — Default to Option A; specify Option B as a drop-in fallback selected by a
single confirmation (REV-1). The executor is auth-agnostic because it calls
AuthorizedHttpClient.call(credential:, request:) — switching auth is a descriptor + credential
swap, not an executor change. If OQ-3 resolves to Basic, add this descriptor and point the
action's credential at it:
# config/auth_providers/midtrans_basic.yml (ONLY if OQ-3 confirms /v2 rejects Bearer)
key: midtrans_basic
display_name: Midtrans (Basic — server key)
fields:
- { name: environment, required: true, enum: [sandbox, production] }
- { name: server_key, required: true, secret: true }
header_strategy: basic_auth # server_key as username, blank password (Base64)
header_config:
username_field: server_key
password_value: ""
storage: none # no token to cache; Basic header built per request
Rationale — Both Midtrans auth conventions are real; which applies per-endpoint is the only
unknown. Pinning both removes the design risk; the AuthorizedHttpClient seam (Decision 2)
makes the choice a config swap. The FE credential form (§2.A) collects whichever field set the
chosen descriptor declares.
Consequences — If Basic is chosen, the PRD's server_key field (OQ-2) becomes correct after
all, and the credential form shows server_key instead of client_id/client_secret. The
executor, registry rows, events, and confirmation logic are unchanged either way.
Reversibility — Trivial: swap the credential's auth_type (and re-collect fields); no
executor code change.
Minimum-coverage check: storage (D7/none-new), sync/async (D5), caching (D2 redis TTL), third-party integration (D2), consistency (read-through, no local transaction state), multi-tenancy (D7), reuse-vs-new (D1/D2/D3 — all reuse; only
Midtrans::Execute+ 4node_registriesrows are new).
Detail 2.0 — Repo Reading Guide
Repo Map (mermaid, both layers)
flowchart LR
subgraph fe["chatbot-fe (Nuxt/Vue)"]
drawer["modules/bot-automation/components/drawers/NewActionDrawer.vue"]
forms["forms/CreateActionForm.vue + ActionIntegrationForm.vue"]
store["store/ai-agent-tool/ + store/ai-agent/"]
svc["common/services/main/v1/ai-agent-tools.ts + ai-agents.ts"]
end
subgraph be["chatbot (Rails)"]
exec["node_executions/action_executor_factory.rb + node_type_registry.rb"]
nodes["node_executions/nodes/midtrans/execute.rb (NEW)"]
auth["lib/auth/authorized_http_client.rb + token_manager.rb"]
cfg["config/auth_providers/midtrans_oauth2.yml"]
api["api/frontend_service/v1/{node_registries,api_connection,ai_agent_tool}"]
hub["use_cases/system/hub/send_message_interactive.rb"]
end
subgraph infra
pg[(Postgres)]
redis[(Redis)]
end
svc --> api
api --> exec --> nodes --> auth --> cfg
auth --> redis
nodes --> hub
api --> pg
Existing Code Anchors
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE | app/core/repositories/node_executions/nodes/google_sheets/execute.rb | Closest precedent: a group executor over multiple node types using a credential | include NodeExecutorInterface + ArgumentInterpolation; dispatch on @node_type; credential[:id] = OrganizationConnection id |
| BE | app/core/repositories/node_executions/nodes/api/execute.rb | Generic REST action — interpolation, credential lookup, {status, code, body, header} result | Result shape + credential resolution |
| BE | app/core/repositories/node_executions/node_type_registry.rb | Where to register the new midtrans group | GROUP = { 'google_sheets' => 'GoogleSheets::Execute', … } |
| BE | app/core/repositories/node_executions/action_executor_factory.rb | How a node_type resolves to an executor | NodeRegistry.find_by_type_and_version(action_type)&.node_type_group → GROUP |
| BE | app/core/repositories/node_executions/nodes/node_executor_interface.rb | Executor contract | initialize(organization_id:, credential:, parameters:, arguments:, room_id:, node_type:), call, before_execute, after_execute |
| BE | lib/auth/authorized_http_client.rb | The HTTP seam for credentialed calls | call(credential:, request:) → Success(resp) / `Failure([:auth_failed |
| BE | config/auth_providers/midtrans_oauth2.yml | The shipped Midtrans descriptor (ground truth) | client_credentials, Bearer, redis, ttl_seconds: 3300, env→token_url map |
| BE | lib/auth/token_manager.rb + lib/auth/token_stores/redis_store.rb | Token mint + cache | fetch with lock; redis key auth:{env}:midtrans_oauth2:token:{credential.id} |
| BE | app/models/organization_connection.rb | Credential model + auth_type_enum (contains midtrans_oauth2) | encrypted auth_data, paper_trail, org scoping |
| BE | app/api/frontend_service/v1/node_registries/node_registries_controller.rb | Action catalog endpoint that feeds the FE drawer | GET /v1/node_registries |
| BE | app/api/frontend_service/v1/api_connection/ (oauth_start.rb, create.rb, update.rb, encrypt_auth_data.rb) | Credential CRUD + encryption | PayloadEncryptor.encrypt; token-cache invalidation on secret rotation |
| BE | app/core/use_cases/system/hub/send_message_interactive.rb | CTA-URL button render | send_button_message(header_format:, body_text:, button_actions:, link:) |
| BE | app/workers/send_mixpanel_event_worker.rb | Event emission | MIXPANEL_TRACKER.track(distinct_id, event_name, params) |
| BE | app/core/repositories/system_preferences/feature_flag.rb | Flag check | FeatureFlag.enabled?(group_code, code, default:) |
| BE | app/core/repositories/qontak_billing/get_subscription.rb | Plan entitlement | GetSubscription.new(params:).call → {package_name, features:[{code, enabled}]} |
| FE | modules/bot-automation/components/drawers/NewActionDrawer.vue | Action drawer switch + selection | formAction state; ActionSelection → form |
| FE | modules/bot-automation/components/forms/CreateActionForm.vue + ActionIntegrationForm.vue | Dynamic-field config form + credential selector | renders requiredItem.properties[] by prop.html.element; credential autocomplete |
| FE | modules/bot-automation/constants/bot-automation-actions-constants.ts | ActionListItem shape, ACTION_LIST | action list item structure (node_type, settings.action_config) |
| FE | common/services/main/v1/ai-agents.ts (fetchActionList → /v1/node_registries) + ai-agent-tools.ts | API client | mainService.aiAgentTools.{get,create,detail,update,delete} → /v1/ai_agent_tools |
| FE | store/ai-agent-tool/ (Pinia) | State for create/update tool | CREATE_AI_AGENT_TOOL action pattern |
| FE | middleware/nlp-feature.ts | Plan/feature gating precedent | subscriptionData.features.find(code===…)?.enabled |
Existing Contracts to Reuse, Extend, or Replace (BE)
| Contract (endpoint / table / event) | Status | Justification | Owner |
|---|---|---|---|
GET /v1/node_registries | reused | Action catalog feeds the FE drawer; add 4 enabled rows | BOT |
POST/PUT/GET /v1/api_connections | reused | Credential CRUD for midtrans_oauth2 connection | BOT |
POST/PUT/GET /v1/ai_agent_tools | reused | Action-instance CRUD (AiAgentAction) | BOT |
internal ActionExecute use case | extended | Add flag/plan gate + dispatch to Midtrans::Execute | BOT |
NodeTypeRegistry::GROUP | extended | Add 'midtrans' => 'Midtrans::Execute' | BOT |
node_registries rows (4 midtrans) | new-with-justification | New action catalog entries; no existing rows describe Midtrans P0 actions | BOT |
Midtrans::Execute executor | new-with-justification | No executor exists for Midtrans; the API/Sheets executors are different node types | BOT |
Redis dedupe key midtrans:refund:dedupe:* | new-with-justification | Refund idempotency guard (Decision 4); no existing equivalent | BOT |
Mixpanel events midtrans_* | new-with-justification | PRD §10 names net-new events; no existing midtrans events | BOT |
Patterns to Follow
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| BE | Executor shape | include NodeExecutorInterface + keyword initialize + call | nodes/google_sheets/execute.rb | none |
| BE | Argument interpolation | include Concerns::ArgumentInterpolation | node_executions/concerns/argument_interpolation.rb | none |
| BE | Credentialed HTTP | AuthorizedHttpClient.call(credential:, request:) returning monads | lib/auth/authorized_http_client.rb | none |
| BE | Result shape | { status: 'success'|'error', code:, body:, … } | nodes/api/execute.rb | none |
| BE | Error/logging | Rails.logger.error("[Auth]…") + Rollbar.warning/error | authorized_http_client.rb, token_manager.rb | none |
| BE | Events | SendMixpanelEventWorker.perform_async(org_id, name, props) | app/workers/send_mixpanel_event_worker.rb | none |
| BE | Feature flag | FeatureFlag.enabled?(group, code, default:) | system_preferences/feature_flag.rb | none |
| BE | Secrets encryption | PayloadEncryptor.encrypt (Lockbox) | lib/auth/payload_encryptor.rb | none |
| FE | Dynamic field form | render properties[] by html.element | forms/ActionIntegrationForm.vue | none |
| FE | API service | mainService.<resource>.<method> (ofetch) | common/services/main/v1/ai-agent-tools.ts | none |
| FE | State | Pinia store slice (state/getters/actions) | store/ai-agent-tool/ | none |
| Cross | snake_case API ↔ camelCase FE | services map payload casing | common/services/main/v1/*.ts | none |
Reading Order for the Agent
config/auth_providers/midtrans_oauth2.yml— the real Midtrans auth contract (ground truth).lib/auth/authorized_http_client.rb— the HTTP seam Midtrans calls use.app/core/repositories/node_executions/nodes/google_sheets/execute.rb— the executor pattern to copy.app/core/repositories/node_executions/node_type_registry.rb+action_executor_factory.rb— registry wiring.app/core/repositories/node_executions/nodes/node_executor_interface.rb— the executor contract.app/api/internal_service/v1/ai_agent/use_cases/action_execute.rb— how executors are invoked.app/core/use_cases/system/hub/send_message_interactive.rb— CTA-URL button render.app/api/frontend_service/v1/node_registries/node_registries_controller.rb— action catalog.modules/bot-automation/components/forms/ActionIntegrationForm.vue— FE dynamic-field form.common/services/main/v1/ai-agents.ts+ai-agent-tools.ts— FE API contract.
Source Verification (anti-hallucination — required)
| Layer | Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|---|
| BE | config/auth_providers/midtrans_oauth2.yml | read | key: midtrans_oauth2; strategy: oauth2_client_credentials; ttl_seconds: 3300; token_url_map sandbox …/v1/oauth/token; fields environment, client_id, client_secret |
| BE | OrganizationConnection#auth_type_enum | read | returns %w[… midtrans_oauth2 google_oauth2] (lines 22–25 of app/models/organization_connection.rb) |
| BE | NodeTypeRegistry::GROUP | read | GROUP = { 'api'=>'API::Execute', 'mekari_qontak_crm'=>…, 'google_sheets'=>'GoogleSheets::Execute' } |
| BE | ActionExecutorFactory.for | read | resolves NodeRegistry.find_by_type_and_version(action_type)&.node_type_group → GROUP[group], constantize under PREFIX='Repositories::NodeExecutions::Nodes' |
| BE | NodeExecutorInterface | read | documented initialize(organization_id:, credential:, parameters:, arguments:, room_id:, node_type:) + call |
| BE | GoogleSheets::Execute | read | include NodeExecutorInterface/ArgumentInterpolation; dispatches on @node_type (google_sheet_row_get/create/update); credential[:id] = OrganizationConnection id |
| BE | AuthorizedHttpClient.call | read | def self.call(credential:, request:, http_client:, max_retries: 1, call_options:); AUTH_FAILURE_CODES=%w[401 403]; returns `Success/Failure([:auth_failed |
| BE | token cache | read | redis_store.rb key auth:#{Rails.env}:#{descriptor.key}:token:#{credential&.id}; TTL capped [60s, 24h] |
| BE | node_registries schema | read | db/schema.rb:1219 table with node_type, node_type_group ("Maps to an executor class via NodeTypeRegistry::GROUP"), properties jsonb, categories jsonb, enabled |
| BE | GET /v1/node_registries | grep | api.rb:51 mount V1::NodeRegistries::NodeRegistriesController => '/v1/node_registries' |
| BE | /v1/api_connections | grep | api.rb:28 mount V1::APIConnection => '/v1/api_connections' |
| BE | /v1/ai_agent_tools | grep | api.rb:46 mount V1::AiAgentTool::AiAgentToolsController => '/v1/ai_agent_tools' |
| BE | CTA-URL button | read | send_message_interactive.rb send_button_message(header_format:, header_text:, body_text:, button_actions:, link:, filename:) |
| BE | events | read | send_mixpanel_event_worker.rb MIXPANEL_TRACKER.track(distinct_id, event_name, params) |
| BE | feature flag | read | feature_flag.rb FeatureFlag.enabled?(group_code, code, default: false) |
| BE | plan entitlement | read | get_subscription.rb returns {package_name, features:[{code, enabled, …}]} |
| BE | secrets encryption | read | payload_encryptor.rb encrypt → "v#{VERSION}:#{base64}" via $lockbox.encrypt |
| BE | test commands | read | bitbucket-pipelines.yml:74 RAILS_ENV=test bundle exec rspec …; :144 … spec/api/internal_service …; .rspec --require spec_helper |
| FE | drawer | read | NewActionDrawer.vue formAction switch (selection → ApiIntegration/CreateAction) |
| FE | dynamic form | read | ActionIntegrationForm.vue loops requiredItem.properties[] by prop.html.element (select/input-text/textarea/date/…) + credential autocomplete |
| FE | API client | read | ai-agents.ts fetchActionList() → endpoint.v1.ai_agents.actions.get (/v1/node_registries); ai-agent-tools.ts CRUD → /v1/ai_agent_tools |
| FE | no Google Calendar / connect UI in production | read | chatbot-fe has only SOURCE_ICON["Google Calendar"] icon (utils/actionIconConfig.ts); the ACTION_GROUPS/Google-Calendar drawer row + connect block exist only in qontak-designer prototype |
| FE | plan gating | read | middleware/nlp-feature.ts subscriptionData.features.find(code==='nlp')?.enabled |
| FE | test commands | read | package.json "test":"vitest run" (L17), "lint:ts" (L13), "build":"nuxt build" (L11); bitbucket-pipelines.yml jsLint/nodeUnitTest |
No unverifiable rows remain. The one open technical unknown (do Midtrans v2 endpoints accept the OAuth2 Bearer token?) is tracked as OQ-3 and gates execution chunks 3–6.
Detail 2.1 — Architecture (mermaid)
End-to-end component diagram
flowchart TB
agent([AI Agent runtime]) --> ae[ActionExecute use case]
ae -->|flag + plan gate| gate{enabled?}
gate -- no --> noop[not executed]
gate -- yes --> fac[ActionExecutorFactory.for node_type]
fac --> me[Midtrans::Execute]
me --> disp{node_type}
disp --> s[check_payment_status]
disp --> l[create_payment_link]
disp --> c[cancel_transaction]
disp --> r[request_refund]
s & l & c & r --> ahc[AuthorizedHttpClient.call]
ahc --> tm[(Auth::TokenManager + Redis)]
ahc --> mid([Midtrans API])
l --> hub[SendMessageInteractive send_button_message]
me --> ev[[SendMixpanelEventWorker]]
Data model (erDiagram)
erDiagram
ORGANIZATION ||--o{ ORGANIZATION_CONNECTION : has
ORGANIZATION ||--o{ AI_AGENT_ACTION : has
NODE_REGISTRY ||--o{ AI_AGENT_ACTION : "typed by (node_type+version)"
ORGANIZATION_CONNECTION {
bigint id PK
string auth_type "midtrans_oauth2"
text auth_data "ENCRYPTED {environment,client_id,client_secret}"
text token_cache "ENCRYPTED (db-store providers only)"
boolean auth_is_managed
bigint organization_id FK
}
AI_AGENT_ACTION {
bigint id PK
string action_type "midtrans_* (= node_registries.node_type)"
string name
text description
jsonb parameters
jsonb credentials "OrganizationConnection ref"
bigint organization_id FK
}
NODE_REGISTRY {
uuid id PK
string node_type "midtrans_check_payment_status | …"
string node_type_group "midtrans"
jsonb properties "dynamic config fields"
jsonb categories "[Other integration]"
float version
boolean enabled
}
No new tables. Midtrans P0 adds rows to
node_registriesand reusesorganization_connections/ai_agent_actions.
State machine — Midtrans transaction status (external, read-only)
We do not own this enum — it is Midtrans's. The diagram captures which states gate cancel (S04) and refund (S05). Transitions are driven by Midtrans, not by us.
stateDiagram-v2
[*] --> pending
pending --> settlement: paid/captured
pending --> expire: timeout
pending --> deny: rejected
pending --> cancel: cancel action (S04)
authorize --> settlement: capture
authorize --> cancel: cancel action (S04)
settlement --> refund: refund action (S05)
note right of pending: cancellable (S04)
note right of authorize: cancellable (S04)
note right of settlement: refund-eligible (S05)
Branch & skip flow — cancel/refund pre-check + confirmation
flowchart TD
trigger([AI Agent identifies cancel/refund + order_id]) --> chk[GET /v2/order_id/status]
chk --> elig{eligible? cancel:pending/authorize · refund:settlement}
elig -- no --> deny["reply: cannot cancel/refund — status: X · NO mutating call"]
elig -- yes --> confirm{customer confirms?}
confirm -- no --> declined["reply: no changes made"]
confirm -- yes --> dedupe{refund only: dedupe window open?}
dedupe -- duplicate --> suppress[suppress second POST]
dedupe -- first --> mutate["POST /v2/order_id/cancel|refund"]
mutate --> ok["reply success + midtrans_action_success"]
Detail 2.2 — Sequence diagrams
Happy path — check payment status (S02)
sequenceDiagram
actor Agent as AI Agent
participant AE as ActionExecute
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant R as Redis (token)
participant MID as Midtrans API
participant MX as SendMixpanelEventWorker
Agent->>AE: execute(midtrans_check_payment_status, {order_id})
AE->>AE: flag + plan gate (enabled?)
AE->>ME: call(credential, parameters, arguments)
ME->>MX: perform_async(midtrans_action_executed)
ME->>AHC: call(credential, GET /v2/{order_id}/status, timeout 60s)
AHC->>R: GET token (hit → reuse; miss → mint via /v1/oauth/token, SET TTL 3300s)
AHC->>MID: GET /v2/{order_id}/status (Bearer)
Note right of MID: must return ≤60s
MID-->>AHC: 200 {transaction_status}
AHC-->>ME: Success(resp)
ME->>MX: perform_async(midtrans_action_success {status,duration_ms})
ME-->>AE: {status:'success', body:{...}}
AE-->>Agent: NL status message
Failure path — timeout / 404 / auth (S02 ERR-1..3)
sequenceDiagram
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant MID as Midtrans API
participant MX as SendMixpanelEventWorker
ME->>AHC: call(GET /v2/{order_id}/status, timeout 60s)
alt no response >60s
MID--xAHC: Net::ReadTimeout → synthetic 504/408
AHC-->>ME: Success(resp code 504)
ME->>MX: midtrans_action_failed reason=timeout
ME-->>ME: "unable to check payment status…"
else 404 not found
MID-->>AHC: 404
AHC-->>ME: Success(resp 404)
ME->>MX: midtrans_action_failed reason=api_error
ME-->>ME: "couldn't find a payment with that order ID"
else token mint / 401 after retry
AHC->>MID: GET (Bearer) → 401
AHC->>AHC: invalidate token, retry once → still 401
AHC-->>ME: Failure([:auth_failed, resp])
ME->>MX: midtrans_action_failed reason=auth_error
ME-->>ME: auth error message
end
Happy path — create payment link → WhatsApp CTA-URL (S03)
sequenceDiagram
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant MID as Midtrans API
participant HUB as SendMessageInteractive
participant CH as Channel gateway
ME->>AHC: call(POST /v1/payment-links {transaction_details,…})
AHC->>MID: POST (Bearer)
MID-->>AHC: 201 {payment_url}
AHC-->>ME: Success(resp)
alt channel == whatsapp
ME->>HUB: send_button_message(body_text, button_actions:[CTA-URL], link: payment_url)
HUB->>CH: interactive CTA-URL button
else other channel
ME->>HUB: send text with clickable URL
HUB->>CH: text message
end
ME->>ME: midtrans_payment_link_created {payment_link_url, channel_type}
Happy/branch path — cancel with confirmation (S04); refund (S05) is symmetric
sequenceDiagram
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant MID as Midtrans API
participant R as Redis (refund dedupe)
ME->>AHC: GET /v2/{order_id}/status
AHC->>MID: GET (Bearer)
MID-->>ME: status
alt not eligible
ME-->>ME: "cannot cancel/refund — status: X" (NO mutating POST)
else eligible
ME-->>ME: confirmation prompt
Note over ME: customer reply
alt declines
ME-->>ME: "no changes were made"
else confirms
opt refund only
ME->>R: SETNX midtrans:refund:dedupe:{conv}:{order} (TTL 120s)
R-->>ME: duplicate? → suppress second POST
end
ME->>AHC: POST /v2/{order_id}/cancel (or /refund {refund_key, amount?})
AHC->>MID: POST (Bearer)
MID-->>ME: 200 success
ME-->>ME: success message + midtrans_action_success
end
end
Detail 2.3 — Database Model (DDL)
No DDL/migrations that create tables. Phase 1 adds data rows to the existing
node_registries table (4 enabled rows) via a Rails data migration / seed. The
organization_connections and ai_agent_actions tables are unchanged.
Reference node_registries schema (existing, db/schema.rb:1219):
-- EXISTING table — no ALTER. Phase 1 inserts 4 rows.
-- create_table "node_registries", id: :uuid, default: gen_random_uuid()
-- node_type varchar NOT NULL -- e.g. 'midtrans_check_payment_status'
-- name varchar -- UI label
-- categories jsonb DEFAULT '[]'-- e.g. ["Other integration"]
-- version float
-- inputs jsonb DEFAULT '[]'
-- outputs jsonb DEFAULT '[]'
-- properties jsonb DEFAULT '[]'-- dynamic config field schema (FE renders this)
-- description text
-- settings jsonb DEFAULT '{}'
-- enabled boolean DEFAULT true NOT NULL
-- node_type_group varchar -- 'midtrans' → NodeTypeRegistry::GROUP
-- UNIQUE (node_type, version) WHERE deleted_at IS NULL
Seed rows (data migration, idempotent on node_type+version):
| node_type | node_type_group | name | categories | version | enabled |
|---|---|---|---|---|---|
midtrans_check_payment_status | midtrans | Check Payment Status | ["Other integration"] | 1.0 | true |
midtrans_create_payment_link | midtrans | Create Payment Link | ["Other integration"] | 1.0 | true |
midtrans_cancel_transaction | midtrans | Cancel Transaction | ["Other integration"] | 1.0 | true |
midtrans_request_refund | midtrans | Request Refund | ["Other integration"] | 1.0 | true |
- Cardinality / growth: +4 catalog rows total (org-independent).
ai_agent_actionsandorganization_connectionsgrow per configuring account (target ≥5 CIDs → tens of rows). - PII classification:
organization_connections.auth_dataholdsclient_secret(sensitive credential) — encrypted at rest (Lockbox viaPayloadEncryptor). No cardholder/PAN data is ever stored (payment data lives in Midtrans). - Retention: credential rows retained until disconnected (soft-delete via
acts_as_paranoid). - Per-status lifecycle:
n/a — no new status enum is owned by this RFC.Transaction status is Midtrans's (read-through only);node_registries.enabledis the only on/off flag (true = visible; soft-deletable). See §2.1 state diagram for the external enum. - Partition/sharding: none.
- NoSQL alternative: n/a — reuses existing relational tables.
properties[] per action (the dynamic-field schema the FE renders; field names remain
provisional per PRD §6/OQ-6 — confirm exact Midtrans field names during build).
Every seeded property MUST carry a non-blank
type(REV-9). As ofchatbotHEADfa6dd8b79("fix(service): skip actions with unresolved argument types"),FrontendService::V2::AiAgent::Repositories::SyncToAiService#build_non_api_args(app/api/frontend_service/v2/ai_agent/repositories/sync_to_ai_service.rb:451-467) resolves each AI-resolvable argument's type from the matching registryproperties[].type(registry_prop&.dig('type')→map_param_type), andbuild_skill_actions(:404-420) then drops the entire action —next if args_with_unresolved_type?(args)(:408,:424-428) — when any AI-resolvable arg resolves to a blank type. So a property with notype⇒ the Midtrans action is silently absent from the synced skill pack (configured but never invokable).typeis the data type (map_param_typemapsstring→str,integer→int,number→float,boolean→bool;array/objectpass through for nested fields) and is distinct fromhtml.element(the FE render hint). This matches the canonical registry shape{name, type, required, html{…}}—spec/factories/node_registry.rbwith_properties({name, type, required}) and the FEPropertiesItem.type, which is non-optional (chatbot-femodules/bot-automation/constants/bot-automation-actions-constants.ts:4).
Action (node_type) | property name | type | html.element | required | AI-resolvable¹ | Source |
|---|---|---|---|---|---|---|
midtrans_check_payment_status | order_id | string | input (html.type:"text") | yes | yes | PRD §6 A1 |
midtrans_create_payment_link | transaction_details.order_id | string | input (html.type:"text") | yes | yes | PRD §6 A2 |
midtrans_create_payment_link | transaction_details.gross_amount | integer | input (html.type:"number") | yes | yes | PRD §6 A2 |
midtrans_create_payment_link | item_details | array | textarea | no | no | PRD §6 A2 |
midtrans_create_payment_link | customer_details | object | textarea | no | no | PRD §6 A2 |
midtrans_create_payment_link | usage_limit | integer | input (html.type:"number") | no | no | PRD §6 A2 |
midtrans_create_payment_link | expiry | object | textarea | no | no | PRD §6 A2 |
midtrans_create_payment_link | enabled_payments | array | textarea | no | no | PRD §6 A2 |
midtrans_cancel_transaction | order_id | string | input (html.type:"text") | yes | yes | PRD §6 A3 |
midtrans_request_refund | order_id | string | input (html.type:"text") | yes | yes | PRD §6 A4 |
midtrans_request_refund | refund_key | string | input (html.type:"text") | no | no² | PRD §6 A4 |
midtrans_request_refund | amount | integer | input (html.type:"number") | no | no | PRD §6 A4 |
midtrans_request_refund | reason | string | input (html.type:"text") | no | no | PRD §6 A4 |
¹ "AI-resolvable" = typically configured use_ai: true in the action's parameters.arguments, so
the agent fills it from the conversation. Only these gate the sync (build_non_api_args builds
args only for use_ai: true params, :456), but every property still needs a type to satisfy
the FE PropertiesItem contract. ² refund_key is system-generated (Decision 4) and never use_ai.
Canonical seeded property (the order_id row, full shape the migration writes):
{
"name": "order_id",
"display_name": "Order ID",
"type": "string",
"required": true,
"html": { "element": "input", "type": "text" },
"description": "Midtrans order_id (AI-resolvable from the conversation)"
}
Detail 2.4 — APIs
Outbound endpoints (consumers call us) — all reused
| Endpoint | Method | AuthN/AuthZ | Request | Response | Status | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/v1/node_registries | GET | session + plan/flag (FE) | filter by category | action catalog incl. 4 midtrans rows | 200 | n/a (read) | v1 | reused |
/v1/api_connections | POST/PUT/GET | Admin/SPV session, org-scoped | {auth_type:'midtrans_oauth2', auth_data:{environment,client_id,client_secret}} | connection (secrets masked) | 200/201/422 | name/code uniqueness per org | v1 | reused |
/v1/ai_agent_tools | POST/PUT/GET | Admin/SPV session | action config (name, description, parameters, credential) | action | 200/201/422 | per-id | v1 | reused |
(internal) ActionExecute | use case | internal service auth | {action, arguments, room_id} | executor result | n/a | per refund dedupe | n/a | extended |
Inbound webhooks (other services call us)
N/A — no inbound webhook. All four P0 actions are outbound request/response; the PRD names no Midtrans payment-notification receiver in this phase.
External (we call Midtrans) — per-call contract
| Call | Method | URL | Timeout | Failure behavior | Retry |
|---|---|---|---|---|---|
| Token mint | POST | https://api(.sandbox).midtrans.com/v1/oauth/token (per environment) | via TokenManager | auth_error event; action aborts | handled by TokenManager lock; no app retry |
| Check status | GET | …/v2/{order_id}/status | 60 s | 504/408 → timeout; 404 → not-found copy | none (read) |
| Create link | POST | …/v1/payment-links | 60 s | 400 → invalid_request; 504 → timeout | none |
| Cancel | POST | …/v2/{order_id}/cancel | 60 s | pre-check gates eligibility; 401/403 retry-once | AuthorizedHttpClient 1× on 401/403 |
| Refund | POST | …/v2/{order_id}/refund | 60 s | dedupe gates duplicate; refund_key idempotency | AuthorizedHttpClient 1× on 401/403 |
OQ-3 gate: the v2 base host/path and whether the OAuth2 Bearer token is accepted on
/v2/*must be confirmed against Midtrans docs before chunks 3–6. If v2 requires Basicserver_key, switch the credential to themidtrans_basicdescriptor specified in §2 Decision 8 — the executor does not change (auth is applied byAuthorizedHttpClient).
Per-call request / response schemas
Concrete contracts the executor builds and parses (field names per Midtrans public API docs;
confirm exact casing/optionality in OQ-6). All requests carry the auth header injected by
AuthorizedHttpClient (Bearer or Basic per Decision 8); Content-Type: application/json.
// Check status — GET /v2/{order_id}/status (no request body)
// 200 response (fields consumed):
{ "order_id": "ORDER-123", "transaction_status": "settlement", // pending|settlement|deny|cancel|expire|failure|authorize
"status_code": "200", "transaction_id": "…", "gross_amount": "150000.00", "payment_type": "…" }
// 404 → { "status_code": "404", "status_message": "Transaction doesn't exist." }
// Create payment link — POST /v1/payment-links
// request:
{ "transaction_details": { "order_id": "ORDER-123", "gross_amount": 150000 },
"item_details": [ { "id": "i1", "name": "Item", "price": 150000, "quantity": 1 } ], // recommended
"customer_details": { "first_name": "…", "email": "…", "phone": "…" }, // optional
"usage_limit": 1, "expiry": { "duration": 24, "unit": "hours" } } // optional
// 201 response (fields consumed):
{ "order_id": "ORDER-123", "payment_url": "https://app.midtrans.com/payment-links/ORDER-123" }
// 400 → { "error_messages": ["transaction_details.gross_amount is required"] }
// Cancel — POST /v2/{order_id}/cancel (no request body)
// 200 → { "status_code": "200", "transaction_status": "cancel", "order_id": "ORDER-123" }
// 412 → { "status_code": "412", "status_message": "Merchant cannot modify the status…" }
// Refund — POST /v2/{order_id}/refund
// request:
{ "refund_key": "ref-<uuid>", // idempotency key, generated per logical refund (Decision 4)
"amount": 150000, // OMIT for full refund (P0 default — OQ-5)
"reason": "customer request" } // optional
// 200 → { "status_code": "200", "transaction_status": "refund", "refund_amount": "150000.00",
// "refund_key": "ref-<uuid>" }
// 412/400 → { "status_code": "412", "status_message": "Cannot refund — invalid status." }
Detail 2.A — UI Contract
The config surface reuses the production action-config components. New work is the
credential connect block for midtrans_oauth2.
- Implementation files (reused):
modules/bot-automation/components/drawers/NewActionDrawer.vue,components/forms/CreateActionForm.vue,components/forms/ActionIntegrationForm.vue. - New file (credential form):
modules/bot-automation/components/forms/MidtransAuthBlock.vue(connect/reauthorize/disconnect for amidtrans_oauth2connection). - Props (MidtransAuthBlock):
interface MidtransAuthBlockProps {
connection?: { id: string; environment: 'sandbox' | 'production'; connected: boolean } | null;
loading?: boolean; // skeleton while connection status fetched
errorMessage?: string; // connect/load failure copy
}
// emits: 'connect' {environment, clientId, clientSecret}, 'reauthorize', 'disconnect'
- State ownership:
store/ai-agent-tool/(action config) + a connection state read viamainService/v1/api_connections. Connection "connected" = amidtrans_oauth2OrganizationConnectionexists for the org. - Event payloads (analytics): FE does not emit Mixpanel directly for these (events are BE-emitted, PRD §10). FE may keep existing form-interaction analytics unchanged.
- Conditional rendering: not-connected → ConnectButton + credential fields, Save disabled; connected → "Midtrans connected [environment]", Reauthorize + Disconnect, Save enabled.
- A11y: form labels for each credential field; error text linked via
aria-describedby; follows@mekari/pixel3MpFormControlsemantics.
Detail 2.B — Data-Fetching Strategy
- Library: ofetch via
mainService(common/services/main), Pinia for state. - Cache key / fetch triggers: action list (
/v1/node_registries) fetched on drawer open; connection status fetched on panel mount and after connect/reauthorize/disconnect. - SWR: no — explicit fetch + Pinia state (
pending/resolved/rejected). - Optimistic updates: none — connect/save await server confirmation before state flip.
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Action config panel | skeleton while connection status fetched | all fields blank, "Not connected", Save disabled | "Failed to load configuration. Try again." + Retry (S01/ERR-3) | connected but required dynamic fields missing → Save disabled | connected + required filled → Save enabled (S01/AC-3) |
| Connect (credentials) | spinner on Connect | n/a | "Connection failed. Check your credentials and try again." (S01/ERR-1) | n/a | "Midtrans connected [environment]" (S01/AC-2) |
| In-conversation result | typing indicator ≤60 s | AI Agent prompts for order_id (S02/AC-3) | error copy per ERR catalog | n/a | status text / CTA-URL button / confirmation result |
Detail 2.D — Data Integrity Matrix
| Write path | Transaction scope | Partial failure behavior | Idempotency key + TTL | Consistency | Duplicate-event handling | Stale-read |
|---|---|---|---|---|---|---|
Create credential (/v1/api_connections) | single-row DB write, auth_data encrypted | 422 on validation; nothing persisted | name/code uniqueness per org | strong (DB) | n/a | n/a |
| Token mint/cache | redis SET with TTL 3300 s under lock | lock timeout → auth_error | redis key per credential.id; lock TTL 15 s | eventual (cache) | second concurrent fetch waits for cached token | re-mint on miss/expiry |
| Cancel POST | external (Midtrans) | status pre-check → no POST if ineligible; 401/403 retry-once | natural (status pre-check rejects re-cancel) | external | second confirm hits pre-check → already-cancelled rejected | live status read each turn |
| Refund POST | external (Midtrans) | eligibility check → no POST if ineligible | refund_key per logical refund + redis dedupe midtrans:refund:dedupe:{conv}:{order} TTL ~120 s | external | duplicate confirm within window → suppressed before POST | live status read each turn |
Detail 2.E — Concurrency Collision Map
| Resource | Writers | Collision scenario | Resolution | On failure |
|---|---|---|---|---|
| Redis Midtrans token (per credential.id) | concurrent agent actions for same org | thundering herd minting tokens | TokenManager lock (SET nx ex 15s) + wait-for-cached poll | lock timeout → auth_error, action aborts |
Refund POST for same (conversation_id, order_id) | duplicated WhatsApp "yes" | double refund | SETNX dedupe key TTL ~120 s | duplicate suppressed; single POST |
| Credential row (org) | concurrent Admin edits | last-write-wins on auth_data | DB row + paper_trail; token cache invalidated on secret rotation | standard model validation |
Detail 2.F — Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input | Retry | DLQ | Concurrency | Idempotency | Timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
SendMixpanelEventWorker | each event emission | (distinct_id, event_name, params) | Sidekiq default | Sidekiq dead set | default queue (event_tracker) | event is fire-and-forget (analytics) | short | dropped after retries; analytics non-critical |
SendMessageInteractiveWorker | CTA-URL / text result delivery | message payload | retry: 1 (existing) | existing | existing | message id | existing | existing path |
The Midtrans API call itself is synchronous (Decision 5), not a queued job.
Detail 2.F.1 — Responsibility Boundary Matrix
N/A — single squad (BOT) owns both layers. External boundary (Midtrans) is captured in
§2.4 external calls + §3.A failure catalog. No cross-squad flow.
Detail 2.F.2 — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Midtrans connection | connected (derived: credential exists) | not-connected | /v1/api_connections create/delete | /v1/api_connections | on panel mount |
| Midtrans transaction | transaction_status (external) | n/a | Midtrans | GET /v2/{order_id}/status (per turn) | live each call (no caching of status) |
| Action config | exists/enabled | n/a | /v1/ai_agent_tools | /v1/ai_agent_tools, /v1/node_registries | on drawer open |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|---|---|---|---|
GET /v1/node_registries | rows {node_type, name, categories, properties, settings, version} | ActionListItem {node_type, name, categories[], properties[], settings, version} | yes | snake_case already consumed by existing FE constants; 4 new rows fit the shape |
POST /v1/api_connections | {id, auth_type, auth_data(masked), name, code} | connect form expects {id, environment, connected} | partial | FE must derive environment from auth_data and connected from presence — add a small mapper in the credential service (no BE change) |
POST /v1/ai_agent_tools | action {id, action_type, name, parameters, credentials} | create payload mirror | yes | existing CRUD contract |
The one
partialhas a mitigation (FE-side mapper); no BE contract change required.
Detail 2.H — End-to-End Data Flow
- Configure: Admin opens drawer →
GET /v1/node_registries(sees 4 midtrans rows) → selects action →MidtransAuthBlockconnect →POST /v1/api_connections(auth_data encrypted) → fills dynamic fields → Save →POST /v1/ai_agent_tools. Side effects:paper_trailrows; no events on configure (events are runtime). - Execute (status): customer msg → AI Agent → internal
ActionExecute(flag/plan gate) →Midtrans::Execute→AuthorizedHttpClient(token from redis or mint) →GET /v2/.../status→ NL reply →midtrans_action_executed/_successevents. - Execute (link): as above →
POST /v1/payment-links→send_button_message(link:)on WhatsApp / text else →midtrans_payment_link_created. - Execute (cancel/refund): status pre-check → confirm → (refund: dedupe) → mutating POST → success reply → events. Ownership: BOT squad both layers; Midtrans owns the payment record.
Detail 2.I — Scope Boundaries
- BE create:
app/core/repositories/node_executions/nodes/midtrans/execute.rb; data migration seeding 4node_registriesrows; specs underspec/core/repositories/node_executions/nodes/midtrans/andspec/api/internal_service/v1/ai_agent/action_execute/midtrans/. - BE modify:
node_type_registry.rb(add'midtrans' => 'Midtrans::Execute');action_execute.rb(flag/plan gate + dispatch already generic — confirm no Midtrans-special-case needed). - BE NOT touched: auth descriptor/token manager (shipped),
OrganizationConnectionmodel, hub send use case (reused as-is). - FE create:
MidtransAuthBlock.vue; a credential service mapper. - FE modify: wire MidtransAuthBlock into the config form when action_type starts
midtrans_. - FE NOT touched: generic dynamic-field renderer, drawer switch logic (4 actions appear automatically from the registry).
- Shared modules touched:
NodeTypeRegistry::GROUP(additive — impacts executor resolution only).
Detail 2.J — Asset Inventory
| Asset | Type | Source | Format & sizes | Path |
|---|---|---|---|---|
| Midtrans logo (drawer row icon) | icon | new export (PM-provided per anchor brand-asset rule) | SVG | `chatbot-fe/static |
Per the ANCHOR brand-asset rule, the Midtrans logo + hex must be PM-provided and code-gen-ready before the FE row lands. Tracked as OQ-7. If absent, the row falls back to a generic Pixel icon (
actionIconConfig.tsdefault).
3. High-Availability & Security
The execution path is stateless (Rails API pods, HPA-managed); credentials live in Postgres
(encrypted) and tokens in Redis (TTL 3300 s). If Midtrans is slow/down, the 60 s timeout
returns a graceful in-conversation error and emits midtrans_action_failed reason=timeout;
no partial local state is written (Midtrans is the system of record). If Redis is down, token
mint falls back to a fresh mint per call (degraded latency, still correct). Full-restart
recovery is inherited from the platform (no new stateful component).
Performance Requirement
- Backend — Per action: bounded by the 60 s budget; expected Midtrans p99 well under
that. Volume is low for P0 (target ≥5 CIDs). RPS: negligible incremental; p99 dominated by
Midtrans latency. Scalability: existing HPA on API + worker pods; Redis token cache caps
token-mint RPS. Load test:
n/a — low-volume P0 conversational feature; smoke against Midtrans sandbox in QA. - Midtrans rate limits (PRD §7 / REV-3): Midtrans enforces per-endpoint rate limits.
Decision: on HTTP 429, the executor does not auto-retry in P0 (a retry inside the
60 s conversational turn risks compounding the limit); it maps to a distinct
failure_reason: rate_limitedwith its own user copy (separate from generictimeout) and emitsmidtrans_action_failed. (Token-mint 429s are absorbed by theTokenManagerlock, which serialises minting.) Auto-backoff/retry is explicitly deferred to a later phase. - Frontend — the config panel reuses existing
@mekari/pixel3components and adds no new route bundle of note. Perf budget:n/a — reuses existing Bot & Automation drawer; no new heavy dependency, no LCP/INP/CLS-sensitive surface introduced(verify bundle delta < 5 KB gz in CI ifMidtransAuthBlockpulls a new dependency — it should not). - Browser support matrix: inherits the production
chatbot-febaseline (last 2 versions of Chrome, Edge, Firefox, Safari + iOS Safari); no Midtrans-specific browser constraint. Config is web-only (NEG-4); mobile renders no config menu.
Monitoring & Alerting
Events (Mixpanel via SendMixpanelEventWorker; field convention cid, conversation_id,
action_type, order_id, duration_ms, failure_reason):
| Event | Trigger | Key properties |
|---|---|---|
midtrans_action_executed | any action initiated | action_type, order_id?, cid, conversation_id, timestamp |
midtrans_action_success | Midtrans 2xx | action_type, order_id, response_status, duration_ms, cid, conversation_id |
midtrans_action_failed | failure/timeout/auth/rate-limit | action_type, order_id?, failure_reason ∈ {timeout, auth_error, invalid_request, api_error, rate_limited}, cid, conversation_id |
midtrans_payment_link_created | link delivered | payment_link_url, channel_type, cid, conversation_id |
midtrans_auth_token_refreshed | token mint by TokenManager | environment, duration_ms, cache_hit, cid |
- FE analytics (config panel): the FE reuses the existing Bot & Automation action-builder
analytics (action created/edited, credential connected) unchanged — no new FE event is
required (runtime events are BE-emitted per PRD §10). If product wants a distinct
"midtrans_action_configured" funnel event, it is added at Save in
store/ai-agent-tool/; marked optional (REV-5). - Alerts (PRD §10):
midtrans_action_failedrate > 5 % / 15-min → BOT Slack; 3 consecutivemidtrans_auth_token_refreshedfailures for same cid → Slack + Eng Lead. - Logs: reuse
[Auth]…structured logs fromAuthorizedHttpClient/TokenManager; executor logs[Midtrans::Execute] node_type=… order_id=… status=…. PII scrub: never logclient_secret, full token, orpayment_link_urlquery secrets. - "3am" runbook: check token mint logs + Midtrans status page; flip flag OFF as kill-switch.
Logging
- BE fields:
organization_id,conversation_id/room_id,node_type,order_id,failure_reason,duration_ms, level INFO/ERROR. - Scrubbed:
client_secret, Bearer token, any card data (never received).
Security Implications
- Threat model: credential theft (client_secret), SSRF via attacker-controlled URLs, unauthorized mutating actions, token leakage in logs.
- Mitigations:
- Outbound URL is not user-controlled — paths are built from fixed Midtrans hosts
(descriptor
token_url_map) + validatedorder_id; no free-form URL (unlike the generic API action).order_idvalidated against an allowed charset (alnum +-_). - Secrets at rest:
auth_dataencrypted via Lockbox (PayloadEncryptor), masked on read (mask_sensitive: true). Never returned in plaintext. - AuthZ: config endpoints are Admin/SPV + plan + flag gated; execution is internal-service only (no public manual-trigger route — NEG-2). Org-scoped credential lookup (no cross-tenant).
- Mutating-action guardrails: status pre-check + explicit confirmation (S04/S05); refund idempotency (Decision 4).
- Static analysis:
brakeman(Gemfile),rubocop.
- Outbound URL is not user-controlled — paths are built from fixed Midtrans hosts
(descriptor
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Constraint | Audit |
|---|---|---|---|---|---|---|
| Admin / SPV | /v1/node_registries, /v1/api_connections, /v1/ai_agent_tools | GET/POST/PUT | own org | config drawer (eligible plan + flag, web) | single connection (NEG-1) | paper_trail |
| AI Agent (runtime) | internal ActionExecute | execute | own org | n/a | only configured + enabled actions | Mixpanel events |
| CS Agent / human | none | — | own org | read-only result in conversation (NEG-2) | no manual trigger | n/a |
Detail 3.A — Failure Mode & Retry Catalog
| External call | Timeout | Retries | Circuit breaker | DLQ | Caller behavior on persistent failure |
|---|---|---|---|---|---|
Token mint (/v1/oauth/token) | TokenManager | none (lock-serialised) | none | n/a | auth_error event; action aborts; auth error copy |
GET /v2/{order_id}/status | 60 s | none | none | n/a | timeout copy + timeout; 404 → not-found copy; 429 → rate_limited copy (no retry) |
POST /v1/payment-links | 60 s | none | none | n/a | invalid_request (400) / timeout; 429 → rate_limited |
POST /v2/{order_id}/cancel | 60 s | 1× on 401/403 (AuthorizedHttpClient) | none | n/a | timeout copy + log; pre-check prevents ineligible call; 429 → rate_limited |
POST /v2/{order_id}/refund | 60 s | 1× on 401/403 | none | n/a | timeout copy; dedupe prevents double POST; 429 → rate_limited |
429 policy (REV-3): no auto-retry in P0 (see §3 Performance).
AuthorizedHttpClientretries once only on 401/403 (auth), never on 429. A 429 returns the distinctrate_limiteduser copy below and logs/event-tagsfailure_reason: rate_limited.
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
| Transaction not cancellable (status ≠ pending/authorize) | Midtrans::Execute cancel pre-check | cancel endpoint NOT called | midtrans_action_failed? no — it's a valid branch; log only | yes ("cannot cancel — status: X") |
| Transaction not refund-eligible (status ≠ settlement) | refund eligibility check | refund endpoint NOT called | log | yes |
| Customer declines confirmation | confirmation step | no mutating call | log | yes ("no changes made") |
| Duplicate refund confirm within dedupe window | redis dedupe (Decision 4) | second POST suppressed | log | no (idempotent — single result) |
order_id missing | executor | prompts customer for order_id | none | yes (S02/AC-3) |
| Flag OFF / plan ineligible | ActionExecute gate + registry visibility | action not executed / not rendered | none | no (NEG-3, NEG-4) |
Detail 3.B — Error Response Catalog (BE → conversation)
| Action | failure_reason | Source | Customer message (PRD) |
|---|---|---|---|
| check_status | timeout | >60 s | "I was unable to check your payment status. Please try again or contact support." |
| check_status | api_error | 404 | "I couldn't find a payment with that order ID. Please verify and try again." |
| create_link | invalid_request | 400 | "I was unable to create a payment link. Please contact support." |
| create_link | timeout | >60 s | "I was unable to generate a payment link. Please try again or contact support." |
| cancel | (branch) | ineligible status | "This transaction cannot be cancelled — current status: [status]." |
| refund | (branch) | ineligible status | "This transaction is not eligible for a refund — current status: [status]." |
| cancel/refund | timeout | >60 s | "Unable to [cancel your transaction / process your refund request]…" |
| any | auth_error | token/401 after retry | auth error copy + log |
| any | rate_limited | 429 | "I'm receiving a lot of requests right now and couldn't complete that. Please try again in a moment." (distinct from timeout — REV-3) |
Detail 3.C — Error Message Catalog (FE — config panel)
| Code | Message (i18n key) | Surface | User-facing? |
|---|---|---|---|
S01/ERR-1 | "Connection failed. Check your credentials and try again." | inline (auth block) | yes |
S01/ERR-2 | "Midtrans connection required before saving." | inline (Save) | yes |
S01/ERR-3 | "Failed to load configuration. Try again." + Retry | banner | yes |
Detail 3.D — Compliance & Data Governance
This RFC touches payment-adjacent flows but stores no cardholder/PAN data — Midtrans
holds all payment data; we store only the merchant API credential (client_secret).
| Field | Classification | Legal basis | Retention | Encryption | Access audit | Right-to-delete |
|---|---|---|---|---|---|---|
organization_connections.auth_data.client_secret | sensitive (merchant credential) | contract/legitimate interest | until disconnect (soft-delete) | Lockbox at rest + TLS in transit | paper_trail + [Auth] logs | delete connection |
payment_link_url (transient, in conversation) | sensitive (links to payment) | service provision | message retention (existing) | TLS | message logs | per existing message deletion |
No PAN/CVV/cardholder data is processed or stored. PCI scope is limited to passing a merchant credential to Midtrans over TLS.
Detail 3.E — Accessibility
- WCAG AA. Credential form: labelled inputs,
aria-describedbyon errors, keyboard-navigable, focus moved to first error on failed connect, contrast via@mekari/pixel3tokens.
4. Backwards Compatibility and Rollout Plan
Compatibility
- BE: additive only — new executor + group entry + 4 catalog rows; no endpoint/shape changes; existing actions unaffected.
- FE: additive — new
MidtransAuthBlock+ conditional wiring; generic form unchanged. - Cross-layer: the one
partialcontract (api_connections → connect form) is bridged by an FE-side mapper; no BE contract evolution.
Rollout Strategy
- Deploy order: BE first (executor + registry rows behind flag OFF), then FE (config surface). Rationale: registry rows + executor are inert while the flag is OFF and no FE exposes them; FE can ship after and rely on the catalog already existing.
- Feature flag:
rollout / midtrans_native_action(proposed; OQ-1), default OFF, enabled per account after a Midtrans credential is connected. Single flag governs both registry visibility and execution. Kill-switch: flip OFF → actions disappear from drawer + execution gate denies (PRD §10 rollback: failed-rate >10 % sustained 1 h → PM disables globally). - Stages (gate signals; schedule lives in
delivery/):- Internal QA (sandbox): all 4 actions pass; confirmation + auth lifecycle verified; 0 P0/P1 bugs.
- Closed Beta (3–5 prod accounts): success rate ≥95 % for 1 week; 0 P0 bugs.
- GA: gates sustained 2 weeks; success ≥95 %.
- Rollback: flip flag OFF (instant); revert FE PR if config UI broken; data migration is
reversible (set the 4 rows
enabled=false/ soft-delete). No data written during rollout needs cleanup (no transaction persistence). - Blast radius: worst case = an account with the flag ON sees failing actions → graceful in-conversation error; no impact to other actions or accounts.
Detail 4.A — Cross-Layer Rollout Compatibility Matrix
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline |
| Backend first | Old | New | yes | registry rows inert while flag OFF; old FE ignores unknown rows |
| Frontend first | New | Old | no | FE would reference missing executor/rows → avoid; deploy BE first |
| Both deployed | New | New | yes | target |
| Backend rollback | New | Old | partial | flag OFF hides actions; FE handles empty/absent rows gracefully |
| Frontend rollback | Old | New | yes | BE inert behind flag |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
| BE | rollout / midtrans_native_action (system_preferences) | feature flag | OFF | yes | DB / ops | no |
| BE | VENDOR_API_TIMEOUT (existing, optional global cap) | int (s) | unset | no | env | no |
| BE | Midtrans client_id/client_secret | per-org auth_data (encrypted) | — | yes (per account) | Admin via UI | yes |
| BE | GOOGLE_*/Lockbox master key (existing) | secret | — | yes | vault/env | yes |
Detail 4.C — Test Plan (commands sourced from repo)
| Layer | Command (source) | What it must prove |
|---|---|---|
| BE unit/integration | RAILS_ENV=test bundle exec rspec spec/core/repositories/node_executions/nodes/midtrans (pattern from bitbucket-pipelines.yml:74; .rspec --require spec_helper) | executor dispatch, status mapping, pre-check, dedupe, error mapping (webmock-stubbed Midtrans) |
| BE API/integration | RAILS_ENV=test bundle exec rspec spec/api/internal_service (source bitbucket-pipelines.yml:144) | ActionExecute flag/plan gate + dispatch to Midtrans::Execute |
| BE lint | bundle exec rubocop (.rubocop.yml present) | style/lint clean |
| BE security | bundle exec brakeman (Gemfile brakeman) | no new high-severity warnings (SSRF/secret leak) |
| FE unit | pnpm test → vitest (package.json:17) | MidtransAuthBlock states; connect/save gating; mapper |
| FE lint | pnpm lint:ts (package.json:13) / pnpm lint (:15) | eslint/prettier clean |
| FE build | nuxt build (package.json:11) | builds |
| Cross-layer | manual sandbox run of all 4 actions in a test conversation (Internal QA gate) | end-to-end incl. CTA-URL button, confirmation, idempotency |
Detail 4.D — Agent Execution Plan
| Order | Layer | Chunk | Files | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | BE | Seed 4 node_registries rows + add 'midtrans' group | db/migrate/<ts>_seed_midtrans_node_registries.rb; app/core/repositories/node_executions/node_type_registry.rb | RAILS_ENV=test bundle exec rails db:migrate; bundle exec rspec | 4 enabled rows exist with node_type_group='midtrans'; every seeded properties[] entry carries a non-blank type (REV-9); rspec configures a use_ai Midtrans action and asserts SyncToAiService#build_skill_actions does NOT drop it — args_with_unresolved_type?(args) ⇒ false (REV-9); ActionExecutorFactory.for('midtrans_check_payment_status') resolves to Midtrans::Execute (once chunk 2 lands); migration reversible |
| 2 | BE | Midtrans::Execute skeleton (interface + dispatch + AuthorizedHttpClient helper) | app/core/repositories/node_executions/nodes/midtrans/execute.rb; spec | bundle exec rspec spec/core/.../midtrans | includes NodeExecutorInterface; call dispatches on @node_type; unknown type → validation error; spec green |
| 3 | BE | midtrans_check_payment_status (+events, 60 s, 404/timeout/auth mapping) | execute.rb; spec (webmock) | bundle exec rspec spec/core/.../midtrans | 200→NL status + midtrans_action_success; 404→not-found copy; 504→timeout; auth fail→auth_error |
| 4 | BE | midtrans_create_payment_link (+CTA-URL via send_button_message) | execute.rb; spec | bundle exec rspec … | 201→URL extracted; WhatsApp path calls send_button_message(link:); other→text; 400→invalid_request; midtrans_payment_link_created emitted |
| 5 | BE | midtrans_cancel_transaction (status pre-check + confirmation) | execute.rb; spec | bundle exec rspec … | ineligible status→cancel endpoint asserted NOT called; decline→no POST; confirm→POST /cancel+success |
| 6 | BE | midtrans_request_refund (eligibility + confirm + refund_key + dedupe) | execute.rb; redis dedupe; spec | bundle exec rspec … | non-settlement→no POST; duplicate confirm in window→single POST; confirm→POST /refund with refund_key |
| 7 | FE | MidtransAuthBlock.vue + credential service mapper + conditional wiring | modules/bot-automation/components/forms/MidtransAuthBlock.vue; common/services/main/v1/*; form wiring | pnpm test; pnpm lint:ts; nuxt build | renders connect/connected states; Save gated on connection; 4 actions appear from registry; vitest green |
| 8 | both | Flag + plan gate wiring + Mixpanel events end-to-end | action_execute.rb gate; event call sites | bundle exec rspec spec/api/internal_service; sandbox smoke | flag OFF→not executed/rendered; events fire with correct props |
| 9 | both | Verification recipe + sandbox QA of all 4 actions | — | §4.E commands | all gates green; Internal QA gate met |
Gate: chunks 3–6 are blocked by OQ-3 (does the OAuth2 Bearer token work on Midtrans
/v2/*and is the host/path correct?). Confirm before starting chunk 3.
Detail 4.E — Verification & Rollback Recipe
- Pre-merge (BE): 1)
bundle exec rubocop2)bundle exec brakeman3)RAILS_ENV=test bundle exec rspec spec/core/repositories/node_executions/nodes/midtrans spec/api/internal_service - Pre-merge (FE): 1)
pnpm lint:ts2)pnpm test3)nuxt build - Post-deploy signals: Mixpanel
midtrans_action_executedcount > 0 after first sandbox run;midtrans_action_failedrate < 5 % / 15-min;[Auth][TokenManager]no error spike; no Rollbarauth_failedsurge. - Rollback: 1) flip
rollout / midtrans_native_actionOFF (actions vanish from drawer + execution denied) 2) if FE config broken, revert FE PR 3) if needed, set the 4node_registriesrowsenabled=false(ordb:rollbackthe seed migration) 4) confirmmidtrans_action_*events stop and error rate normalises within 15 min.
Detail 4.F — Resource & Cost Notes
- Compute: negligible incremental; synchronous calls hold a pod up to 60 s (low volume).
- DB: +4 catalog rows + a credential row per account. Redis: small token + dedupe keys.
- Egress: HTTPS to Midtrans per action. No new infra components.
5. Concern, Questions, or Known Limitations
| # | Type | Question / limitation | Owner | Status |
|---|---|---|---|---|
| OQ-1 | Open Question | Feature-flag name — proposed rollout / midtrans_native_action; confirm with Eng (PRD OQ#1) | BOT Eng Lead | open |
| OQ-2 (REV-7) | Major | PRD ⇆ code auth mismatch: PRD says midtrans_oauth with server_key/client_key/partner_id/merchant_id, ~15 min TTL, /v1/access-token. Shipped code is midtrans_oauth2 with environment/client_id/client_secret, 3300 s TTL, /v1/oauth/token. Now coupled to OQ-3: if v2 needs Basic, the PRD's server_key is correct (use midtrans_basic, Decision 8); if Bearer, use the shipped descriptor. The FE credential-form field set follows the chosen descriptor. | PM + BOT Eng | open |
| OQ-3 (REV-1) | Major (was Blocker) | Does the OAuth2 client-credentials Bearer token authenticate Midtrans /v2/{order_id}/status|cancel|refund + /v1/payment-links? No longer blocks scaffolding — §2 Decision 8 specifies both the Bearer (default, shipped) and midtrans_basic paths; the executor is auth-agnostic (AuthorizedHttpClient), so resolving this is a credential/descriptor swap, not a code change. One-line doc confirmation needed before wiring the live header on chunks 3–6. | BOT Eng + Midtrans docs | open |
| OQ-4 | Open Question | Bespoke MidtransActionConfigPanel (PRD §6 component tree) vs reuse generic dynamic-field form + new MidtransAuthBlock (this RFC's choice). Confirm with design/PM; decide whether a credential-form Figma frame is needed. | PM + FE Lead | open |
| OQ-5 | Open Question | Partial vs full refund scope for P0 (amount field). RFC default: full refund only. If partial in scope, add an AC for the partial-amount path (PRD §7, S05). | PM | open |
| OQ-6 | Open Question | Finalise the provisional properties[] field lists (names/types/required) against Midtrans API docs (PRD OQ#3/#6). | BOT Eng Lead | open |
| OQ-7 | Open Question | Midtrans brand asset (logo SVG + hex) for the drawer row (ANCHOR brand-asset rule). | PM | open |
| OQ-8 (REV-9) | Major | Seeded node_registries properties[] must carry a non-blank type per property. As of chatbot HEAD fa6dd8b79 ("fix(service): skip actions with unresolved argument types"), FrontendService::V2::AiAgent::Repositories::SyncToAiService#build_skill_actions filters out any action whose AI-resolvable args resolve to a blank type (args_with_unresolved_type?); for non-api actions the type is read from the matching registry properties[].type (build_non_api_args). Seeding only field → html.element (no type) would silently drop the Midtrans action from the agent's skill pack (configured but non-functional). RESOLVED (2026-06-21): §2.3 now specifies a per-property type column + the grounded JSON shape + the sync-drop rationale, and chunk 1 (§4.D) acceptance now asserts SyncToAiService#build_skill_actions does not drop a use_ai Midtrans action (args_with_unresolved_type? ⇒ false). (Exact field names still tracked by OQ-6.) | BOT Eng Lead | resolved |
| Limitation | — | Synchronous 60 s execution holds a pod per in-flight action; acceptable at P0 volume, revisit if volume grows. | BOT | accepted |
| Limitation | — | Payment links cannot be revoked via this action once created; expiry governed by Midtrans account settings (PRD S03 CANNOT). | — | accepted |
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-20 | RFC author (initial draft) | Resolve OQ-3 (Bearer vs Basic on v2) before backend execution chunks; reconcile OQ-2 auth fields with PM |
| 2026-06-20 | rfc-reviewer (R1 → R2) | R1 scored 6.5 (HOLD). Iterated: added §2 Decision 8 (Bearer + spec'd midtrans_basic fallback → REV-1 no longer blocks scaffolding); pinned per-call Midtrans request/response schemas in §2.4 (REV-2); added 429 rate_limited policy + copy (REV-3); added FE perf/browser specifics (REV-4); FE analytics note (REV-5). R2 score 7.5 (PROCEED with notes). Residual: REV-1/REV-7 doc+PM confirmation. See …-review.md. |
| 2026-06-21 | rfc-reviewer (R3) + fix | R3 re-grounded every citation vs chatbot HEAD fa6dd8b79; surfaced REV-9 (the fix(service): skip actions with unresolved argument types commit now drops any action whose AI-resolvable args lack a registry properties[].type). Fixed here: §2.3 properties[] rewritten with a per-property type column + grounded JSON shape + the SyncToAiService drop rationale; chunk 1 (§4.D) acceptance now asserts the seeded use_ai action survives build_skill_actions. OQ-8/REV-9 → resolved. Residual: REV-1/REV-7 (external), OQ-6 (field names). |
7. Ready for agent execution
- Ready for agent execution: yes — proceed with notes. After the R1→R2 review iteration,
the auth-mechanism risk is no longer a design blocker: §2 Decision 8 specifies both the
default Bearer path (already shipped under BOT-4243) and a drop-in
midtrans_basicfallback, and the executor is auth-agnostic (AuthorizedHttpClient), so the choice is a credential/ descriptor swap rather than a code change. An agent can build chunks 1–9 against the default Bearer path today; only two quick confirmations remain before the live auth header on chunks 3–6 and the FE credential field copy on chunk 7 are finalised.
Outstanding confirmations (notes, not blockers):
- REV-1 / OQ-3 — one-line Midtrans-docs confirmation that the Bearer token works on
/v2/*(else flip the credential tomidtrans_basic— already specified). Default path is shipped. - REV-7 / OQ-2 — PM reconciles the PRD's auth field names/TTL/endpoint with the chosen descriptor; finalise FE credential-form copy (drives chunk 7 only).
- OQ-6 — finalise
properties[]field names against Midtrans docs (schemas now pinned in §2.4; confirm exact casing/optionality). - OQ-4 / OQ-5 / OQ-7 — confirm reuse-vs-bespoke FE form, partial-refund scope, brand asset.
All other gates are satisfied: Infrastructure Topology ✓, Technical Decisions (ADR) ✓, PRD-to-Schema ✓, Detail 1.B/1.C ✓, Repo Reading Guide + Source Verification ✓ (no unverified rows), mermaid diagrams (topology, component, ER, state, sequence ×4 incl. failure paths, branch/skip) ✓, DDL (no new tables; seed rows + per-status note) ✓, APIs (outbound reused + webhooks n/a + external contract) ✓, Cross-Layer Contract Verification ✓, Data Integrity + Concurrency + Async specs ✓, Failure/Branch/Error catalogs ✓, Configuration Contract ✓, Agent Execution Plan ✓, Verification & Rollback Recipe ✓.
Reviewed by
rfc-reviewer— seemidtrans-phase-1-p0-core-actions-review.md(R2 score 7.5 / Strong / PROCEED with notes).