RFC: AI Agent Sequential Idle Follow-up (BE)
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. It is also agent-execution-ready: §1 PRD-to-Schema Derivation, §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe are complete.
Delivery & project management live elsewhere. This RFC is the technical artifact only. Delivery pointer below reads
not yet handed to delivery.The YAML frontmatter is the machine-readable index; the metadata table is the human-readable governance record. Both agree on every shared field.
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | DRAFT — open for engineering review | YAML status: carries the linter enum (draft); review target: BOT squad BE tech reviewer + tech lead |
| DRI | Dimas Fauzi Hidayat | Single accountable owner. Staffing lives in delivery/ once handed off. |
| Team | chatbot | Advisory squad slug carried from source PRD |
| Author(s) | Claude (rfc-starter) + Dimas Fauzi Hidayat | Grounded against chatbot@master, 2026-07-05 |
| Reviewers | pending — BOT squad BE tech reviewer | To be assigned at review kickoff |
| Approver(s) | pending — BOT squad tech lead + infosec approver | To be assigned at review kickoff |
| Submitted Date | 2026-07-05 | ISO-8601 |
| Last Updated | 2026-07-06 | Bump on every material edit |
| Target Release | 2026-Q3 | Carried from PRD target_quarter |
| Target Quarter | 2026-Q3 | Advisory |
| Delivery | not yet handed to delivery | The initiative has no delivery/timeline.md yet |
| Related | PRD — Sequential Idle Follow-up | ADJUSTMENT PRD v1.1 |
| Discussion | pending — BOT squad channel thread to be opened at review |
Type: backend Sub-type: enhancement
Sections at a Glance
- Overview (incl. §1 PRD-to-Schema Derivation)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequences → data model → APIs → integrity / concurrency / async specs)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- Ready for agent execution
1. Overview
The AI Agent's idle action is single-fire today: one timer, one action
(follow_up / assign / resolve). The only way to get "nudge, then
auto-resolve" is to chain the agent's follow_up into the global channel
idle rule — a hardcoded fallback at
process_idle_rule_action.rb:77-83 that couples agent behavior to channel
settings, is invisible to customer Bot Builders, and is easy to
misconfigure.
This RFC replaces that single action with a self-contained ordered
sequence of up to 3 steps stored inside the agent's existing
parameters['profile']['idle_rule'] JSONB (additive steps array — no
migration), executed by the existing idle worker chain
(SendMessageWithResolve → ProcessIdleRuleMessageWorker → SendMessageAfterSend → ProcessIdleRuleActionWorker → ProcessIdleRuleAction) with a step pointer carried on the scheduled job
args. A customer reply resets the sequence implicitly through the
already-existing has_new_reply? guard. When a sequence is configured, the
AI-Agent idle path never invokes the global channel idle rule.
This is a delta on the shipped v2 AI Agent engine (Autonomous AI Agent); nothing outside the idle path changes.
One grounding correction to the PRD (S9 #3, S10 ERR-1/ERR-2): the PRD
assumes failed steps "retry per existing worker policy". There is no
existing retry — both ProcessIdleRuleMessageWorker and
ProcessIdleRuleActionWorker run sidekiq_options retry: false
(app/workers/process_idle_rule_message_worker.rb:5,
app/workers/process_idle_rule_action_worker.rb:5). This RFC introduces a
bounded explicit re-schedule (Decision 4) to honor the PRD's intent
without changing the legacy workers' global options.
Success Criteria
- A 3-step sequence (
follow_up 10m → follow_up 10m → resolve 10m) fires in order on a live channel, each step within ±1 minute of its configured duration (PRD S7 Performance), verified by history rows carryingparameters.idle_rule.step_index0, 1, 2. - A customer reply between steps prevents every later step of the in-flight sequence from firing (0 out-of-order fires in the QA concurrency matrix).
- Zero behavior change for legacy single-action
idle_ruleconfigs with the flag OFF — the existing specsspec/core/use_cases/system/hub/process_idle_rule_action_spec.rbandsend_message_after_send_spec.rbpass unmodified. - No added latency on the live inbound-message path — step advancement is
scheduled work (
perform_in), never inline. - Step failure rate (failed executions ÷ fired executions, from history rows + Rollbar) < 2% sustained post-GA (PRD §12).
Out of Scope
- More than 3 steps; new action types; per-step branching (PRD Non-Goals 1, 3, 4).
- Any change to the legacy global channel idle rule
(
channel_integration.default_auto_send_next_intent_*) for non-AI-Agent flows (PRD Non-Goal 2). - Re-architecting idle detection or the worker chain (PRD Non-Goal 5).
- The FE step-list builder (
chatbot-feAiAgentIdleActionForm.vue/idleActionUtils.ts) — separate FE RFC, blocked on the Figma design (PRD Dependency 1). This RFC freezes the API contract the FE will consume. - Other AI Agent advanced settings (PRD Non-Goal 6).
Related Documents
- PRD: Sequential Idle Follow-up (ADJUSTMENT, v1.1)
- Initiative: ai-agent-idle-action README
- Related initiative: Autonomous AI Agent (owns the v2 engine this adjusts)
- Repo architecture hub:
chatbotdocs/architecture/architecture.md
Assumptions
- The existing
has_new_reply?guard (process_idle_rule_action.rb:135-143) is a sufficient reset mechanism — no new inbound-message listener is needed (PRD Open Question 4; confirmed by grounding, see Decision 3). Repositories::SyncToAiServicedoes not readidle_rule(verified — zero grep hits inapp/core/repositories/sync_to_ai_service.rb), so addingstepscannot break the upstream skill-pack sync.- The plan-tier entitlement is inherited from the AI Agent feature; no new billing check is added (PRD S7; Commercial confirmation tracked in PRD Open Question 3).
Dependencies
| Dependency | Type | Owner | Status |
|---|---|---|---|
ai_agent_sequential_idle SystemPreferences row + org-settings targeting | Internal config | BOT squad | Needs provisioning (Decision 6, §4 Detail 4.A) |
| FE step-list builder (separate FE RFC) | Internal, cross-artifact | BOT squad FE | Blocked on Figma — BE contract frozen here |
| Figma for step-list builder | Design | Wulan (qontak-designer) | In progress — change-request spec issued 2026-07-05: design-change-request-step-list-builder |
| No external/third-party APIs | — | — | The idle path only touches internal ChatService send + assign/resolve workers already in production |
PRD-to-Schema Derivation (backend-specific — required)
| PRD-described entity / attribute / rule | Persisted as | Exposed via | Enforced where | Source (PRD §) |
|---|---|---|---|---|
Ordered idle steps, 1–3, each {action, duration, message, assign?} | ai_agents.parameters JSONB → profile.idle_rule.steps[] (additive; no DDL) | POST /api/frontend_service/v2/ai_agents, PATCH …/:id (request); GET …/:id, GET …/ (response) | Grape params (ai_agents_controller.rb :ai_agent_profile helper) + Entities::Profile::IdleRule dry-struct + new IdleRuleSteps validator | §8 CHG-001, §9 #1 |
| Step cap = 3 (reject at API, ignore extras at runtime) | — (validation rule) | 422 on >3 steps | Grape length: { maximum: 3 } + validator + runtime steps.first(3) guard | §7 Step cap |
action ∈ {follow_up, assign, resolve}, duration 1..86400 s, message required for follow_up/resolve, assign target required for assign | — (validation rule) | 422 field-level error | New validator (pattern: use_cases/validators/capability_ref_presence.rb) — mirrors the runtime checks at send_message_after_send.rb:131-144 | §9 #1 |
| Per-room step pointer (advance on idle, reset on reply) | Not persisted at rest — step_index carried on the ProcessIdleRuleActionWorker job args; audit copy written into histories.parameters.idle_rule.step_index on each fire | — (internal) | ProcessIdleRuleAction (execute + re-arm); reset via existing has_new_reply? abort | §9 #3–4, Decision 2/3 |
Fire step, then re-arm next step's timer; resolve is terminal; no global-rule fallback in steps mode | histories row per fired step (existing insert_history, enriched params) | — (runtime behavior) | ProcessIdleRuleAction steps branch (new); global fallback (process_idle_rule_action.rb:61-68,77-83) bypassed when steps execute | §8 CHG-003, §9 #3, #5 |
Legacy single-action idle_rule = implicit 1-step sequence, unchanged | existing profile.idle_rule.{action,follow_up,assign,resolve} | unchanged | Lenient read: steps absent → existing code path byte-identical | §7 Backward compat, S04 |
Feature flag ai_agent_sequential_idle, default OFF, per-org targeting | system_preferences row (global kill-switch) + organizations.settings['ai_agent_sequential_idle'] (org targeting) | — (config) | Save-time: validator rejects steps when OFF. Runtime: steps ignored (legacy read) when OFF | §7 Feature flag, Decision 6 |
| Step telemetry (fired / failed / reset / resolved) | histories.parameters.idle_rule enriched with step_index, step_count, is_last_step + Rollbar on failure | BI derives from history rows (no in-repo event bus — see §3 Monitoring, deviation flagged) | insert_history merge (process_idle_rule_action.rb:112-133) + Rollbar.error/warning | §12 |
Detail 1.A — PRD Traceability Matrix
Forward (PRD AC → RFC):
| PRD composite AC id | Service / endpoint / job | RFC section |
|---|---|---|
| IDLE-ADJ-S01/AC-1..3, ERR-1..3, NEG-1..2 | POST /v2/ai_agents, PATCH /v2/ai_agents/:id idle_rule.steps contract + validator | §2.4, Detail 2.0, §4.C chunk 1 |
| IDLE-ADJ-S02/AC-1..4 | SendMessageAfterSend#schedule_ai_agent_idle_rule steps branch + ProcessIdleRuleAction steps executor + re-arm | §2 Decision 2/5, Detail 2.2, §4.C chunks 3–4 |
| IDLE-ADJ-S02/ERR-1..2 | Bounded re-schedule + idempotency check (Decision 4) | §2 Decision 4, Detail 2.A/2.C, §4.C chunk 4 |
| IDLE-ADJ-S02/NEG-1 | Steps mode bypasses execute_global_idle_rule | §2 Decision 5, Detail 3.A.1 |
| IDLE-ADJ-S02/NEG-2 | Runtime steps.first(3) guard | Detail 2.3 shape rules, §4.C chunk 4 |
| IDLE-ADJ-S03/AC-1..3, ERR-1..2, NEG-1 | has_new_reply? abort + fresh chain on next agent reply | §2 Decision 3, Detail 2.2 reset sequence, Detail 2.B |
| IDLE-ADJ-S04/AC-1..3, ERR-1, NEG-1 | Lenient read — steps absent ⇒ legacy path unchanged | §2 Decision 5, §4 Compatibility, §4.C chunk 4 AC |
Reverse (RFC → PRD AC):
| New endpoint / table / service / dependency | PRD composite AC id it serves |
|---|---|
idle_rule.steps param on POST/PATCH /v2/ai_agents (extended, no new endpoint) | IDLE-ADJ-S01/AC-1..3 |
IdleRuleSteps validator (new file) | IDLE-ADJ-S01/ERR-2..3, NEG-1 |
step_index/attempt fields on ProcessIdleRuleActionWorker job args | IDLE-ADJ-S02/AC-1..3, S03/AC-1..2 |
ai_agent_sequential_idle flag (SystemPreferences + org settings) | IDLE-ADJ-S01 Permission Model, S04/AC-3 |
| History-row idempotency check before step execution | IDLE-ADJ-S02/ERR-1..2, S03/ERR-1..2 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads | Required writes | Status surface |
|---|---|---|---|---|
| AI Agent → Advanced Settings → Idle action form (step-list builder, CHG-002) | web (chatbot-fe) | GET /api/frontend_service/v2/ai_agents/:id — response profile.idle_rule.steps (extended entity) | POST /api/frontend_service/v2/ai_agents, PATCH …/:id — request profile.idle_rule.steps | Saved config echoed on reload; validation errors field-level (Detail 3.B) |
| Runtime idle behavior (CHG-003) | messaging channels via chat hub | n/a — system-driven | n/a — system-driven | history rows parameters.idle_rule.step_index; room is_closed/closed_reason after a resolve step |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted | Cross-tenant? | Audit trail |
|---|---|---|---|---|
| Chatbot Specialist (Qontak internal) / Customer Bot Builder | JWT + set_role(%w[owner supervisor admin]) (ai_agents_controller.rb:160,189,214,248) + Middlewares::Ownership + Middlewares::Subscription (ai_agents_controller.rb:7-8) | GET /, GET /summary, GET /:id, POST /, PATCH /:id on /api/frontend_service/v2/ai_agents | no — org-scoped by Ownership middleware | has_paper_trail on AiAgent (app/models/ai_agent.rb:5) versions every config change |
| System / worker (runtime execution) | Internal Sidekiq — no user credential | n/a — use-case invocation, not HTTP | no — room/agent looked up by org-scoped repositories | history row per fired step |
PRD Section Coverage
| PRD § | Title | Where covered |
|---|---|---|
| 2 | Adjustment Context | §1 Overview |
| 3 | One-liner + Problem | §1 Overview |
| 4 | Target Users + Persona | Detail 1.A Role Coverage |
| 5 | Non-Goals | §1 Out of Scope |
| 6 | Scope Changes | §1 Out of Scope (FE deferred), §2 (BE) |
| 7 | Constraints | §1 PRD-to-Schema (cap, compat, flag), §3 Performance |
| 8 | Feature Changes CHG-001/002/003 | CHG-001 → Detail 2.3; CHG-002 → n/a — FE RFC (contract frozen in §2.4); CHG-003 → Detail 2.2 + Decision 2–5 |
| 9 | API & Webhook Behavior | §2.4, Detail 2.A/2.C |
| 10 | System Flow + Stories + ACs | Detail 1.A, 1.C, Detail 2.1 state machine |
| 11 | Rollout | §4 Rollout Strategy |
| 12 | Observability | §3 Monitoring & Alerting (with deviation note) |
| 13 | Success Metrics | §1 Success Criteria, §3 Monitoring |
| 14 | Launch Plan & Stage Gates | §4 Rollout Strategy |
| 15 | Dependencies | §1 Dependencies |
| 16 | Key Decisions + Alternatives | §2 Technical Decisions (expanded to ADR) |
| 17 | Open Questions | §5 (each resolved or carried) |
| — | PRD Changelog | n/a — document history, no technical content |
Detail 1.B — Key Decisions Summary
| # | Decision | Chosen option | §2 block |
|---|---|---|---|
| 1 | Storage of steps | Additive steps[] inside existing profile.idle_rule JSONB — no migration | Decision 1 |
| 2 | Step pointer location (PRD OQ 1) | Carried on ProcessIdleRuleActionWorker job args (step_index), audit copy on history rows | Decision 2 |
| 3 | Reset-on-reply mechanism (PRD Assumption 4) | Existing has_new_reply? guard + natural chain restart — no new listener, no cancellation API | Decision 3 |
| 4 | Retry + idempotency (PRD OQ 2) | Bounded explicit re-schedule (max 3 attempts, 60 s apart, counter on job args) + history-row idempotency check. Corrects PRD's false "existing retry" assumption | Decision 4 |
| 5 | Self-containment vs global rule | Steps mode never calls execute_global_idle_rule; legacy path byte-identical | Decision 5 |
| 6 | Feature flag mechanism | SystemPreferences rollout kill-switch + organizations.settings org targeting | Decision 6 |
| 7 | closed_reason for sequence resolve (former OQ-2) | New value RESOLVE_AI_IDLE (new locale key); legacy resolve keeps RESOLVE | Decision 7 |
| 8 | Caching | none — config re-read from Postgres at each fire (existing behavior) | Decision 8 |
Detail 1.C — Per-Story Change Map
| Story id | Story title | Layer scope | Changes (concrete BE artifacts) | Composite AC ids covered | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|
| IDLE-ADJ-S01 | Configure a multi-step idle sequence | BE + FE consumes new (FE RFC pending — this RFC freezes the contract) | • ai_agents_controller.rb :ai_agent_profile → idle_rule.steps params (≤3)• entities/profile.rb + models/profile.rb → Step sub-entity• new use_cases/validators/idle_rule_steps.rb• flag check at save | S01/AC-1..3, ERR-1..3 (ERR-1 UI-side = API 422 here), NEG-1..2 | rspec spec/api/frontend_service/v2/ai_agent green: 3-step payload persists + echoes; 4-step payload → 422; empty message on follow_up/resolve → 422; duration 0 or >86400 → 422; flag OFF + steps → 422 | §2.4 · Detail 2.0 · §4.C chunk 1 |
| IDLE-ADJ-S02 | Execute the sequence step-by-step | Runtime / behavior (BE-only) | • send_message_with_resolve.rb#ai_agent_idle_rule? accepts steps shape• send_message_after_send.rb#schedule_ai_agent_idle_rule steps branch → schedule step 0• process_idle_rule_action.rb steps executor: fire steps[step_index] via existing handle_*, enrich history, re-arm step_index+1, skip global fallback | S02/AC-1..4, ERR-1..2, NEG-1..2 | rspec spec/core/use_cases/system/hub green: step 0 fires + step 1 scheduled with step 1's duration; resolve step terminal (no re-arm); assign step re-arms; failure → same step re-scheduled ≤3 attempts, pointer not advanced; global rule NOT invoked in steps mode; 4+ stored steps → only first 3 execute | Decision 2/4/5 · Detail 2.2 · §4.C chunks 3–4 |
| IDLE-ADJ-S03 | Reset on customer reply | Runtime / behavior (BE-only) | No new code path — covered by has_new_reply? abort (process_idle_rule_action.rb:57,135-143) + fresh chain from send_next_intent on the agent's next reply. Concurrency spec added | S03/AC-1..3, ERR-1..2, NEG-1 | Spec: pending step job with reply-after-anchor → aborts (returns Success(false), nothing sent); next chain starts at step 0; race spec: reply landing between check and fire never double-resolves (room is_closed guard) | Decision 3 · Detail 2.B · §4.C chunk 4 |
| IDLE-ADJ-S04 | Preserve legacy single-action behavior | Runtime / behavior + Config | Lenient read only — steps absent ⇒ every existing branch untouched incl. :77-83 global fallback; flag OFF ⇒ steps ignored at runtime, rejected at save | S04/AC-1..3, ERR-1, NEG-1 | Existing specs process_idle_rule_action_spec.rb + send_message_after_send_spec.rb pass unmodified; new spec: agent with steps stored but flag OFF fires legacy keys (or nothing if only steps present) | Decision 5 · §4 Compatibility · §4.C chunk 4 |
CHG-002 (FE form) is
n/a — covered in the FE RFC(not yet written; blocked on Figma). Every BE story appears exactly once above.
2. Technical Design
Infrastructure Topology
No new runtime component. The change lives entirely inside the existing
chatbot Rails monolith (API pods + Sidekiq worker pods) and its existing
Postgres + Redis + Sidekiq infrastructure. The only external touchpoint is
the existing internal ChatService send-message call already used by every
idle action today.
Deployment topology
flowchart TB
internet([Tenant browser]) -->|HTTPS| lb[Load Balancer]
lb -->|HTTP| api["chatbot API pods xN<br/>(Grape frontend_service)"]
api -->|read + write parameters JSONB| db[(Postgres primary<br/>ai_agents, rooms, histories)]
api -->|enqueue| redis[(Redis / Sidekiq queues<br/>default, live_event,<br/>send_message_high_throughput, vip_1)]
redis -->|consume| workers["chatbot Sidekiq worker pods xM<br/>ProcessIdleRuleMessageWorker<br/>ProcessIdleRuleActionWorker"]
workers -->|read config + write histories| db
workers -->|re-arm next step via perform_in| redis
workers -->|"send follow-up message (internal HTTPS)"| chat(["ChatService<br/>(existing internal service)"])
workers -->|enqueue| resolve[["ResolveRoomWorker /<br/>AssignAgentRoundRobinWorker /<br/>AssignAgentWorker (existing)"]]
Per-service responsibility
Single service (chatbot). Responsibilities within it:
flowchart LR
subgraph chatbot["chatbot (Rails monolith - BOT squad)"]
uc_save["POST + PATCH /v2/ai_agents<br/>(persist idle_rule.steps)"]
uc_arm["SendMessageAfterSend<br/>(schedule step N timer)"]
uc_fire["ProcessIdleRuleAction<br/>(fire step N, re-arm N+1)"]
end
uc_save -->|JSONB write| db[(Postgres ai_agents)]
uc_arm -->|"perform_in(duration)"| q[["Sidekiq<br/>ProcessIdleRuleActionWorker"]]
q --> uc_fire
uc_fire -->|follow_up text| chat(["ChatService - internal HTTPS,<br/>owner: chat squad"])
uc_fire -->|resolve / assign| w[["ResolveRoomWorker,<br/>AssignAgent*Worker (existing)"]]
uc_fire -->|"re-arm step N+1"| q
No third-party APIs are involved anywhere in this flow.
Technical Decisions (ADR format)
Decision 1: Store steps inside the existing profile.idle_rule JSONB — no migration
Context
The idle config already lives at ai_agents.parameters['profile']['idle_rule']
(runtime reads profile.idle_rule with a legacy fallback to top-level
idle_rule — send_message_with_resolve.rb:301,
process_idle_rule_action.rb:100,109). The PRD mandates backward-compatible
storage with no backfill.
Options considered
- Option A — additive
steps[]key inside the existing blob- Pros: zero migration; lenient read gives legacy compat for free; the
persistence path (
update_ai_agent.rb:88-98mergesprofilewholesale) needs no change;SyncToAiServiceignoresidle_ruleentirely (verified). - Cons: no DB-level integrity (enforced at API layer instead); config not queryable by SQL joins (acceptable — per-agent config, small).
- Pros: zero migration; lenient read gives legacy compat for free; the
persistence path (
- Option B — new
ai_agent_idle_stepsnormalized table- Pros: SQL-queryable, FK integrity.
- Cons: migration + repo + builder + entity plumbing for a ≤3-row array; diverges from every other advanced setting; PRD already rejected it.
Decision: Option A.
Rationale: The blob is schema-less by design, the write path is a
wholesale profile merge, and the cap of 3 makes normalization pure
overhead. Matches PRD Key Decision 2.
Consequences: Validation must be airtight at the Grape/entity/validator
layer since Postgres won't enforce anything; the runtime keeps a defensive
steps.first(3) guard for blobs written by any other path.
Reversibility: Trivial — stop reading steps; stored arrays become
inert keys in the blob (same property that makes flag-OFF safe).
Decision 2: Step pointer carried on the scheduled job args (resolves PRD Open Question 1)
Context The runtime must know which step a room is on. PRD offered three homes: room/session column, cache entry, or the scheduled job itself.
Options considered
- Option A —
step_indexon theProcessIdleRuleActionWorkerpayload- Pros: the chain already carries per-fire context this way
(
idle_rule,latest_history_id,worker_queue—send_message_after_send.rb:147-156); reset requires no cleanup because a stale job self-aborts onhas_new_reply?; zero migration; zero shared mutable state. - Cons: pointer is not queryable at rest (mitigated: each fire writes
step_indexintohistories.parameters.idle_rule, which is the audit trail BI already reads); a mid-sequence deploy that flushes Redis loses scheduled jobs (pre-existing property of ALL idle rules today, not new).
- Pros: the chain already carries per-fire context this way
(
- Option B — column on
rooms- Pros: queryable, survives Redis flush.
- Cons: migration on a hot table; needs explicit reset writes on every inbound message (new listener — contradicts PRD Assumption 4); write races between reset and advance become real row-lock contention.
- Option C — Redis key per room
- Pros: no migration.
- Cons: new TTL/eviction semantics to design; same explicit-reset burden as B; Redis eviction silently corrupts sequences.
Decision: Option A.
Rationale: It is the only option where reset-on-reply needs no new code (Decision 3) and it matches the exact pattern the chain uses today. The at-rest-queryability gap is covered by the history-row audit copy.
Consequences: "Cancel in-flight timer on reply" is implemented as ignore-at-fire-time, not eager cancellation — the PRD explicitly allows "cancelled/ignored" (S03/AC-1). Orphan jobs cost one no-op worker execution.
Reversibility: Pointer location is invisible to the API contract; moving to a room column later is additive.
Decision 3: Reset-on-reply via the existing has_new_reply? guard (confirms PRD Assumption 4)
Context A customer reply mid-sequence must prevent later steps and restart the sequence on the next idle window.
Options considered
- Option A — reuse the existing at-fire-time guard + natural chain restart
ProcessIdleRuleActionalready aborts when any history newer than the job'slatest_history_idanchor exists (process_idle_rule_action.rb:57,135-143→Repositories::Histories::HasNewReplyAfter). And every agent reply to a customer message re-entersSendMessageWithResolve#send_next_intent(send_message_with_resolve.rb:263-285), which arms a fresh chain from step 0. Reset is therefore emergent: old chain self-aborts, new chain starts at step 0.- Pros: zero new listeners; exactly the semantics S03 describes; the timer anchor is the agent's reply to the customer's new message — same measurement semantics as today's single action.
- Cons: the abort is checked at fire time, so a reply arriving in the
milliseconds between the
has_new_reply?check and the send can still let one step fire (TOCTOU) — this window exists today for the single action and is unchanged; double-resolve is separately blocked by theis_closedguard (process_idle_rule_action.rb:56).
- Option B — eager cancellation (track Sidekiq JIDs per room, delete on inbound)
- Pros: no orphan jobs.
- Cons: new inbound-path hook (latency on the hot path — violates PRD S7 Performance), JID bookkeeping store, and Sidekiq scheduled-set deletion is O(log N) scan — real cost for zero behavioral difference.
Decision: Option A — no new listener.
Rationale: S03's ACs are satisfiable with already-shipped guards; adding inbound-path work to save one no-op worker run is a bad trade.
Consequences: S03/ERR-1's race guarantee is "at most one of {advance, reset} wins" — delivered as: the fired step is the one that won the race; the pointer stays consistent because the new chain always starts at 0 and the old chain can never fire again past the new-reply anchor.
Reversibility: Eager cancellation can be layered on later without contract changes.
Decision 4: Bounded explicit re-schedule for failed steps (resolves PRD Open Question 2; corrects a PRD assumption)
Context
PRD S9 #3 / S02 ERR-1..2 assume a failed step "retries per existing worker
policy". Grounding shows both idle workers are retry: false
(process_idle_rule_message_worker.rb:5,
process_idle_rule_action_worker.rb:5) — today a failed idle action is
dropped silently. The PRD's intent (resolve retried, no silent skip) needs a
mechanism.
Options considered
- Option A — flip
sidekiq_options retry: NonProcessIdleRuleActionWorker- Pros: one line.
- Cons: changes legacy single-action behavior too (a class-level option) — violates S04's "byte-identical legacy" requirement; Sidekiq's exponential backoff (minutes→hours) is wrong for a user-visible timer.
- Option B — bounded explicit re-schedule, only in steps mode
- On rescued step-execution failure:
Rollbar.errorwithroom_id/step_index/attempt, then re-schedule the samestep_indexwithattempt + 1viaperform_in(60.seconds), capped at 3 attempts; after the cap, log terminal failure and stop (pointer never advanced past a failed step). - Pros: legacy path untouched; deterministic 60 s cadence; attempt counter
rides the same job args as
step_index(Decision 2); satisfies ERR-1/ERR-2 exactly ("pointer not advanced", "resolve retried", "no silent skip"). - Cons: ~15 lines of retry logic we own.
- On rescued step-execution failure:
Decision: Option B.
Idempotency key (the other half of PRD OQ 2): before executing step
i, the executor checks whether a history row newer than the job's
latest_history_id anchor already carries
parameters.idle_rule.step_index == i for this room — i.e. the key is
(room_id, latest_history_id anchor, step_index), realized as a
history-row existence check (histories are written in the same use case,
process_idle_rule_action.rb:112-133). If found (a retry after
post-execution crash), execution is skipped and the job proceeds straight
to re-arming step i+1. Combined with the is_closed guard, a resolve
can never double-fire and a follow_up can double-send only if the crash
lands exactly between ChatService accept and history insert — the same
partial-failure window every send in this codebase has today.
Consequences: A step can fire up to 3 × 60 s late in the worst retry
case — inside the PRD's ±1 min tolerance per attempt, and logged each time.
Reversibility: Delete the rescue block; behavior degrades to today's drop-on-failure.
Decision 5: Steps mode is self-contained — global channel idle rule never invoked; legacy path byte-identical
Context
The core problem is the hidden follow_up → global channel rule chaining
(process_idle_rule_action.rb:77-83); S04 simultaneously demands zero
change for legacy configs and non-AI-Agent flows.
Options considered
- Option A — branch at the top of the executor:
stepspresent + flag ON → new sequential path (which never touchesexecute_global_idle_rule); otherwise fall through to the existing method body, untouched- Pros: the legacy code path is provably unchanged (existing specs pass unmodified — that IS the regression gate); the removal of the coupling is scoped to exactly the new mode.
- Option B — rework the method to a unified step model where legacy = 1-step sequence internally
- Pros: one code path.
- Cons: silently changes legacy semantics (e.g. legacy
follow_up+ global rule chaining is used behavior per S04/AC-2); impossible to prove byte-identical; higher regression risk for zero user value.
Decision: Option A.
Rationale: S04 is a Must-Have regression story; "legacy = implicit 1-step sequence" is the PRD's conceptual framing, but the safest implementation of "behaves exactly as before" is "runs exactly the same code".
Consequences: Two branches live in ProcessIdleRuleAction until the
flag GAs and legacy configs are (eventually, out of scope) migrated.
Reversibility: Delete the steps branch.
Decision 6: Flag = SystemPreferences kill-switch + org-settings targeting
Context
PRD wants ai_agent_sequential_idle, default OFF, per-org targeting. The
repo has no LaunchDarkly-style flag system.
Options considered
- Option A —
SystemPreferencesrow (group_code: 'rollout',code: 'ai_agent_sequential_idle') as the global kill-switch, ANDorganizations.settings['ai_agent_sequential_idle'] == truefor per-org enablement. Feature active for an org ⇔ both are true.- Pros: both halves are shipped patterns — rollout-group flag:
ai_assist_image_processing(process_incoming_message_with_resolve.rb:120); org-settings read:@organization_settings.worker_queue/settings.dig(send_message_with_resolve.rb:99-103). Org settings is schema-less, so no migration. - Cons: org targeting is a manual settings write (ops console) — no self-serve UI. Acceptable for a staged beta of 2–3 orgs.
- Pros: both halves are shipped patterns — rollout-group flag:
- Option B — QontakBilling feature code (pattern:
chatbot_commercesubscription check,send_message_with_resolve.rb:1173)- Pros: commercial-grade gating.
- Cons: PRD says the feature inherits the existing AI Agent entitlement — it is a rollout flag, not a sellable add-on; billing round-trip on the idle path is needless.
Decision: Option A, checked at two points: save time (validator
rejects steps when inactive for the org → 422) and runtime
(ai_agent_idle_rule? ignores steps when inactive, reads legacy keys).
Consequences: Flag state must be readable in both the API context and
the worker context — both already load the organization
(send_message_with_resolve.rb:99), so the check is one helper used twice.
Reversibility: Kill-switch OFF instantly reverts every org to legacy
behavior; stored steps stay inert (Decision 1).
Decision 7: Sequence resolve writes closed_reason: RESOLVE_AI_IDLE (decided by DRI 2026-07-05, closing former OQ-2)
Context
Legacy resolve paths write RESOLVE (process_idle_rule_action.rb:163 →
I18n.t('model.room.closed_reason.resolve')), which downstream
containment/ROI reporting (the AI Agent Impact Report initiative, keyed on
rooms.closed_reason) cannot distinguish from idle-timeout or manual
closes. closed_reason is a free string field — no enum validation on the
model (app/models/room.rb:57) — and the locale table already carries the
RESOLVE/RESOLVE_AI/ASSIGN_AGENT_AI family
(config/locales/en.yml:343-347).
Options considered
- Option A — new value
RESOLVE_AI_IDLE: distinct attribution for AI-idle-sequence closes.- Pros: Impact Report can count sequence auto-closes precisely, separate
from both live-AI resolves (
RESOLVE_AI) and generic resolves; additive string, no enum to migrate. - Cons: downstream consumers (inbox filters, BI) see a new value — must be announced (see §4 Compatibility).
- Pros: Impact Report can count sequence auto-closes precisely, separate
from both live-AI resolves (
- Option B — reuse
RESOLVE_AI: pros: no new value; cons: conflates sequence auto-close with the live-conversation AI resolve the Impact Report treats as its hero containment metric. - Option C — keep
RESOLVE: pros: nothing changes; cons: the sequence's containment contribution stays unmeasurable — the exact gap OQ-2 raised.
Decision: Option A — add locale key
model.room.closed_reason.resolve_ai_idle: "RESOLVE_AI_IDLE" and pass it
from the steps-mode resolve execution. Legacy single-action resolve keeps
RESOLVE untouched (S04).
Consequences: the steps executor cannot call handle_resolve fully
verbatim — it passes a closed_reason override (small parameterization of
the existing method; legacy call site keeps its default). New value must be
whitelisted/labelled wherever closed_reason values are rendered or
filtered downstream (flagged in §4 Compatibility; Impact Report initiative
notified via this RFC's review).
Reversibility: one-line change back; historical rows would carry the value permanently (acceptable — append-only audit data).
Decision 8: Caching — none
Context / Options / Decision: no alternative considered — the agent
config is re-read from Postgres at each fire
(process_idle_rule_action.rb:99,108 Repositories::AiAgents::FindById /
AiAgent.find_by), which is at most once per step per room per idle window.
Caching would add invalidation complexity to save single-row primary-key
reads. Existing behavior, unchanged.
Consistency model: strong — single Postgres primary for config reads
and history writes; the only eventual element is Sidekiq scheduling, whose
staleness is exactly the has_new_reply? guard's job.
Multi-tenancy: unchanged — org scoping via Middlewares::Ownership at
the API and org-scoped repositories at runtime (Role Coverage table).
Sync vs async: all step work is async (perform_in) — the inbound
message path gains zero work (Decision 3).
Reuse vs new: no new endpoint, no new table, no new worker class — see
Existing Contracts table.
Detail 2.0 — Repo Reading Guide
Repo Map (mermaid)
flowchart LR
subgraph api["app/api/frontend_service/v2/ai_agent/"]
ctrl["ai_agents_controller.rb"]
ent["entities/profile.rb"]
mdl["models/profile.rb"]
val["use_cases/validators/"]
upd["use_cases/update_ai_agent.rb"]
end
subgraph core["app/core/use_cases/system/hub/"]
smwr["send_message_with_resolve.rb"]
smas["send_message_after_send.rb"]
pira["process_idle_rule_action.rb"]
end
subgraph wrk["app/workers/"]
w1["process_idle_rule_message_worker.rb"]
w2["process_idle_rule_action_worker.rb"]
end
ctrl --> upd --> db[(ai_agents.parameters JSONB)]
smwr --> w1 --> smas --> w2 --> pira
pira --> db2[(histories)]
Existing Code Anchors
| Path | Why the agent reads it | What pattern it teaches |
|---|---|---|
app/core/use_cases/system/hub/send_message_with_resolve.rb | Entry point of the idle chain; ai_agent_idle_rule? (L295-311) is where steps becomes schedulable | dual-key config read (profile.idle_rule ‖ idle_rule), source/source_id stamping, worker-queue resolution (L155-168), Rollbar context pattern (L135-139) |
app/core/use_cases/system/hub/send_message_after_send.rb | schedule_ai_agent_idle_rule (L131-158) is where step 0 gets scheduled | per-action validation before scheduling; ProcessIdleRuleActionWorker.perform_in(duration.seconds, payload) job-args shape |
app/core/use_cases/system/hub/process_idle_rule_action.rb | The executor this RFC branches; contains the :77-83 fallback being bypassed | guards (L56-57), fetch_idle_rule_config (L90-110), execute_action/handle_* (L145-195) reused per step verbatim, insert_history enrichment point (L112-133) |
app/workers/process_idle_rule_action_worker.rb + process_idle_rule_message_worker.rb | The retry: false fact driving Decision 4 | sidekiq_options retry: false, queue: + UseCases::…parameters(HashWithIndifferentAccess.new(params)) unwrap idiom |
app/api/frontend_service/v2/ai_agent/ai_agents_controller.rb | The :ai_agent_profile params helper (idle_rule L40-63) to extend; routes + auth | Grape params helpers, set_role, Ownership/Subscription middlewares, per-route desc blocks feeding Swagger |
app/api/frontend_service/v2/ai_agent/entities/profile.rb | Dry-struct IdleRule (L27-51) to extend with Step | ::Entities::AbstractEntity attribute style, Types::Coercible::Integer.optional.default(nil) |
app/api/frontend_service/v2/ai_agent/models/profile.rb | Grape response entity IdleRule (L26-57) to extend | expose … using: nesting, documentation: blocks Swagger reads |
app/api/frontend_service/v2/ai_agent/use_cases/update_ai_agent.rb | Persistence path (L88-98) — proves no write-path change is needed; SyncToAiService call site (L61) | wholesale profile merge into parameters; previous_parameters deep_dup for sync |
app/api/frontend_service/v2/ai_agent/use_cases/validators/capability_ref_presence.rb | The in-repo validator pattern the new IdleRuleSteps validator mirrors | cross-field validation as a separate validator object under use_cases/validators/ |
app/core/use_cases/system/hub/process_incoming_message_with_resolve.rb | Flag pattern for Decision 6 | Repositories::SystemPreferences::FindBy.new({ code: …, group_code: 'rollout', enabled: true }) (L120) |
Existing Contracts to Reuse, Extend, or Replace
| Contract | Status | Justification | Owner |
|---|---|---|---|
POST /api/frontend_service/v2/ai_agents | extended — idle_rule.steps added to the :ai_agent_profile params helper | — | BOT squad |
PATCH /api/frontend_service/v2/ai_agents/:id | extended — same helper | — | BOT squad |
GET /api/frontend_service/v2/ai_agents/:id + GET / + GET /summary | extended — response entity exposes steps when present | — | BOT squad |
ProcessIdleRuleActionWorker job args | extended — step_index, attempt keys added; absent = legacy | — | BOT squad |
histories.parameters.idle_rule | extended — step_index, step_count, is_last_step merged in (steps mode only) | — | BOT squad |
ResolveRoomWorker, AssignAgentRoundRobinWorker, AssignAgentWorker, Repositories::ChatService::SendMessage | reused verbatim via existing handle_* methods | — | BOT / chat squad |
execute_global_idle_rule (process_idle_rule_action.rb:232-256) | reused untouched for legacy + non-AI-Agent flows; bypassed in steps mode | — | BOT squad |
No new-with-justification contracts — this RFC introduces zero new endpoints, tables, or worker classes.
Patterns to Follow
| Concern | Pattern in repo | Reference file | Deviation in this RFC? |
|---|---|---|---|
| HTTP handler shape | Grape controller + params helper + desc + set_role | app/api/frontend_service/v2/ai_agent/ai_agents_controller.rb | none |
| Use-case shape | contract do + Dry::Monads::Do.for(:result) + Success()/Failure() | app/core/use_cases/system/hub/process_idle_rule_action.rb | none |
| Worker shape | include Sidekiq::Worker + sidekiq_options + use-case delegation | app/workers/process_idle_rule_action_worker.rb | per-job bounded re-schedule added in steps mode (Decision 4) — class options untouched |
| Validation | separate validator object under use_cases/validators/ | …/validators/capability_ref_presence.rb | none |
| Error/log reporting | Rollbar.warning/.error with structured context | send_message_with_resolve.rb:135-139 | none |
| Feature flag | SystemPreferences::FindBy rollout row | process_incoming_message_with_resolve.rb:120 | + org-settings targeting layer (Decision 6) |
Reading Order for the Agent
app/core/use_cases/system/hub/send_message_with_resolve.rb— L263-311 only: how the chain starts, whatidle_rulecarries.app/core/use_cases/system/hub/send_message_after_send.rb— whole file (163 lines): how step 0 gets its timer.app/core/use_cases/system/hub/process_idle_rule_action.rb— whole file: guards, executor, fallback, history write.app/workers/process_idle_rule_action_worker.rb+process_idle_rule_message_worker.rb— 12 lines each:retry: false.app/api/frontend_service/v2/ai_agent/ai_agents_controller.rb— L19-63 (params helpers) + L247-326 (POST/PATCH).app/api/frontend_service/v2/ai_agent/entities/profile.rb— the dry-struct to extend.app/api/frontend_service/v2/ai_agent/models/profile.rb— the response entity to extend.app/api/frontend_service/v2/ai_agent/use_cases/update_ai_agent.rb— L54-98: why persistence needs no change.spec/core/use_cases/system/hub/process_idle_rule_action_spec.rb+send_message_after_send_spec.rb— the regression gate.docs/openapi/CONVENTIONS.md— required for chunk 5 (OpenAPI bundle update).
Source Verification (anti-hallucination — required)
| Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|
send_message_with_resolve.rb#ai_agent_idle_rule? | read | L295-311: idle_rule = ai_agent.parameters&.dig('profile', 'idle_rule') || ai_agent.parameters&.dig('idle_rule'); sets idle_rule['source'] = 'ai_agent', source_id |
send_message_with_resolve.rb#send_next_intent | read | L263-285: enqueues ProcessIdleRuleMessageWorker.set(queue: @config_worker_queue).perform_async({… idle_rule: idle_rule, worker_queue: …}) |
send_message_after_send.rb#schedule_ai_agent_idle_rule | read | L131-158: validates action/duration/assign target, then ProcessIdleRuleActionWorker.set(queue: …).perform_in(send_in, {room_id:, …, idle_rule:, latest_history_id: latest_history&.id …}) |
process_idle_rule_action.rb guards + fallback | read | L56 chatbot_room.is_closed.present? abort; L57 has_new_reply?; L61-68 immediate global fallback; L77-83 follow_up → execute_global_idle_rule; L135-143 Repositories::Histories::HasNewReplyAfter |
process_idle_rule_action.rb executor + history | read | L145-151 execute_action dispatch; L157-165 handle_resolve → ResolveRoomWorker with closed_reason: I18n.t('model.room.closed_reason.resolve'); L112-133 insert_history with parameters: (latest_history&.parameters || {}).merge(idle_rule: idle_rule) |
Workers retry: false | read | process_idle_rule_message_worker.rb:5 and process_idle_rule_action_worker.rb:5: sidekiq_options retry: false, queue: … |
| v2 controller params + routes + auth | read + grep | idle_rule params block L40-63; routes get '/' L159, get '/summary' L188, get '/:id' L213, post '/' L247, patch '/:id' L292; set_role(%w[owner supervisor admin]) L160/189/214/248; use Middlewares::Ownership / Subscription L7-8; mounted at /v2/ai_agents (app/api/frontend_service/api.rb:59) |
| Dry-struct + Grape entities | read | entities/profile.rb L27-51 class IdleRule < ::Entities::AbstractEntity with Assign/Resolve/FollowUp; models/profile.rb L26-57 class IdleRule < Grape::Entity, exposed L79 |
| Persistence path | read | update_ai_agent.rb L88-98: new_parameters = existing_parameters.merge('profile' => valid_params[:profile]&.as_json …); SyncToAiService call L61 |
| SyncToAiService ignores idle_rule | grep | grep -rn "idle_rule" app/core/repositories/sync_to_ai_service.rb → 0 hits |
| Flag patterns | grep + read | process_incoming_message_with_resolve.rb:120 SystemPreferences::FindBy…group_code: 'rollout'; send_message_with_resolve.rb:99-103 org settings load |
| Validator pattern | ls | app/api/frontend_service/v2/ai_agent/use_cases/validators/capability_ref_presence.rb exists |
| Audit trail | read | app/models/ai_agent.rb:4-5 acts_as_paranoid + has_paper_trail |
closed_reason free-string + locale family (Decision 7) | read + grep | app/models/room.rb:57 plain field :closed_reason (no enum validation); config/locales/en.yml:343-347 keys assign_agent, assign_agent_ai, resolve, resolve_ai — resolve_ai_idle added beside them |
| Regression specs | ls | spec/core/use_cases/system/hub/process_idle_rule_action_spec.rb, send_message_after_send_spec.rb; API specs under spec/api/frontend_service/v2/ai_agent/ |
| Test/lint commands | read | repo AGENTS.md Commands section: bundle exec rspec, rubocop, brakeman, bundle exec fasterer, bundle exec reek; OpenAPI: ruby scripts/openapi/split.rb, npx --yes @apidevtools/swagger-cli validate docs/openapi/openapi.yaml, npx --yes @stoplight/spectral-cli lint docs/openapi/openapi.yaml --fail-severity=error |
Detail 2.1 — Architecture
Component diagram
flowchart TB
fe([chatbot-fe form - future FE RFC]) -->|"POST / PATCH /v2/ai_agents"| ctrl[AiAgentsController]
ctrl --> valdt[IdleRuleSteps validator - new]
ctrl --> upd[UpdateAiAgent use case]
upd --> db[(ai_agents.parameters JSONB)]
agentreply([AI agent replies to customer]) --> smwr[SendMessageWithResolve]
smwr -->|perform_async| w1[[ProcessIdleRuleMessageWorker]]
w1 --> smas[SendMessageAfterSend]
smas -->|"perform_in(steps 0 duration, step_index 0)"| w2[[ProcessIdleRuleActionWorker]]
w2 --> pira[ProcessIdleRuleAction]
pira -->|"steps mode: fire step, write history, re-arm N+1"| w2
pira --> hist[(histories)]
pira --> chat(["ChatService send"])
pira --> rw[[ResolveRoomWorker / AssignAgent workers]]
Data model
No DDL. The affected JSONB shape and its relations:
erDiagram
AI_AGENTS ||--o{ HISTORIES : "config fired as"
ROOMS ||--o{ HISTORIES : has
AI_AGENTS {
uuid id PK
jsonb parameters "profile.idle_rule.steps added here"
}
ROOMS {
int id PK
bool is_closed "guards double-resolve"
text closed_reason "RESOLVE_AI_IDLE written by sequence resolve step"
}
HISTORIES {
int id PK
int room_id FK
jsonb parameters "idle_rule + step_index audit copy"
}
steps shape (inside parameters.profile.idle_rule, additive next to
the legacy keys):
{
"action": "follow_up",
"follow_up": { "duration": 600, "message": "legacy key — untouched" },
"steps": [
{ "order": 1, "action": "follow_up", "duration": 600, "message": "Are you still there?" },
{ "order": 2, "action": "follow_up", "duration": 600, "message": "Last reminder before we close" },
{ "order": 3, "action": "resolve", "duration": 600, "message": "Closing for now — reach out anytime" }
]
}
Rules: 1–3 entries; order = 1-based array position (server re-derives,
client value not trusted); action ∈ {follow_up, assign, resolve};
duration integer seconds 1..86400; message required for
follow_up/resolve, optional for assign; assign object (same shape as
legacy: type ∈ {auto, division, agent} + division{id,name} /
agent{id,name}) required when action == "assign". Runtime defensively
executes steps.first(3).
- Cardinality: ≤3 array entries per agent; agents per org typically < 20 — no growth concern.
- Example rows: above.
- PII: step
messageis tenant-authored template text (same class as existing idle messages) — no new PII. - Retention: follows
ai_agentsrow (acts_as_paranoid soft delete); history rows follow existinghistoriesretention. - Per-status lifecycle: n/a — no status enum introduced; room lifecycle
(
is_closed/closed_reason) unchanged. - Partitioning: none.
- NoSQL alternative: n/a — data already lives in Postgres JSONB by established convention (Decision 1).
State machine — sequence lifecycle (per room, steps mode)
stateDiagram-v2
[*] --> Armed: agent replies, chain armed with step_index 0
Armed --> Firing: step duration elapses, worker fires
Firing --> Aborted: has_new_reply or room closed
Firing --> Fired: handle action succeeds, history written
Firing --> RetryWait: execution error, attempt below 3
RetryWait --> Firing: 60s re-schedule, same step_index
Firing --> Halted: attempt cap reached, terminal log
Fired --> Armed2: more steps remain, re-arm step_index plus 1
Armed2 --> Firing
Fired --> Closed: step action was resolve
Fired --> Open: last step was follow_up, room stays open
Aborted --> [*]: next agent reply starts fresh at step 0
Halted --> [*]
Closed --> [*]
Open --> [*]: next inbound restarts cycle
Branch & skip flow — mode selection at fire time
flowchart TD
fire([ProcessIdleRuleAction invoked]) --> closed{room closed?}
closed -- yes --> stop([abort, Success false])
closed -- no --> reply{has_new_reply?}
reply -- yes --> stop
reply -- no --> cfg["fetch_idle_rule_config (re-read agent JSONB)"]
cfg --> mode{"steps present AND flag active for org?"}
mode -- no --> legacy["existing method body, unchanged<br/>(incl. lines 77-83 global fallback)"]
mode -- yes --> idem{"history for this step_index<br/>already written after anchor?"}
idem -- yes --> rearm
idem -- no --> exec["execute steps at step_index via existing handle_*"]
exec --> hist["insert_history + step_index, step_count, is_last_step"]
hist --> term{"action resolve OR last step?"}
term -- yes --> done([sequence ends — global rule NOT invoked])
term -- no --> rearm["re-arm worker: step_index+1,<br/>delay = next step duration,<br/>anchor = new history id"]
rearm --> done2([Success true])
Detail 2.2 — Sequence diagrams
Happy path — 3-step sequence fires to resolution
sequenceDiagram
participant Cust as Customer (channel)
participant API as chatbot API pod
participant DB as Postgres primary
participant Q as Sidekiq (Redis)
participant W as Worker pod
participant Chat as ChatService (internal)
Note over API: agent sends its reply to the customer
API->>Q: ProcessIdleRuleMessageWorker.perform_async (idle_rule with steps)
Q->>W: SendMessageAfterSend
W->>Q: ProcessIdleRuleActionWorker.perform_in(600s, step_index 0, anchor h0)
Note over Q: 600s pass, customer silent
Q->>W: fire step 0
W->>DB: room open? new reply after h0? config re-read
DB-->>W: clear
W->>Chat: send "Are you still there?"
Chat-->>W: accepted
W->>DB: insert history h1 (step_index 0, step_count 3)
W->>Q: perform_in(600s, step_index 1, anchor h1)
Note over Q: 600s pass, still silent — step 1 fires same way, writes h2, arms step 2
Q->>W: fire step 2 (resolve)
W->>DB: guards pass, config re-read
W->>Q: ResolveRoomWorker.perform_async (closing message, closed_reason RESOLVE_AI_IDLE)
W->>DB: insert history h3 (step_index 2, is_last_step true)
Note over W: no re-arm — resolve is terminal, global rule never invoked
Reset path — customer replies mid-sequence (IDLE-ADJ-S03)
sequenceDiagram
participant Cust as Customer (channel)
participant Hub as chatbot inbound hub
participant DB as Postgres primary
participant Q as Sidekiq (Redis)
participant W as Worker pod
Note over Q: step 1 job scheduled (anchor h1)
Cust->>Hub: "sorry, I am back"
Hub->>DB: inbound history h2 written (existing path, no new listener)
Note over Hub: agent replies, SendMessageWithResolve runs again
Hub->>Q: fresh chain armed at step_index 0, anchor h3
Note over Q: old step 1 job fires later
Q->>W: fire step 1 (anchor h1)
W->>DB: HasNewReplyAfter(h1)?
DB-->>W: yes — h2 exists
W-->>Q: abort, Success(false) — nothing sent, old chain dead
Note over Q: only the fresh chain (step 0) remains live
Failure path — step execution error, bounded retry (Decision 4)
sequenceDiagram
participant Q as Sidekiq (Redis)
participant W as Worker pod
participant DB as Postgres primary
participant Chat as ChatService (internal)
Q->>W: fire step 1 (attempt 0)
W->>DB: guards pass
W->>Chat: send follow-up
Chat--xW: 5xx / timeout (raised)
W->>W: rescue — Rollbar.error(room_id, step_index 1, attempt 0)
W->>Q: perform_in(60s, step_index 1, attempt 1) — pointer NOT advanced
Q->>W: re-fire step 1 (attempt 1)
W->>DB: idempotency check — history with step_index 1 after anchor?
DB-->>W: none (send never succeeded)
W->>Chat: send follow-up
Chat-->>W: accepted
W->>DB: insert history (step_index 1)
W->>Q: re-arm step 2 normally
Note over W: after attempt 3 the step halts with a terminal Rollbar error — no silent skip
Detail 2.3 — Database Model (DDL)
No DDL. No table is created or altered (Decision 1). The complete data
contract is the JSONB shape in Detail 2.1, validated at the API layer and
guarded at runtime. Migration section of §4 is therefore none.
Detail 2.4 — APIs
No new endpoint. All changes are extensions to the existing
/api/frontend_service/v2/ai_agents surface (mounted at
app/api/frontend_service/api.rb:59). Per repo AGENTS.md API rules, the
same PR must update the Grape desc/params blocks, the response entities,
docs/openapi/openapi.yaml, regenerate docs/openapi/dist/, and pass
swagger-cli + spectral (§4.C chunk 5).
Outbound endpoints (consumers call us)
| Endpoint | Method | AuthN/AuthZ | Request schema delta | Response schema delta | Status codes | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/api/frontend_service/v2/ai_agents | POST | JWT + set_role(owner/supervisor/admin) + Ownership + Subscription | profile.idle_rule.steps[] (optional, 1–3, shape per Detail 2.1) | echoes steps | 201 / 400 / 401 / 403 / 422 (steps validation) / 500 | n/a — create | additive, no version bump | extended |
/api/frontend_service/v2/ai_agents/:id | PATCH | same | same | same | 200 / 400 / 401 / 403 / 404 / 422 / 500 | full-merge PATCH per existing semantics (profile replaced wholesale — update_ai_agent.rb:92) | additive | extended |
/api/frontend_service/v2/ai_agents/:id | GET | same | — | profile.idle_rule.steps exposed when present (legacy keys always echoed for compat) | 200 / 401 / 403 / 404 / 500 | n/a | additive | extended |
/api/frontend_service/v2/ai_agents + /summary | GET | same | — | list/summary entities unchanged unless they embed profile (verify at implementation — models/get_ai_agents.rb) | 200 / 401 / 403 / 500 | n/a | additive | extended |
Example — PATCH request fragment (steps mode) and 422 response:
{ "profile": { "idle_rule": { "steps": [
{ "order": 1, "action": "follow_up", "duration": 600, "message": "Are you still there?" },
{ "order": 2, "action": "resolve", "duration": 600, "message": "Closing for now." }
] } } }
{ "error": "IDLE_RULE_STEPS_INVALID", "message": "steps[1].message is required for action resolve", "details": { "index": 1, "field": "message" } }
(Exact error envelope follows the surface's existing
error_response(errors) helper — see Detail 3.B.)
- Rate limits / payload size: unchanged (existing middleware stack).
- Pagination: unchanged (list endpoints untouched beyond entity exposure).
- Backward compatibility:
stepsoptional everywhere; consumers that never send/read it are unaffected. Flag OFF ⇒ requests carryingstepsare rejected 422 (deliberate, per PRD S7 flag semantics).
Inbound webhooks
n/a — no service calls us in this flow; the idle chain is internally scheduled.
Detail 2.A — Data Integrity Matrix
| Write path | Transaction scope | Partial failure behavior | Idempotency key + TTL | Consistency | Duplicate handling | Stale-read handling |
|---|---|---|---|---|---|---|
Save steps (POST/PATCH) | single-row ai_agents update (existing use case) | Grape/validator rejects before write — no partial write (PRD S9 #1) | n/a — full-merge PATCH, last-write-wins (existing semantics) | strong | PaperTrail versions every write | FE reloads config after save (existing) |
| Fire step → send/assign/resolve | no DB txn spans the send (matches existing behavior); history insert after action | crash between send-accept and history insert → retry may re-send one follow_up (window identical to every existing send); resolve protected by is_closed guard | (room_id, anchor history id, step_index) — history-row existence check, no TTL (Decision 4) | strong (primary reads) | idempotency check skips execution, proceeds to re-arm | config re-read at fire time — a config edit mid-sequence applies from the next fire |
| Re-arm next step | Sidekiq enqueue after history insert | enqueue fails → sequence stalls, terminal Rollbar error (retry path covers rescued errors) | job args carry full context — re-enqueue safe | eventual (scheduler) | old jobs self-abort via has_new_reply? | anchor id always the just-written history |
Detail 2.B — Concurrency Collision Map
| Resource | Writers | Collision scenario | Resolution mechanism | Behavior on conflict |
|---|---|---|---|---|
| Room idle state (which step fires next) | scheduled step job vs inbound customer reply | reply lands while step job is due (S03/ERR-1) | at-fire-time HasNewReplyAfter check against the job's anchor; new chain always starts at step 0 | at most one wins: reply before check → step aborts; reply in the TOCTOU ms after check → step sends once, but every LATER step aborts (window unchanged from today's single action) |
| Room closed state | resolve step vs human agent resolving vs another resolve retry | double resolve | chatbot_room.is_closed guard at L56 + ResolveRoomWorker idempotent close | second resolve aborts, Success(false) |
| Agent config | tenant editing config vs mid-flight sequence | steps edited between step N and N+1 | config re-read at each fire (fetch_idle_rule_config) | next fire uses new config; if steps removed → steps-mode branch not taken, job aborts harmlessly |
| Same step double-fire | retry after post-send crash | duplicate follow_up | history-row idempotency check (Decision 4) | execution skipped, re-arm proceeds |
Detail 2.C — Async Job / Event Consumer Spec
| Job | Trigger | Input shape | Retry | DLQ | Concurrency limit | Idempotency key | Per-message timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
ProcessIdleRuleMessageWorker (existing, unchanged) | perform_async on agent reply | existing payload + idle_rule.steps passes through opaque | retry: false (unchanged) | none (Sidekiq default dead set only on retryable jobs — n/a) | Sidekiq queue concurrency (default/live_event/send_message_high_throughput/vip_1) | n/a — scheduling only | Sidekiq default | failure = chain never armed; logged by Sidekiq |
ProcessIdleRuleActionWorker (existing class, extended args) | perform_in(step duration) / perform_in(60s) on retry | existing payload + step_index (int, 0-based), attempt (int, default 0); both absent ⇒ legacy | class retry: false unchanged; steps mode adds bounded app-level re-schedule, max 3 attempts, 60 s fixed (Decision 4) | none — after attempt 3, terminal Rollbar.error and halt (pointer frozen; next inbound restarts sequence) | same queue set | (room_id, anchor history id, step_index) via history existence | Sidekiq default | malformed steps blob → steps.first(3) + per-field nil-guards → falls back to abort with Rollbar warning, never raises out |
Detail 2.D — Responsibility Boundary Matrix
n/a — single service, single squad (BOT). The only cross-squad surface is ChatService message delivery, reused verbatim through the existing repository with no contract change. (Design/FE dependencies are tracked in §1 Dependencies, not runtime boundaries.)
Detail 2.E — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| AI Agent config | profile.idle_rule.steps | absent (legacy) | POST/PATCH /v2/ai_agents | GET /v2/ai_agents/:id | none — read-your-write |
| Room | is_closed, closed_reason (RESOLVE_AI_IDLE on a sequence resolve step — Decision 7; legacy single-action resolve keeps RESOLVE) | open | ResolveRoomWorker (existing, closed_reason passed by caller) | existing room APIs / inbox | seconds (async worker) |
| Sequence progress (audit) | histories.parameters.idle_rule.step_index / step_count / is_last_step | — | ProcessIdleRuleAction#insert_history | BI over histories (ai_activity_logs datamart family) | append-only |
3. High-Availability & Security
HA posture is unchanged: stateless API pods; all sequence state rides Sidekiq scheduled jobs + Postgres history rows. If workers are down, step jobs queue and fire late (existing property of every idle rule); if Redis is flushed, in-flight sequences are lost and re-arm on the next agent-customer exchange (pre-existing property, see Decision 2 cons — explicitly accepted). Postgres outage stops the world equally for every chatbot flow; no new failure domain is introduced.
Performance Requirement
- Zero added work on the inbound message path (Decision 3) — the PRD's headline constraint.
- Step fire accuracy: within ±1 min of configured duration under normal Sidekiq scheduler load (same tolerance as today's single action; PRD S7).
- Added load: ≤ 2 extra scheduled jobs per idle room per cycle (steps 2–3), each a single-row config read + one history insert. Negligible against existing queue volume; no HPA change.
- Load test: n/a — no new hot path; covered by the staging QA matrix (concurrency specs + a soak of ≥50 simultaneous 3-step rooms on staging).
Monitoring & Alerting
Deviation from PRD §12 (flagged for PM ack): the PRD names five
analytics events (ai_agent_idle_sequence_saved, …_step_fired,
…_sequence_reset, …_sequence_resolved, …_step_failed). Grounding
found no in-repo analytics event bus on the BE idle path (grep for
activity-log emitters: 0 hits in app/core / lib). Rather than invent an
event system, this RFC delivers the same observables through the two
channels the repo already has:
| PRD event | Delivered as |
|---|---|
ai_agent_idle_sequence_saved | PaperTrail version on ai_agents + derivable from config reads; step_count/actions visible in the stored JSONB |
ai_agent_idle_step_fired | history row with parameters.idle_rule.step_index (+ step_count, is_last_step, action) |
ai_agent_idle_sequence_reset | derivable: inbound history newer than a step-N history with no step-N+1 following |
ai_agent_idle_sequence_resolved | room closed_reason = RESOLVE_AI_IDLE (Decision 7 — directly countable by the Impact Report) + the resolve step's history row |
ai_agent_idle_step_failed | Rollbar.error('AI agent idle step failed', room_id:, ai_agent_id:, step_index:, attempt:, reason:) — naming pattern follows the existing Rollbar context call at send_message_with_resolve.rb:135-139 |
The BI ai_activity_logs datamart family already consumes history-grain
rows; wiring these fields into a dashboard is a BI task, not a BE emit
change. If the PM requires true product-analytics events, that is an
upstream dependency to declare (see §5 OQ-3).
- Alert: Rollbar occurrence threshold on the new error class — investigate when failed-step occurrences exceed 2% of fired steps in 24 h (PRD §12 cadence; ratio computed in BI over history rows).
- Dashboard: BOT squad AI Agent telemetry board (Metabase, existing family).
- 3 am runbook: given a room id — (1) pull its histories, read
parameters.idle_rule.step_indextrail; (2) check Rollbar for the room id; (3) check Sidekiq scheduled set for pendingProcessIdleRuleActionWorkerjobs with that room id; (4) kill-switch = disable the SystemPreferences row (reverts every org to legacy instantly).
Logging
- Structured Rollbar context on every failure/abort in steps mode:
room_id,ai_agent_id,step_index,attempt,reason— no message bodies. - PII: step
messagecontent is never logged (only sent through the existing ChatService path).
Security Implications
- Threat model: tenant-authored config executed by a system worker. Entry points: the two write endpoints (authenticated, org-scoped) — no new public surface, no webhook, no third-party call.
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | Additional constraint | Audit trail |
|---|---|---|---|---|---|
| owner / supervisor / admin | /api/frontend_service/v2/ai_agents{,/:id,/summary} | GET, POST, PATCH (DELETE pre-existing, untouched) | own org only (Middlewares::Ownership) | steps accepted only when flag active for the org (else 422) | PaperTrail on AiAgent |
| all other roles | same | — | — | 403 via set_role (existing) | — |
| system worker | n/a — internal use-case invocation | — | org-scoped repositories | — | history rows |
- Ownership validation: existing
Middlewares::Ownership+ org-scoped repository lookups — enforcement unchanged. - Input validation per field: Detail 2.3 rules, enforced in Grape params +
IdleRuleStepsvalidator; strings length-capped at the surface's existing message limits;division.id/agent.idtreated as opaque refs exactly as the legacyassignobject does today. - Injection: no raw SQL anywhere in the path (ActiveRecord + repositories); step messages go through the same ChatService payload path as all existing idle messages (no interpolation into queries or shell).
- Secrets: none touched.
- Audit: PaperTrail versions on config change; per-fire history rows.
- Rate limiting: existing surface middleware; no new user-triggered endpoint.
- Tenancy isolation: unchanged (see matrix).
- Static analysis:
bundle exec brakemanmust stay clean (repo no-failure policy,AGENTS.md). - Public exposure: none new.
- ISO 27001/27701: no new data class, no new processor — inherits the platform posture.
Detail 3.A — Failure Mode & Retry Catalog
| Call | Timeout | Retries | Circuit breaker | DLQ | Behavior on persistent failure |
|---|---|---|---|---|---|
| ChatService send (follow_up step) | existing repository client timeout (unchanged) | step-level: 3 attempts × 60 s (Decision 4) | none (matches existing path) | none — terminal Rollbar error, pointer frozen | sequence halts at the failed step; next customer inbound + agent reply restarts at step 0 |
ResolveRoomWorker / AssignAgent*Worker enqueue | in-process | enqueue itself is in-process Redis — failure raises → caught by Decision 4 retry | n/a | n/a | as above |
| Postgres reads/writes | Rails pool timeout | raise → Decision 4 retry | n/a | n/a | as above |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Checked where | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
steps present + flag active | ProcessIdleRuleAction top-of-method mode branch | sequential executor; global channel idle rule skipped (IDLE-ADJ-S02/NEG-1) | history rows carry step fields | yes — behavior |
steps absent or flag inactive | same | legacy body runs unchanged, incl. :77-83 fallback (IDLE-ADJ-S04) | unchanged | no |
| customer replied since anchor | has_new_reply? (L57) | step + all later steps of old chain skipped | job no-ops (Success false) | yes — no unwanted nudge |
| room already closed | L56 guard | step skipped | job no-ops | no |
| step already executed (retry) | idempotency history check | execution skipped, re-arm proceeds | existing history row is the record | no |
| stored steps beyond index 2 | runtime steps.first(3) | ignored (IDLE-ADJ-S02/NEG-2) | Rollbar warning once per fire | no |
last step is follow_up | terminal check | sequence ends, room stays open (PRD S7 Step ordering; FE shows hint — FE RFC) | history is_last_step: true | yes — room remains open |
Detail 3.B — Error Response Catalog
Error envelope: the surface's existing error_response(errors) helper
shape (AGENTS.md API rules).
| Endpoint | Error code | HTTP | Message | When | User-facing? |
|---|---|---|---|---|---|
POST/PATCH /v2/ai_agents | IDLE_RULE_STEPS_INVALID | 422 | steps must contain between 1 and 3 entries | >3 or empty array supplied | yes |
| same | IDLE_RULE_STEPS_INVALID | 422 | steps[i].action must be one of follow_up, assign, resolve | unknown action | yes |
| same | IDLE_RULE_STEPS_INVALID | 422 | steps[i].duration must be between 1 and 86400 seconds | out-of-range duration | yes |
| same | IDLE_RULE_STEPS_INVALID | 422 | steps[i].message is required for action follow_up/resolve | missing message | yes |
| same | IDLE_RULE_STEPS_INVALID | 422 | steps[i].assign target is required for action assign | missing/invalid assign target (mirrors send_message_after_send.rb:140-144 rules) | yes |
| same | IDLE_RULE_STEPS_NOT_ENABLED | 422 | sequential idle steps are not enabled for this organization | flag inactive for org | yes |
| existing 401/403/404 | — | — | unchanged | — | yes |
Detail 3.C — Compliance & Data Governance
N/A — no compliance trigger; verified no new PII / payment / health / audit data touched. Step messages are tenant-authored bot copy, the same
data class as the existing single-action idle messages stored in the same
JSONB today.
4. Backwards Compatibility and Rollout Plan
Compatibility
- Existing endpoints: request/response shapes additively extended —
stepsoptional; no field removed or retyped; legacyidle_rulekeys echoed unchanged. - Legacy stored configs: read leniently —
stepsabsent ⇒ the exact existing code path executes (Decision 5). No backfill, no migration, no dual-write. - Non-AI-Agent flows:
execute_global_idle_ruleand the channel global rule untouched (PRD Non-Goal 2). - New
closed_reasonvalueRESOLVE_AI_IDLE(Decision 7): additive string onrooms.closed_reason— downstream consumers (inbox filters, BI/Metabase reports, the AI Agent Impact Report initiative) must be notified to label/whitelist it. Legacy values unchanged. - Compatibility window: indefinite — legacy single-action configs are supported permanently in this RFC's scope.
- Consumer notification: FE squad consumes the frozen contract via the FE RFC; OpenAPI bundle update in the same PR is the notification artifact.
Rollout Strategy
- Migration sequence: none (no DDL).
- Feature flag:
ai_agent_sequential_idle— SystemPreferences (group_code: 'rollout') kill-switch, default absent/OFF + per-orgorganizations.settings['ai_agent_sequential_idle']targeting (Decision 6). Kill-switch behavior: flip OFF ⇒ every org reverts to legacy at the next step fire (in-flight jobs take the legacy/no-op branch); storedstepsstay inert. - Stages (mirrors PRD §11/§14):
- Internal QA (staging, 1 wk) — flag ON for Qontak test org only. Go: all S01–S04 ACs green incl. concurrency specs; 0 step failures in test matrix.
- Closed beta (2–3 design-partner orgs, 2 wks) — org-settings targeting. Go: ≥1 live nudge→resolve sequence completed; failed-step ratio < 2%; no P1/P2.
- GA — kill-switch ON globally; org enablement per plan tier (Commercial decision, PRD OQ 3).
- Rollback trigger: failed-step ratio ≥ 2% over 24 h, or any double-send / double-resolve incident, or Rollbar spike on the new error class.
- Rollback mechanism: (1) org-level: unset the org settings key; (2) global: disable the SystemPreferences row; (3) code-level: revert PR. Data written during rollout (steps arrays, enriched history rows) is inert under flag OFF — no cleanup required.
- Blast radius worst case: AI-Agent-idle-configured rooms of enabled orgs only; legacy orgs structurally unreachable by the new branch.
- PIC + timeline:
delivery/artifacts once handed off (per governance).
Detail 4.A — Configuration Contract
| Config | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
system_preferences row code: ai_agent_sequential_idle, group_code: rollout, enabled | boolean row | absent (OFF) | yes (for any rollout) | BOT squad ops — same mechanism as existing rollout rows (e.g. ai_assist_image_processing); exact provisioning route (console vs data migration) confirmed with the team, see §5 OQ-4 | no |
organizations.settings['ai_agent_sequential_idle'] | boolean in JSONB | absent (OFF) | per targeted org | BOT squad ops console | no |
No new env vars.
Detail 4.B — Test Plan (commands sourced from repo AGENTS.md — Commands / Integrity sections)
| Layer | Command | What it must prove |
|---|---|---|
| Unit/UC — runtime | bundle exec rspec spec/core/use_cases/system/hub/process_idle_rule_action_spec.rb spec/core/use_cases/system/hub/send_message_after_send_spec.rb | legacy specs pass unmodified (S04 gate) + new steps contexts: fire/re-arm/terminal/retry/idempotency/abort |
| API | bundle exec rspec spec/api/frontend_service/v2/ai_agent | S01 contract: persist/echo/422 matrix incl. flag-OFF rejection |
| Full suite | bundle exec rspec | no cross-cutting regression (repo integrity rule) |
| Lint | bundle exec rubocop | no violations (no-failure policy) |
| Security | bundle exec brakeman | no new warnings |
| Perf/smell | bundle exec fasterer && bundle exec reek | advisory gates per AGENTS.md |
| OpenAPI | npx --yes @apidevtools/swagger-cli validate docs/openapi/openapi.yaml && npx --yes @stoplight/spectral-cli lint docs/openapi/openapi.yaml --fail-severity=error | bundle valid after chunk 5 |
| Load | n/a — no new hot path (see §3 Performance); staging soak in Stage 1 instead | — |
Detail 4.C — Agent Execution Plan
| Order | Chunk | Files to modify/create | Commands to run | Acceptance criteria |
|---|---|---|---|---|
| 1 | Contract: accept + expose steps | app/api/frontend_service/v2/ai_agent/ai_agents_controller.rb (:ai_agent_profile idle_rule block L40-63 → add steps array params, ≤3); entities/profile.rb (add Step < ::Entities::AbstractEntity, attribute :steps, Types::Array.of(Step).optional.default(nil) on IdleRule); models/profile.rb (mirror Grape::Entity Step, expose steps); new use_cases/validators/idle_rule_steps.rb (cross-field rules + flag check; pattern: capability_ref_presence.rb); wire validator in create/update use cases; new/updated specs in spec/api/frontend_service/v2/ai_agent/ | bundle exec rspec spec/api/frontend_service/v2/ai_agent && bundle exec rubocop | 422 matrix from Detail 3.B fully covered by specs; valid 1–3-step payload persists into parameters.profile.idle_rule.steps and echoes on GET; flag-OFF save rejected; legacy payloads (no steps) unaffected |
| 2 | Flag helper | small shared helper (suggested: app/core/use_cases/concerns/ or repository) sequential_idle_enabled?(organization) = SystemPreferences row enabled AND org settings true; spec | bundle exec rspec spec/core && bundle exec rubocop | helper truth table spec: OFF/OFF, ON/OFF, OFF/ON, ON/ON → only ON/ON active |
| 3 | Arm step 0 | app/core/use_cases/system/hub/send_message_with_resolve.rb — ai_agent_idle_rule? (L295-311): when helper active and steps present + non-empty, set idle_rule with steps payload (keep source/source_id stamping); send_message_after_send.rb — schedule_ai_agent_idle_rule (L131-158): steps branch → validate steps[0] with the existing per-action checks, perform_in(steps[0].duration.seconds, payload + step_index: 0, attempt: 0); specs | bundle exec rspec spec/core/use_cases/system/hub/send_message_after_send_spec.rb && bundle exec rubocop | new spec: steps config schedules worker with step 0 duration + step_index: 0; legacy contexts in the existing spec file pass unmodified; flag-inactive org with steps stored → legacy scheduling |
| 4 | Execute + re-arm + retry | app/core/use_cases/system/hub/process_idle_rule_action.rb — contract: add optional(:step_index), optional(:attempt), steps inside idle_rule hash schema; top-of-result mode branch (Decision 5); steps executor: idempotency check → execute_action(steps[i].action, steps[i]) (reuse handle_*; handle_resolve gains a closed_reason: keyword arg defaulting to today's RESOLVE, steps mode passes I18n.t('model.room.closed_reason.resolve_ai_idle') — Decision 7) → insert_history enriched (step_index, step_count, is_last_step) → re-arm i+1 with next duration + new anchor, unless terminal; rescue → bounded re-schedule (Decision 4); steps.first(3) guard; config/locales/en.yml (+ sibling locales) add model.room.closed_reason.resolve_ai_idle: "RESOLVE_AI_IDLE" next to the existing family (en.yml:343-347); specs incl. race + retry + NEG-1 (global rule not invoked) | bundle exec rspec spec/core/use_cases/system/hub/process_idle_rule_action_spec.rb && bundle exec rspec && bundle exec rubocop && bundle exec brakeman | ALL legacy contexts pass unmodified (incl. legacy resolve still writing RESOLVE); new contexts: 3-step happy path fires in order with correct delays; sequence resolve writes closed_reason: RESOLVE_AI_IDLE; resolve terminal; last-follow_up leaves room open; reply-after-anchor aborts; retry caps at 3 with pointer frozen; idempotent re-fire skips execution; stored 4th step ignored; execute_global_idle_rule never called in steps mode (spec asserts) |
| 5 | OpenAPI + docs | docs/openapi/openapi.yaml (extend the three ai_agents operations' schemas); regenerate docs/openapi/dist/ via ruby scripts/openapi/split.rb; docs/openapi/SESSION-LOG.md batch entry; architecture spoke: update the idle/bot-configuration flow doc per docs/architecture hub rules (set status: ready after self-review) | npx --yes @apidevtools/swagger-cli validate docs/openapi/openapi.yaml && npx --yes @stoplight/spectral-cli lint docs/openapi/openapi.yaml --fail-severity=error && ruby scripts/openapi/split.rb | both validators exit 0; dist/ regenerated and committed; SESSION-LOG entry present; spoke updated or flagged stale per AGENTS.md decision rules |
| 6 | Flag provisioning (staging) | ops: create SystemPreferences row (disabled), enable for Qontak test org settings | manual / console per §5 OQ-4 | staging org saves + runs a 3-step sequence end-to-end; QA matrix green |
Detail 4.D — Verification & Rollback Recipe
- Pre-merge verification commands (in order):
bundle exec rspecbundle exec rubocopbundle exec brakemannpx --yes @apidevtools/swagger-cli validate docs/openapi/openapi.yamlnpx --yes @stoplight/spectral-cli lint docs/openapi/openapi.yaml --fail-severity=error
- Post-deploy verification signals:
- Staging: a seeded 3-step room shows history rows with
parameters.idle_rule.step_index= 0,1,2 in order; roomclosed_reason = RESOLVE_AI_IDLEafter the resolve step. - Rollbar: zero occurrences of the new step-failure error class in the first 24 h of each stage.
- BI (Metabase, AI Agent board): failed-step ratio < 2% rolling 24 h.
- Staging: a seeded 3-step room shows history rows with
- Rollback recipe (in order):
- Unset
organizations.settings['ai_agent_sequential_idle']for the affected org (org-scoped incident), or disable theai_agent_sequential_idleSystemPreferences row (global). - Confirm next idle fires take the legacy branch (history rows stop
carrying
step_index). - If code defect: revert the PR (no DDL — clean revert), redeploy.
- Stored
stepsarrays require no cleanup (inert under flag OFF).
- Unset
Detail 4.E — Resource & Cost Notes
- Compute: no new pods; ≤ 2 extra scheduled jobs per idle room per cycle.
- DB: +1 single-row read and +1 history insert per extra step — noise against existing volume.
- Network egress: none new (internal ChatService only).
- Storage: history rows grow by ≤ 2 rows per fully-run sequence; steps JSON adds ≤ ~600 bytes per configured agent.
- New infra: none.
5. Concern, Questions, or Known Limitations
| # | Type | Item | Owner | Status |
|---|---|---|---|---|
| OQ-1 | Resolved here | PRD OQ 1 (pointer storage) → job args (Decision 2); PRD OQ 2 (idempotency) → (room, anchor, step_index) history check + bounded re-schedule (Decision 4); PRD Assumption 4 → confirmed (Decision 3) | — | closed by this RFC, pending review |
| OQ-2 | Resolved (DRI, 2026-07-05) | Sequence resolve writes the new closed_reason: RESOLVE_AI_IDLE (Decision 7); legacy resolve keeps RESOLVE. Downstream consumers notified via §4 Compatibility; Impact Report initiative gains a directly countable containment value. | DRI + BOT | closed |
| OQ-3 | Resolved (DRI ack, 2026-07-05) | PRD §12's five analytics events are delivered as history-row fields + Rollbar (§3 Monitoring deviation table) — accepted by the DRI; no product-analytics event dependency is opened. | DRI | closed |
| OQ-4 | Resolved (DRI, 2026-07-05) | Provision the SystemPreferences row through the same mechanism that created ai_assist_image_processing (rollout group) — accepted; implementer confirms the concrete route (console vs data migration) with the team during chunk 6, non-blocking. | BOT Eng | closed |
| OQ-5 | Carried from PRD | Plan tier for GA default-ON (PRD OQ 3, Commercial). | DRI | open, non-blocking for build |
| LIM-1 | Known limitation | TOCTOU window: a reply landing in the milliseconds between has_new_reply? and the send can let one step fire post-reply. Pre-existing for the single action; unchanged; later steps always abort. | — | accepted |
| LIM-2 | Known limitation | Redis flush loses in-flight scheduled steps (pre-existing for all idle rules). Sequence re-arms on next exchange. | — | accepted |
| LIM-3 | Known limitation | GET list/summary entities: verify at implementation whether they embed the full profile (then steps auto-appears) — chunk 1 spec covers it. | BOT Eng | to verify in chunk 1 (= review REV-4) |
| REV-1 | From review R1 (minor) | Pin the flag-helper file — recommend app/core/use_cases/concerns/sequential_idle_flag.rb instead of chunk 2's "concerns/ or repository". | BOT Eng | pin in chunk 2 PR |
| REV-2 | From review R1 (minor) | Name the idempotency query object — recommend Repositories::Histories::HasIdleStepAfter (sibling of HasNewReplyAfter), args history_id, room_id, step_index. | BOT Eng | pin in chunk 4 PR |
| REV-3 | From review R1 (minor) | Enumerate locale files carrying the closed_reason family beyond en.yml (ls config/locales/ + grep) so resolve_ai_idle lands in all of them. | BOT Eng | resolve in chunk 4 PR |
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-05 | rfc-starter (authoring) | All 10 mermaid blocks validated with mmdc (see PR description). Grounding corrected the PRD's "existing worker retry" assumption (Decision 4). |
| 2026-07-05 | Dimas Fauzi Hidayat (DRI) | Closed OQ-2 (sequence resolve → new closed_reason: RESOLVE_AI_IDLE, Decision 7), acked OQ-3 (history-row + Rollbar observability deviation), accepted OQ-4 (SystemPreferences provisioning route). §7 flipped to yes; mermaid re-validated after edits. |
| 2026-07-05 | Dimas Fauzi Hidayat (DRI) | ON HOLD — do not hand to delivery yet. Wait for the step-list builder Figma (PRD Dependency 1, designer unassigned) so the initiative's full picture (BE + FE) is locked before build. §7 BE readiness stands, but review/build kickoff is deferred until the design lands and the FE RFC can be authored against it. Reviewed R1 = 8.5 PROCEED (see sequential-idle-follow-up-review.md). |
7. Ready for agent execution
- yes — all former blockers resolved by the DRI on 2026-07-05 (OQ-2 →
RESOLVE_AI_IDLE, Decision 7; OQ-3 → monitoring deviation acked; OQ-4 → provisioning route accepted, concrete mechanism confirmed during chunk 6). - Gates: infrastructure topology ✔, ADR decisions ✔ (storage, sync/async, caching, third-party n/a, consistency, multi-tenancy, reuse/new, closed_reason), PRD-to-Schema ✔, Detail 1.B/1.C ✔, Repo Reading Guide + Source Verification ✔ (every anchor opened, evidence quoted), mermaid diagrams ✔ (topology, service, repo map, component, ER, state, branch/skip, 3 sequences incl. failure path — all 10 validated with mmdc), data contract ✔ (no DDL — JSONB shape + rules), APIs tagged ✔ (all
extended/reused, zero new), Data Integrity ✔, Concurrency Map ✔, Async Job Spec ✔, Failure/Branch/Error catalogs ✔, Configuration Contract ✔, Execution Plan with repo-sourced commands ✔, Verification & Rollback ✔.
Reviewed by
rfc-reviewer— R1: 8.5/10, PROCEED (seesequential-idle-follow-up-review.mdbeside this RFC). Hold (DRI, 2026-07-05): build kickoff deferred until the step-list builder Figma lands and the paired FE RFC is authored against it — the BE contract here is frozen in the meantime.