Skip to main content

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

FieldValueNotes
StatusDRAFT — open for engineering reviewYAML status: carries the linter enum (draft); review target: BOT squad BE tech reviewer + tech lead
DRIDimas Fauzi HidayatSingle accountable owner. Staffing lives in delivery/ once handed off.
TeamchatbotAdvisory squad slug carried from source PRD
Author(s)Claude (rfc-starter) + Dimas Fauzi HidayatGrounded against chatbot@master, 2026-07-05
Reviewerspending — BOT squad BE tech reviewerTo be assigned at review kickoff
Approver(s)pending — BOT squad tech lead + infosec approverTo be assigned at review kickoff
Submitted Date2026-07-05ISO-8601
Last Updated2026-07-06Bump on every material edit
Target Release2026-Q3Carried from PRD target_quarter
Target Quarter2026-Q3Advisory
Deliverynot yet handed to deliveryThe initiative has no delivery/timeline.md yet
RelatedPRD — Sequential Idle Follow-upADJUSTMENT PRD v1.1
Discussionpending — BOT squad channel thread to be opened at review

Type: backend Sub-type: enhancement

Sections at a Glance

  1. Overview (incl. §1 PRD-to-Schema Derivation)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequences → data model → APIs → integrity / concurrency / async specs)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. 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

  1. 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 carrying parameters.idle_rule.step_index 0, 1, 2.
  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).
  3. Zero behavior change for legacy single-action idle_rule configs with the flag OFF — the existing specs spec/core/use_cases/system/hub/process_idle_rule_action_spec.rb and send_message_after_send_spec.rb pass unmodified.
  4. No added latency on the live inbound-message path — step advancement is scheduled work (perform_in), never inline.
  5. 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-fe AiAgentIdleActionForm.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).

Assumptions

  1. 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).
  2. Repositories::SyncToAiService does not read idle_rule (verified — zero grep hits in app/core/repositories/sync_to_ai_service.rb), so adding steps cannot break the upstream skill-pack sync.
  3. 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

DependencyTypeOwnerStatus
ai_agent_sequential_idle SystemPreferences row + org-settings targetingInternal configBOT squadNeeds provisioning (Decision 6, §4 Detail 4.A)
FE step-list builder (separate FE RFC)Internal, cross-artifactBOT squad FEBlocked on Figma — BE contract frozen here
Figma for step-list builderDesignWulan (qontak-designer)In progress — change-request spec issued 2026-07-05: design-change-request-step-list-builder
No external/third-party APIsThe idle path only touches internal ChatService send + assign/resolve workers already in production

PRD-to-Schema Derivation (backend-specific — required)

