Review of RFC — AI Spam Gatekeeper Phase 1: Silent Classification & Block (BE), cycles R1 (initial) + R2 (post-fix), 2026-07-14.
R2 summary (2026-07-14): all 7 R1 findings applied and verified fixed in the same-day RFC revision. PM confirmed the REV-2 product intent (spam message stays visible in the resolved room). Re-score: 8.5/10 — Agentic-Ready — PROCEED. No open findings on the chatbot-owned scope; the only remaining gates are the two by-design external dependencies (hub-core provenance,
ai_agent_alert).
RFC Review: AI Spam Gatekeeper Phase 1 — Silent Classification & Block (BE)
Executive Summary
A well-grounded backend RFC with unusually strong repo evidence (every anchor in Detail 2.0 carries session-verified file:line, several independently re-verified), clean decision hygiene (9 ADRs, honest "no alternative considered" where true), and a fail-open posture that correctly protects the messaging hot path. The cross-repo dependency boundaries (Detail 2.D) are exactly right: the core cost-saving path has zero hard runtime dependency on either external squad.
Two genuine gaps keep it out of the Agentic-Ready band, both at the core of
the feature rather than its edges: (1) the classifier prompt template — the
actual spec of the classification behavior — is never written down; an agent
reaching Chunk 2 must invent the system prompt, the policy-interpolation
format, and the few-shot examples from nothing. (2) The reply-suppression
side-effect contract on the high_confidence path is explicitly deferred
(§5 item 3) — and it hides a real product question the RFC doesn't surface:
whether the spam message itself remains visible in the resolved room. It must
(the "Bukan spam" review flow is impossible if the supervisor can't see what
was classified), which means suppression cannot simply mirror the legacy
early-return; insert_history/SendContext behavior needs pinning.
Both are fixable inline without redesign. Verdict: PROCEED with notes — fix REV-1 and REV-2 in the RFC before implementation starts; the rest can be addressed during Chunk review.
Quick Verdict
| Overall score | R1: 7.5 / 10 → R2: 8.5 / 10 |
| Rating band | R1 Strong (7.0–8.4) → R2 Agentic-Ready (8.5–10.0) |
| Agentic Readiness Verdict | R1: PROCEED with notes → R2: PROCEED — all 7 findings fixed; chatbot-owned scope has no open findings |
| RFC type / sub-type | backend / new-feature |
| Confidence | High — all sections present, consistent; R2 gaps closed with concrete spec (prompt template, suppression contract, resolve-idempotency table) |
| Mermaid validity | 6/6 blocks parse (mmdc-validated both cycles; happy-path sequence re-validated after the R2 suppression-note edit) |
Findings Ledger
| ID | Severity | Finding | Status | First seen | Resolved in |
|---|---|---|---|---|---|
| REV-1 | major | Classifier prompt template unspecified — the SystemPreference params_spam_classifier seed was only an envelope; system prompt, interpolation contract, and calibration examples absent. | fixed | R1 | R2 — Detail 4.A now carries the verbatim system prompt, {{CATEGORIES}}/{{DEFINITION}}/{{MESSAGE}} template, JSON contract, and a 6-row calibration table (incl. the disabled-category-never-matched assertion) |
| REV-2 | major | Reply-suppression side-effect contract undefined — did the spam message get insert_history'd / SendContext-pushed? Design decision (message must stay visible for SPAM-S04), not an implementation detail. | fixed | R1 | R2 — PM confirmed message-visibility; promoted into Decision 4 (insert_history runs, SendContext skipped, early return after insert) + happy-path sequence updated + Chunk 3 assertion added |
| REV-3 | minor | Resolve-of-already-closed-room "success-equivalent" claim asserted but unenumerated. | fixed | R1 | R2 — Detail 2.C resolve-response table (2xx / already-closed no-op / 4xx terminal / 5xx retryable) |
| REV-4 | minor | spam_policy PATCH contract lacked example payloads and a concrete 400 error body. | fixed | R1 | R2 — Detail 2.4 example request/success/two-400s/404 |
| REV-5 | minor | ProcessSpamVerdictWorker queue/concurrency unstated. | fixed | R1 | R2 — Detail 2.C pins Sidekiq default queue (follows ResolveRoomWorker), no cap needed |
| REV-6 | minor | GET exposure didn't name the entity file. | fixed | R1 | R2 — Detail 2.4 names entities/channel_integration/channel_integration.rb + the expose :spam_policy shape |
| REV-7 | minor | Redis-down skip indistinguishable from lost lock in telemetry. | fixed | R1 | R2 — distinct spam_classifier_lock_error event + alert added to Detail 3.A |
Review History
| Cycle | Date | Reviewed revision | Score | Verdict | Open / Fixed |
|---|---|---|---|---|---|
| R1 | 2026-07-14 | RFC last_updated 2026-07-14, working tree (uncommitted) | 7.5 | PROCEED with notes | 7 open / 0 fixed |
| R2 | 2026-07-14 | same-day post-fix revision, working tree (uncommitted) | 8.5 | PROCEED | 0 open / 7 fixed |
Delta R1 → R2: 7 findings fixed (2 major, 5 minor), 0 still open, 0 newly found. Score +1.0 (7.5 → 8.5). Both majors (REV-1 prompt, REV-2 suppression) closed with concrete spec, moving TDC/DMS off their caps and clearing the 9.0-band blocker on dangling decisions. Remaining gates are the two by-design external dependencies only.
PRD → RFC Traceability Matrix
PRD: Phase 1 — Silent Classification & Block v1.4 (READY).
| PRD item | RFC coverage | Verdict |
|---|---|---|
| SPAM-S01 (config + 4 ACs) | Chunk 1 + Detail 2.4 + 4.A | ✅ covered (REV-4/REV-6 polish) |
| SPAM-S01-NEG | Branch catalog: "not a new conversation" + retroactivity guard | ✅ |
| SPAM-S02 (classify/block + 4 ACs) | Chunks 2–4, Decisions 1/2/4/5/8, sequences | ✅ covered (REV-1/REV-2 gate the spec depth) |
| SPAM-S02-NEG (WA only) | Branch catalog + hook placement | ✅ |
| SPAM-S03 (tag + 4 ACs) | Chunk 4 fallback + Detail 2.C; is_create_tag: true pinned | ✅ |
| SPAM-S03-NEG (never block ambiguous) | Verdict dispatch + Data Integrity Matrix row 2 | ✅ |
| SPAM-S04 (undo) | Detail 2.D contract only — by design, Omnichannel-owned | ✅ as scoped (dependency honest) |
| SPAM-S05 (alert + NEG) | Chunk 5 emitter boundary; NEG spec-asserted | ✅ as scoped (delivery = dependency) |
| PRD §5 constraints (3s SLA, flag default, classification scope) | Decisions 2/3, Detail 4.A | ✅ |
| PRD §10 observability (8 events) | Chunk 5 acceptance: "all 8 PRD §10 events observable" | ✅ |
| PRD §9/§12 rollout + gates | §4 + Detail 4.D | ✅ |
| Reverse check (RFC → PRD) | Every net-new component (classifier repo, worker, emitter, lock, i18n entry) maps to a story or named decision; no scope creep found | ✅ |
Forward: 12/12 covered (2 as explicitly-bounded dependencies). Reverse: no unjustified additions.
Scorecard (backend — new-feature weighting)
| Category | Score | Evidence (one line) |
|---|---|---|
| PRT — PRD Traceability | 9.0 | Bidirectional matrix (Detail 1.A/1.C) incl. per-AC → chunk mapping; reverse clean |
| TDC — Technical Decisions | 7.5 | 9 ADRs with real alternatives; but Decision 2/4 jointly leave reply-suppression side-effects dangling (REV-2), and Decision 1's "prompt in SystemPreference" defers the prompt itself (REV-1) |
| DMS — Data Model & Schema | 7.5 | No-DDL claim is verified and correct; spam_policy JSON shape precise w/ provenance stamps; docked for the unspecified prompt-row content (the one piece of "data" an agent must author blind, REV-1) |
| ACV — API Contract & Versioning | 7.0 | Param table + validation + role gate + wire-compat reasoning present; missing example payloads/error bodies (REV-4), entity exposure vague (REV-6) |
| DIC — Data Integrity | 8.0 | Block-first invariant, NX lock, legacy-bucket separation all explicit; idempotency of resolve asserted but unenumerated (REV-3) |
| FMC — Failure Modes | 8.5 | Full catalog incl. the loud-fail on missing prompt row and the blocked-contact/open-room edge; per-call timeout/retry/fallback pinned; no-DLQ-needed reasoning holds (tag fallback is the terminal sink) |
| CSS — Concurrency & Scaling | 7.5 | Collision map covers the real races (incl. tenant-disable mid-flight); Chunk 6 load gate is honest; worker queue/concurrency unstated (REV-5) |
| SAS — Security | 8.0 | Prompt-injection surface named with a structural mitigation (enum-constrained output, channel-scoped blast radius); role gate reused; no new authz surface; rationale-in-logs-only |
| MRP — Migration & Rollout | 9.0 | No-migration verified; 4-level rollback ladder incl. no-deploy prompt-row disable; config contract complete; stage gates numeric |
| OBS — Observability | 8.5 | 8 named events w/ properties, numeric alert thresholds incl. page condition, hard FP pause trigger; REV-7 telemetry blind spot on Redis-down |
| SBC — Service Boundary | 9.0 | Detail 2.D is the best section of the RFC — per-step ownership, wire-compat sequencing, per-boundary graceful degradation |
| CPA — Pattern Alignment | 9.0 | Every pattern cites an actually-read reference file (summarization prompt, NX guards, retry conventions, Grape shape) |
| CDG — Compliance (triggered: PII/message content) | 7.5 | Data-processor channel argument sound (same OpenAI path as summarization); block-record reversibility explicit; right-to-delete inherits existing block semantics (implicit — one sentence would make it explicit) |
| RCS — Resource & Cost (advisory) | — | Strong: unit-economics framing (call cost vs 332 IDR saved), cohort ceiling quantified, spend visible in existing token reporting |
Score caps: none triggered (no category < 5.0). 9.0+ band requires no dangling decisions and DMS/DIC/FMC ≥ 8.5 — blocked by REV-1/REV-2.
Decision Closure Assessment
| # | Decision | Verdict |
|---|---|---|
| 1 | Squad-owned Gpt::Completion + SystemPreference prompt | Partial — backend choice fully resolved w/ alternatives; the prompt content/interpolation contract it defers is the classifier's actual spec (REV-1) |
| 2 | Inline classification, 3s, fail-open | Partial — call-site choice resolved; suppression side-effects on high_confidence unpinned (REV-2, cross-cuts Decision 4) |
| 3 | Config on remote_config.spam_policy | Resolved |
| 4 | ProcessSpamVerdictWorker, block-first, exhaustion→tag | Partial — ordering + fallback fully specified; resolve-idempotency classification unenumerated (REV-3); queue unstated (REV-5) |
| 5 | RESOLVE_AI_SPAM literal + i18n | Resolved |
| 6 | Cross-repo provenance contract | Resolved (dependency honestly bounded, wire-compat reasoned) |
| 7 | SpamAlertEmitter no-op boundary | Resolved |
| 8 | Redis NX lock | Resolved (REV-7 is telemetry polish, not the decision) |
| 9 | No caching | Resolved |
6 of 9 fully resolved, 3 Partial, 0 Dangling. The Partials share one root: the RFC is strongest where it grounds against existing code, and thinnest on the two artifacts that have no existing-code anchor (the prompt, the suppression semantics) — exactly where an agent has nothing to mirror.
Data Integrity Deep-Dive
| Write path | Transaction scope | Partial failure | Idempotency | Consistency | Duplicate handling |
|---|---|---|---|---|---|
| Contact block (hub-core, via HTTP) | hub-core-internal (existing) | worker retry ×3 → tag fallback; room never resolved without block 2xx | hub-core existing ContactBlock dedup | eventual (HTTP boundary) | re-run safe per existing interactor |
Room resolve RESOLVE_AI_SPAM | existing ResolveRoom path | retry re-runs whole chain; block idempotent; already-closed classification unpinned (REV-3) | claimed, mechanism unenumerated | eventual | treated-as-success asserted |
| "Suspected spam" tag | ChatService (existing) | best-effort; spam_tag_failed log only | existing 422-create-retry | eventual | tag re-apply is naturally idempotent |
spam_policy config write | single JSON key update | last-write-wins + provenance stamp | n/a | strong (single row) | n/a |
Concurrency Collision Map — verified
RFC Detail 2.B covers all five real collision surfaces including the two easy to miss (tenant-disable mid-flight; verdict worker vs. human takeover). No missing collision found by this review.
API Contract Completeness Check
PATCH /api/v1/channel_integrations/:id (extended): method/path ✅ · auth ✅
(named role gate w/ file:line) · request schema ✅ (fields/types/validation) ·
response schema ⚠️ (echo asserted, no example) · error taxonomy ⚠️ ("existing
error_response shape" — no concrete body) · idempotency ✅ (natural PATCH
last-write-wins + stamp) · examples ❌ (REV-4). GET exposure ⚠️ (REV-6).
Async Job / Event Consumer Spec
| Job | Trigger | Input shape | Retry | DLQ | Concurrency | Idempotency key | Timeout |
|---|---|---|---|---|---|---|---|
ProcessSpamVerdictWorker | verdict dispatch | room_id, contact_id, category, organization_id ✅ | ×3 + exhaustion hook ✅ | tag fallback is the terminal sink (reasoned — no DLQ needed) ✅ | ❌ unstated (REV-5) | room-scoped chain, idempotent by claim (REV-3) | inherits Sidekiq default — acceptable, HTTP timeouts pinned per call |
Compliance Trigger Check
Triggered (message content → OpenAI; "spam" labeling of a person). CDG scored (7.5). One-line improvement: state explicitly that right-to-delete/deletion of a blocked contact follows the existing ContactBlock deletion semantics.
Agentic Readiness Deep-Dive
Vague word audit: spec sections are largely free of weasel words; the two
instances of deferred language are explicitly ledgered by the RFC itself
(§5 items 3–5) rather than hidden — good hygiene, but item 3 is load-bearing
(REV-2). Dangling alternatives: none — every "or" in the RFC is resolved
or explicitly ledgered. Task decomposition: 6 chunks, each independently
mergeable behind the flag, each with assertable acceptance criteria and real
repo commands (AGENTS.md:52-79 verified) — strong.
Strengths
- Evidence discipline. The Source Verification table is the strongest this
reviewer has seen in this repo — every claim has file:line, several
double-verified, including the negative claims (
RESOLVE_AI_SPAMunused;ai_agent_alertzero code). - Boundary honesty. Detail 2.D states exactly what this RFC does NOT ship, who owns it, and how the system degrades at each boundary — the two external dependencies cannot silently block the core path.
- Fail-open coherence. One consistent principle (never degrade the messaging pipeline) applied at every failure point, each with a named log event and customer-impact statement.
Biggest Gaps
- The prompt is the spec, and it's missing (REV-1, Decision 1 / Detail 4.A).
- Suppression semantics hide a product decision (REV-2, Decision 2/4): the
spam message must stay visible in the resolved room for SPAM-S04 review —
the RFC neither states this nor specs the
insert_history/SendContextbehavior that delivers it. - Consumer-ready polish on the config API (REV-4/REV-6): example payloads, error bodies, and the named entity file.
Priority Actions (sequenced)
R2 (2026-07-14): all six actions below applied and verified. Retained as the R1 record.
- [REV-1] Add an appendix (or extend Detail 4.A) with the draft classifier
prompt: system prompt text, user-prompt template with named placeholders
(e.g.
{{CATEGORIES}},{{DEFINITION}},{{MESSAGE}}), the JSON-output instruction, and 2–3 calibration examples per verdict class. The synthetic accuracy matrix (Chunk 2) then tests against this, not an invention. - [REV-2] Promote suppression semantics into Decision 4: customer message
IS inserted into room history (SPAM-S04 requires visibility);
SendContextpush skipped or no-op'd (no AI conversation will continue); reply path suppressed via early return after history insert. Delete §5 item 3. - [REV-3] Enumerate resolve responses: which ChatService/
ResolveRoomoutcomes are success-equivalent (already-closed) vs. retryable — one table row in Detail 2.C. - [REV-4/REV-6] Add one example request/response/error-body triple to Detail 2.4 and name the entity file + field.
- [REV-5] Pin the worker's queue (default vs. dedicated) and note the concurrency expectation.
- [REV-7] Add a distinct
spam_classifier_lock_error(or reason field on the skip log) so a Redis outage is visible.
Dangling Decisions
None fully dangling. Three Partials (Decisions 1, 2, 4) — see ledger REV-1/2/3/5.
Open Questions (for the author)
- On REV-2: confirm the product intent that a spam-resolved room retains the offending message in its history (this review asserts it must, for the "Bukan spam" flow — confirm with the PM, then spec it).
- On REV-5: is there precedent for a dedicated low-priority Sidekiq queue for
non-latency-critical action chains in this repo, or does everything ride
default? Follow whatever
ResolveRoomWorkerdoes.
Implementation Readiness Checklist
R1 snapshot: Unblocked now — Chunks 1, 5, 6. Blocked pending RFC edits — Chunk 2 (REV-1), Chunk 3 (REV-2), Chunk 4 (REV-3).
R2 (post-fix): REV-1/2/3 landed in the RFC → all six chunks unblocked on
the chatbot-owned scope. The only remaining external gates are by design:
hub-core/hub-chat provenance (Detail 2.D) and the ai_agent_alert emit-body
swap — both consumed through stable boundaries that never block the core
cost-saving path.