PRD-described entity / attribute / rulePersisted asExposed viaEnforced whereSource (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 stepsGrape 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 errorNew 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 reststep_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 modehistories 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, unchangedexisting profile.idle_rule.{action,follow_up,assign,resolve}unchangedLenient read: steps absent → existing code path byte-identical§7 Backward compat, S04
Feature flag ai_agent_sequential_idle, default OFF, per-org targetingsystem_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 failureBI 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 idService / endpoint / jobRFC section
IDLE-ADJ-S01/AC-1..3, ERR-1..3, NEG-1..2POST /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..4SendMessageAfterSend#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..2Bounded re-schedule + idempotency check (Decision 4)§2 Decision 4, Detail 2.A/2.C, §4.C chunk 4
IDLE-ADJ-S02/NEG-1Steps mode bypasses execute_global_idle_rule§2 Decision 5, Detail 3.A.1
IDLE-ADJ-S02/NEG-2Runtime steps.first(3) guardDetail 2.3 shape rules, §4.C chunk 4
IDLE-ADJ-S03/AC-1..3, ERR-1..2, NEG-1has_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-1Lenient read — steps absent ⇒ legacy path unchanged§2 Decision 5, §4 Compatibility, §4.C chunk 4 AC

Reverse (RFC → PRD AC):

New endpoint / table / service / dependencyPRD 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 argsIDLE-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 executionIDLE-ADJ-S02/ERR-1..2, S03/ERR-1..2

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired readsRequired writesStatus 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.stepsSaved config echoed on reload; validation errors field-level (Detail 3.B)
Runtime idle behavior (CHG-003)messaging channels via chat hubn/a — system-drivenn/a — system-drivenhistory rows parameters.idle_rule.step_index; room is_closed/closed_reason after a resolve step

Role Coverage

PRD roleAuthorization mechanismEndpoints permittedCross-tenant?Audit trail
Chatbot Specialist (Qontak internal) / Customer Bot BuilderJWT + 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_agentsno — org-scoped by Ownership middlewarehas_paper_trail on AiAgent (app/models/ai_agent.rb:5) versions every config change
System / worker (runtime execution)Internal Sidekiq — no user credentialn/a — use-case invocation, not HTTPno — room/agent looked up by org-scoped repositorieshistory row per fired step

PRD Section Coverage

PRD §TitleWhere covered
2Adjustment Context§1 Overview
3One-liner + Problem§1 Overview
4Target Users + PersonaDetail 1.A Role Coverage
5Non-Goals§1 Out of Scope
6Scope Changes§1 Out of Scope (FE deferred), §2 (BE)
7Constraints§1 PRD-to-Schema (cap, compat, flag), §3 Performance
8Feature Changes CHG-001/002/003CHG-001 → Detail 2.3; CHG-002 → n/a — FE RFC (contract frozen in §2.4); CHG-003 → Detail 2.2 + Decision 2–5
9API & Webhook Behavior§2.4, Detail 2.A/2.C
10System Flow + Stories + ACsDetail 1.A, 1.C, Detail 2.1 state machine
11Rollout§4 Rollout Strategy
12Observability§3 Monitoring & Alerting (with deviation note)
13Success Metrics§1 Success Criteria, §3 Monitoring
14Launch Plan & Stage Gates§4 Rollout Strategy
15Dependencies§1 Dependencies
16Key Decisions + Alternatives§2 Technical Decisions (expanded to ADR)
17Open Questions§5 (each resolved or carried)
PRD Changelogn/a — document history, no technical content

Detail 1.B — Key Decisions Summary

#DecisionChosen option§2 block
1Storage of stepsAdditive steps[] inside existing profile.idle_rule JSONB — no migrationDecision 1
2Step pointer location (PRD OQ 1)Carried on ProcessIdleRuleActionWorker job args (step_index), audit copy on history rowsDecision 2
3Reset-on-reply mechanism (PRD Assumption 4)Existing has_new_reply? guard + natural chain restart — no new listener, no cancellation APIDecision 3
4Retry + 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" assumptionDecision 4
5Self-containment vs global ruleSteps mode never calls execute_global_idle_rule; legacy path byte-identicalDecision 5
6Feature flag mechanismSystemPreferences rollout kill-switch + organizations.settings org targetingDecision 6
7closed_reason for sequence resolve (former OQ-2)New value RESOLVE_AI_IDLE (new locale key); legacy resolve keeps RESOLVEDecision 7
8Cachingnone — config re-read from Postgres at each fire (existing behavior)Decision 8

Detail 1.C — Per-Story Change Map

Story idStory titleLayer scopeChanges (concrete BE artifacts)Composite AC ids coveredAcceptance criteria (verifiable)RFC anchors
IDLE-ADJ-S01Configure a multi-step idle sequenceBE + FE consumes new (FE RFC pending — this RFC freezes the contract)ai_agents_controller.rb :ai_agent_profileidle_rule.steps params (≤3)
entities/profile.rb + models/profile.rbStep 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..2rspec 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-S02Execute the sequence step-by-stepRuntime / 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..2rspec 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 executeDecision 2/4/5 · Detail 2.2 · §4.C chunks 3–4
IDLE-ADJ-S03Reset on customer replyRuntime / 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 addedS03/AC-1..3, ERR-1..2, NEG-1Spec: 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-S04Preserve legacy single-action behaviorRuntime / behavior + ConfigLenient read only — steps absent ⇒ every existing branch untouched incl. :77-83 global fallback; flag OFF ⇒ steps ignored at runtime, rejected at saveS04/AC-1..3, ERR-1, NEG-1Existing 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_rulesend_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-98 merges profile wholesale) needs no change; SyncToAiService ignores idle_rule entirely (verified).
    • Cons: no DB-level integrity (enforced at API layer instead); config not queryable by SQL joins (acceptable — per-agent config, small).
  • Option B — new ai_agent_idle_steps normalized 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_index on the ProcessIdleRuleActionWorker payload
    • Pros: the chain already carries per-fire context this way (idle_rule, latest_history_id, worker_queuesend_message_after_send.rb:147-156); reset requires no cleanup because a stale job self-aborts on has_new_reply?; zero migration; zero shared mutable state.
    • Cons: pointer is not queryable at rest (mitigated: each fire writes step_index into histories.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).
  • 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
    • ProcessIdleRuleAction already aborts when any history newer than the job's latest_history_id anchor exists (process_idle_rule_action.rb:57,135-143Repositories::Histories::HasNewReplyAfter). And every agent reply to a customer message re-enters SendMessageWithResolve#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 the is_closed guard (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: N on ProcessIdleRuleActionWorker
    • 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.error with room_id/step_index/attempt, then re-schedule the same step_index with attempt + 1 via perform_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.

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: steps present + flag ON → new sequential path (which never touches execute_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 — SystemPreferences row (group_code: 'rollout', code: 'ai_agent_sequential_idle') as the global kill-switch, AND organizations.settings['ai_agent_sequential_idle'] == true for 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.
  • Option B — QontakBilling feature code (pattern: chatbot_commerce subscription 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:163I18n.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).
  • 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

PathWhy the agent reads itWhat pattern it teaches
app/core/use_cases/system/hub/send_message_with_resolve.rbEntry point of the idle chain; ai_agent_idle_rule? (L295-311) is where steps becomes schedulabledual-key config read (profile.idle_ruleidle_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.rbschedule_ai_agent_idle_rule (L131-158) is where step 0 gets scheduledper-action validation before scheduling; ProcessIdleRuleActionWorker.perform_in(duration.seconds, payload) job-args shape
app/core/use_cases/system/hub/process_idle_rule_action.rbThe executor this RFC branches; contains the :77-83 fallback being bypassedguards (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.rbThe retry: false fact driving Decision 4sidekiq_options retry: false, queue: + UseCases::…parameters(HashWithIndifferentAccess.new(params)) unwrap idiom
app/api/frontend_service/v2/ai_agent/ai_agents_controller.rbThe :ai_agent_profile params helper (idle_rule L40-63) to extend; routes + authGrape params helpers, set_role, Ownership/Subscription middlewares, per-route desc blocks feeding Swagger
app/api/frontend_service/v2/ai_agent/entities/profile.rbDry-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.rbGrape response entity IdleRule (L26-57) to extendexpose … using: nesting, documentation: blocks Swagger reads
app/api/frontend_service/v2/ai_agent/use_cases/update_ai_agent.rbPersistence 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.rbThe in-repo validator pattern the new IdleRuleSteps validator mirrorscross-field validation as a separate validator object under use_cases/validators/
app/core/use_cases/system/hub/process_incoming_message_with_resolve.rbFlag pattern for Decision 6Repositories::SystemPreferences::FindBy.new({ code: …, group_code: 'rollout', enabled: true }) (L120)

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
POST /api/frontend_service/v2/ai_agentsextendedidle_rule.steps added to the :ai_agent_profile params helperBOT squad
PATCH /api/frontend_service/v2/ai_agents/:idextended — same helperBOT squad
GET /api/frontend_service/v2/ai_agents/:id + GET / + GET /summaryextended — response entity exposes steps when presentBOT squad
ProcessIdleRuleActionWorker job argsextendedstep_index, attempt keys added; absent = legacyBOT squad
histories.parameters.idle_ruleextendedstep_index, step_count, is_last_step merged in (steps mode only)BOT squad
ResolveRoomWorker, AssignAgentRoundRobinWorker, AssignAgentWorker, Repositories::ChatService::SendMessagereused verbatim via existing handle_* methodsBOT / chat squad
execute_global_idle_rule (process_idle_rule_action.rb:232-256)reused untouched for legacy + non-AI-Agent flows; bypassed in steps modeBOT squad

No new-with-justification contracts — this RFC introduces zero new endpoints, tables, or worker classes.

Patterns to Follow

ConcernPattern in repoReference fileDeviation in this RFC?
HTTP handler shapeGrape controller + params helper + desc + set_roleapp/api/frontend_service/v2/ai_agent/ai_agents_controller.rbnone
Use-case shapecontract do + Dry::Monads::Do.for(:result) + Success()/Failure()app/core/use_cases/system/hub/process_idle_rule_action.rbnone
Worker shapeinclude Sidekiq::Worker + sidekiq_options + use-case delegationapp/workers/process_idle_rule_action_worker.rbper-job bounded re-schedule added in steps mode (Decision 4) — class options untouched
Validationseparate validator object under use_cases/validators/…/validators/capability_ref_presence.rbnone
Error/log reportingRollbar.warning/.error with structured contextsend_message_with_resolve.rb:135-139none
Feature flagSystemPreferences::FindBy rollout rowprocess_incoming_message_with_resolve.rb:120+ org-settings targeting layer (Decision 6)

Reading Order for the Agent

  1. app/core/use_cases/system/hub/send_message_with_resolve.rb — L263-311 only: how the chain starts, what idle_rule carries.
  2. app/core/use_cases/system/hub/send_message_after_send.rb — whole file (163 lines): how step 0 gets its timer.
  3. app/core/use_cases/system/hub/process_idle_rule_action.rb — whole file: guards, executor, fallback, history write.
  4. app/workers/process_idle_rule_action_worker.rb + process_idle_rule_message_worker.rb — 12 lines each: retry: false.
  5. app/api/frontend_service/v2/ai_agent/ai_agents_controller.rb — L19-63 (params helpers) + L247-326 (POST/PATCH).
  6. app/api/frontend_service/v2/ai_agent/entities/profile.rb — the dry-struct to extend.
  7. app/api/frontend_service/v2/ai_agent/models/profile.rb — the response entity to extend.
  8. app/api/frontend_service/v2/ai_agent/use_cases/update_ai_agent.rb — L54-98: why persistence needs no change.
  9. spec/core/use_cases/system/hub/process_idle_rule_action_spec.rb + send_message_after_send_spec.rb — the regression gate.
  10. docs/openapi/CONVENTIONS.md — required for chunk 5 (OpenAPI bundle update).

Source Verification (anti-hallucination — required)

Anchor / pattern / contractVerified byEvidence
send_message_with_resolve.rb#ai_agent_idle_rule?readL295-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_intentreadL263-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_rulereadL131-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 + fallbackreadL56 chatbot_room.is_closed.present? abort; L57 has_new_reply?; L61-68 immediate global fallback; L77-83 follow_upexecute_global_idle_rule; L135-143 Repositories::Histories::HasNewReplyAfter
process_idle_rule_action.rb executor + historyreadL145-151 execute_action dispatch; L157-165 handle_resolveResolveRoomWorker 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: falsereadprocess_idle_rule_message_worker.rb:5 and process_idle_rule_action_worker.rb:5: sidekiq_options retry: false, queue: …
v2 controller params + routes + authread + grepidle_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 entitiesreadentities/profile.rb L27-51 class IdleRule < ::Entities::AbstractEntity with Assign/Resolve/FollowUp; models/profile.rb L26-57 class IdleRule < Grape::Entity, exposed L79
Persistence pathreadupdate_ai_agent.rb L88-98: new_parameters = existing_parameters.merge('profile' => valid_params[:profile]&.as_json …); SyncToAiService call L61
SyncToAiService ignores idle_rulegrepgrep -rn "idle_rule" app/core/repositories/sync_to_ai_service.rb → 0 hits
Flag patternsgrep + readprocess_incoming_message_with_resolve.rb:120 SystemPreferences::FindBy…group_code: 'rollout'; send_message_with_resolve.rb:99-103 org settings load
Validator patternlsapp/api/frontend_service/v2/ai_agent/use_cases/validators/capability_ref_presence.rb exists
Audit trailreadapp/models/ai_agent.rb:4-5 acts_as_paranoid + has_paper_trail
closed_reason free-string + locale family (Decision 7)read + grepapp/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_airesolve_ai_idle added beside them
Regression specslsspec/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 commandsreadrepo 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 message is tenant-authored template text (same class as existing idle messages) — no new PII.
  • Retention: follows ai_agents row (acts_as_paranoid soft delete); history rows follow existing histories retention.
  • 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)

EndpointMethodAuthN/AuthZRequest schema deltaResponse schema deltaStatus codesIdempotencyVersioningReuse?
/api/frontend_service/v2/ai_agentsPOSTJWT + set_role(owner/supervisor/admin) + Ownership + Subscriptionprofile.idle_rule.steps[] (optional, 1–3, shape per Detail 2.1)echoes steps201 / 400 / 401 / 403 / 422 (steps validation) / 500n/a — createadditive, no version bumpextended
/api/frontend_service/v2/ai_agents/:idPATCHsamesamesame200 / 400 / 401 / 403 / 404 / 422 / 500full-merge PATCH per existing semantics (profile replaced wholesale — update_ai_agent.rb:92)additiveextended
/api/frontend_service/v2/ai_agents/:idGETsameprofile.idle_rule.steps exposed when present (legacy keys always echoed for compat)200 / 401 / 403 / 404 / 500n/aadditiveextended
/api/frontend_service/v2/ai_agents + /summaryGETsamelist/summary entities unchanged unless they embed profile (verify at implementation — models/get_ai_agents.rb)200 / 401 / 403 / 500n/aadditiveextended

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: steps optional everywhere; consumers that never send/read it are unaffected. Flag OFF ⇒ requests carrying steps are 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 pathTransaction scopePartial failure behaviorIdempotency key + TTLConsistencyDuplicate handlingStale-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)strongPaperTrail versions every writeFE reloads config after save (existing)
Fire step → send/assign/resolveno DB txn spans the send (matches existing behavior); history insert after actioncrash 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-armconfig re-read at fire time — a config edit mid-sequence applies from the next fire
Re-arm next stepSidekiq enqueue after history insertenqueue fails → sequence stalls, terminal Rollbar error (retry path covers rescued errors)job args carry full context — re-enqueue safeeventual (scheduler)old jobs self-abort via has_new_reply?anchor id always the just-written history

Detail 2.B — Concurrency Collision Map

ResourceWritersCollision scenarioResolution mechanismBehavior on conflict
Room idle state (which step fires next)scheduled step job vs inbound customer replyreply 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 0at 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 stateresolve step vs human agent resolving vs another resolve retrydouble resolvechatbot_room.is_closed guard at L56 + ResolveRoomWorker idempotent closesecond resolve aborts, Success(false)
Agent configtenant editing config vs mid-flight sequencesteps edited between step N and N+1config 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-fireretry after post-send crashduplicate follow_uphistory-row idempotency check (Decision 4)execution skipped, re-arm proceeds

Detail 2.C — Async Job / Event Consumer Spec

JobTriggerInput shapeRetryDLQConcurrency limitIdempotency keyPer-message timeoutPoison handling
ProcessIdleRuleMessageWorker (existing, unchanged)perform_async on agent replyexisting payload + idle_rule.steps passes through opaqueretry: 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 onlySidekiq defaultfailure = chain never armed; logged by Sidekiq
ProcessIdleRuleActionWorker (existing class, extended args)perform_in(step duration) / perform_in(60s) on retryexisting payload + step_index (int, 0-based), attempt (int, default 0); both absent ⇒ legacyclass 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 existenceSidekiq defaultmalformed 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

EntityState field / eventDefaultUpdated byRead viaStale window
AI Agent configprofile.idle_rule.stepsabsent (legacy)POST/PATCH /v2/ai_agentsGET /v2/ai_agents/:idnone — read-your-write
Roomis_closed, closed_reason (RESOLVE_AI_IDLE on a sequence resolve step — Decision 7; legacy single-action resolve keeps RESOLVE)openResolveRoomWorker (existing, closed_reason passed by caller)existing room APIs / inboxseconds (async worker)
Sequence progress (audit)histories.parameters.idle_rule.step_index / step_count / is_last_stepProcessIdleRuleAction#insert_historyBI 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 eventDelivered as
ai_agent_idle_sequence_savedPaperTrail version on ai_agents + derivable from config reads; step_count/actions visible in the stored JSONB
ai_agent_idle_step_firedhistory row with parameters.idle_rule.step_index (+ step_count, is_last_step, action)
ai_agent_idle_sequence_resetderivable: inbound history newer than a step-N history with no step-N+1 following
ai_agent_idle_sequence_resolvedroom closed_reason = RESOLVE_AI_IDLE (Decision 7 — directly countable by the Impact Report) + the resolve step's history row
ai_agent_idle_step_failedRollbar.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_index trail; (2) check Rollbar for the room id; (3) check Sidekiq scheduled set for pending ProcessIdleRuleActionWorker jobs 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 message content 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

RoleEndpoint(s)MethodsTenant scopeAdditional constraintAudit 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 rolessame403 via set_role (existing)
system workern/a — internal use-case invocationorg-scoped repositorieshistory rows
  • Ownership validation: existing Middlewares::Ownership + org-scoped repository lookups — enforcement unchanged.
  • Input validation per field: Detail 2.3 rules, enforced in Grape params + IdleRuleSteps validator; strings length-capped at the surface's existing message limits; division.id/agent.id treated as opaque refs exactly as the legacy assign object 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 brakeman must 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

CallTimeoutRetriesCircuit breakerDLQBehavior 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 frozensequence halts at the failed step; next customer inbound + agent reply restarts at step 0
ResolveRoomWorker / AssignAgent*Worker enqueuein-processenqueue itself is in-process Redis — failure raises → caught by Decision 4 retryn/an/aas above
Postgres reads/writesRails pool timeoutraise → Decision 4 retryn/an/aas above

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerChecked whereDownstream effectAuditUser-visible?
steps present + flag activeProcessIdleRuleAction top-of-method mode branchsequential executor; global channel idle rule skipped (IDLE-ADJ-S02/NEG-1)history rows carry step fieldsyes — behavior
steps absent or flag inactivesamelegacy body runs unchanged, incl. :77-83 fallback (IDLE-ADJ-S04)unchangedno
customer replied since anchorhas_new_reply? (L57)step + all later steps of old chain skippedjob no-ops (Success false)yes — no unwanted nudge
room already closedL56 guardstep skippedjob no-opsno
step already executed (retry)idempotency history checkexecution skipped, re-arm proceedsexisting history row is the recordno
stored steps beyond index 2runtime steps.first(3)ignored (IDLE-ADJ-S02/NEG-2)Rollbar warning once per fireno
last step is follow_upterminal checksequence ends, room stays open (PRD S7 Step ordering; FE shows hint — FE RFC)history is_last_step: trueyes — room remains open

Detail 3.B — Error Response Catalog

Error envelope: the surface's existing error_response(errors) helper shape (AGENTS.md API rules).

EndpointError codeHTTPMessageWhenUser-facing?
POST/PATCH /v2/ai_agentsIDLE_RULE_STEPS_INVALID422steps must contain between 1 and 3 entries>3 or empty array suppliedyes
sameIDLE_RULE_STEPS_INVALID422steps[i].action must be one of follow_up, assign, resolveunknown actionyes
sameIDLE_RULE_STEPS_INVALID422steps[i].duration must be between 1 and 86400 secondsout-of-range durationyes
sameIDLE_RULE_STEPS_INVALID422steps[i].message is required for action follow_up/resolvemissing messageyes
sameIDLE_RULE_STEPS_INVALID422steps[i].assign target is required for action assignmissing/invalid assign target (mirrors send_message_after_send.rb:140-144 rules)yes
sameIDLE_RULE_STEPS_NOT_ENABLED422sequential idle steps are not enabled for this organizationflag inactive for orgyes
existing 401/403/404unchangedyes

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 extendedsteps optional; no field removed or retyped; legacy idle_rule keys echoed unchanged.
  • Legacy stored configs: read leniently — steps absent ⇒ the exact existing code path executes (Decision 5). No backfill, no migration, no dual-write.
  • Non-AI-Agent flows: execute_global_idle_rule and the channel global rule untouched (PRD Non-Goal 2).
  • New closed_reason value RESOLVE_AI_IDLE (Decision 7): additive string on rooms.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-org organizations.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); stored steps stay inert.
  • Stages (mirrors PRD §11/§14):
    1. 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.
    2. 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.
    3. 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

ConfigTypeDefaultRequiredProvisionerSecret?
system_preferences row code: ai_agent_sequential_idle, group_code: rollout, enabledboolean rowabsent (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-4no
organizations.settings['ai_agent_sequential_idle']boolean in JSONBabsent (OFF)per targeted orgBOT squad ops consoleno

No new env vars.

Detail 4.B — Test Plan (commands sourced from repo AGENTS.md — Commands / Integrity sections)

LayerCommandWhat it must prove
Unit/UC — runtimebundle 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.rblegacy specs pass unmodified (S04 gate) + new steps contexts: fire/re-arm/terminal/retry/idempotency/abort
APIbundle exec rspec spec/api/frontend_service/v2/ai_agentS01 contract: persist/echo/422 matrix incl. flag-OFF rejection
Full suitebundle exec rspecno cross-cutting regression (repo integrity rule)
Lintbundle exec rubocopno violations (no-failure policy)
Securitybundle exec brakemanno new warnings
Perf/smellbundle exec fasterer && bundle exec reekadvisory gates per AGENTS.md
OpenAPInpx --yes @apidevtools/swagger-cli validate docs/openapi/openapi.yaml && npx --yes @stoplight/spectral-cli lint docs/openapi/openapi.yaml --fail-severity=errorbundle valid after chunk 5
Loadn/a — no new hot path (see §3 Performance); staging soak in Stage 1 instead

Detail 4.C — Agent Execution Plan

OrderChunkFiles to modify/createCommands to runAcceptance criteria
1Contract: accept + expose stepsapp/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 rubocop422 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
2Flag helpersmall shared helper (suggested: app/core/use_cases/concerns/ or repository) sequential_idle_enabled?(organization) = SystemPreferences row enabled AND org settings true; specbundle exec rspec spec/core && bundle exec rubocophelper truth table spec: OFF/OFF, ON/OFF, OFF/ON, ON/ON → only ON/ON active
3Arm step 0app/core/use_cases/system/hub/send_message_with_resolve.rbai_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.rbschedule_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); specsbundle exec rspec spec/core/use_cases/system/hub/send_message_after_send_spec.rb && bundle exec rubocopnew 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
4Execute + re-arm + retryapp/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 brakemanALL 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)
5OpenAPI + docsdocs/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.rbboth validators exit 0; dist/ regenerated and committed; SESSION-LOG entry present; spoke updated or flagged stale per AGENTS.md decision rules
6Flag provisioning (staging)ops: create SystemPreferences row (disabled), enable for Qontak test org settingsmanual / console per §5 OQ-4staging 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):
    1. bundle exec rspec
    2. bundle exec rubocop
    3. bundle exec brakeman
    4. npx --yes @apidevtools/swagger-cli validate docs/openapi/openapi.yaml
    5. npx --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; room closed_reason = RESOLVE_AI_IDLE after 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.
  • Rollback recipe (in order):
    1. Unset organizations.settings['ai_agent_sequential_idle'] for the affected org (org-scoped incident), or disable the ai_agent_sequential_idle SystemPreferences row (global).
    2. Confirm next idle fires take the legacy branch (history rows stop carrying step_index).
    3. If code defect: revert the PR (no DDL — clean revert), redeploy.
    4. Stored steps arrays require no cleanup (inert under flag OFF).

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

#TypeItemOwnerStatus
OQ-1Resolved herePRD 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-2Resolved (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 + BOTclosed
OQ-3Resolved (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.DRIclosed
OQ-4Resolved (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 Engclosed
OQ-5Carried from PRDPlan tier for GA default-ON (PRD OQ 3, Commercial).DRIopen, non-blocking for build
LIM-1Known limitationTOCTOU 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-2Known limitationRedis flush loses in-flight scheduled steps (pre-existing for all idle rules). Sequence re-arms on next exchange.accepted
LIM-3Known limitationGET list/summary entities: verify at implementation whether they embed the full profile (then steps auto-appears) — chunk 1 spec covers it.BOT Engto verify in chunk 1 (= review REV-4)
REV-1From 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 Engpin in chunk 2 PR
REV-2From review R1 (minor)Name the idempotency query object — recommend Repositories::Histories::HasIdleStepAfter (sibling of HasNewReplyAfter), args history_id, room_id, step_index.BOT Engpin in chunk 4 PR
REV-3From 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 Engresolve in chunk 4 PR

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-05rfc-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-05Dimas 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-05Dimas 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 (see sequential-idle-follow-up-review.md beside 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.