# 09 — Security & performance: auto-reply + AI fallback **Agent:** 9/10 · **Scope:** harden FAQ match + AI fallback (no git) **Contract:** [02-contract.md](./02-contract.md) §§6–7 **Related:** [01-inventory.md](./01-inventory.md), `docs/security-notes.md` --- ## Goal Make automatic first responses (FAQ match → AI fallback) safe under abuse: | Control | Requirement | |---------|-------------| | Rate limits | AI jobs: 10 / company / hour + 30 / platform / minute (FAQ match excluded) | | Prompt injection | Ticket subject/body treated as **untrusted data** (delimiters + soft filters) | | Tenant isolation | Ticket reads/writes always filter `company_id`; KB snippets with foreign `Company` dropped | | Secret redaction | Strip keys/tokens/PEM/DSNs before match features, prompts, and logs | | Timeouts | `AutoReplyTimeout` = 25s on `TryAutoReplyLLM` | | Idempotency | No double public auto-post (row lock + unique partial index) | | Indexes | See migration `035_support_auto_security_perf.sql` | --- ## Implementation map | Piece | Path | |-------|------| | Secret redact + untrusted wrap | `apps/api/internal/security/ticket_prompt.go` | | FAQ match uses shared redact | `RedactSecretsForMatch` → `security.RedactSecrets` | | AI rate limiter | `apps/api/internal/support/auto_ratelimit.go` | | Prompt builder (isolation) | `apps/api/internal/support/auto_prompt.go` → `BuildAutoReplyMessages` | | Idempotent claim / insert | `apps/api/internal/support/auto_idempotency.go` | | Gated LLM entry | `TryAutoReplyLLM` in `ai_auto_reply.go` | | Idempotency indexes | `apps/api/sql/schema/035_support_auto_security_perf.sql` | | AI jobs table | `apps/api/sql/schema/034_support_auto_jobs.sql` (agent 4) | | Abuse tests | `security/ticket_prompt_test.go`, `support/ai_auto_reply_test.go`, `support/auto_security_test.go` | ### `TryAutoReplyLLM` gate order 1. `context.WithTimeout(..., AutoReplyTimeout)` 2. Load `company_id` for ticket (fail closed if no pool) 3. **`SupportAI == nil` → `ErrAIAutoReplyDisabled`** (product still opt-in; agent 4 wires runner) 4. `AIRateLimiter.Allow(companyID)` → else `ErrAIRateLimited` 5. `ClaimAutoReplyAttempt` (FOR UPDATE; already-posted / disabled / closed) 6. `SupportAI.RunAutoReply` with nested timeout; errors logged via `RedactForAutoLog` ### Prompt contract - Fixed `AutoReplySystemPrompt` (server-owned; not admin free-text). - Customer text wrapped in `<<>>` after `SanitizeUntrustedTicketText`. - Platform KB only (`Company == uuid.Nil`) or matching `companyID`; never other tenants’ tickets. ### Idempotency - Application: `PostMatchedAutoReply` + `ClaimAutoReplyAttempt` / `InsertAutoSystemMessage` check `auto_reply_message_id`, status, and existing public `is_auto_reply` rows; unique violations treated as soft skip. - DB: unique partial index `support_messages_one_public_auto_per_ticket_uidx` on `(ticket_id) WHERE is_auto_reply AND NOT is_internal_note`. - AI jobs: unique partial `support_auto_jobs_ticket_active_uidx` in `034_support_auto_jobs.sql` for `pending|running`. --- ## Threat model (abuse cases covered by tests) | Case | Test | |------|------| | Injection phrases in ticket body | `TestSanitizeUntrustedTicketTextFiltersInjection`, `TestBuildAutoReplyMessages_treatsBodyAsUntrusted` | | API keys / Stripe / PEM / DSN in body | `TestRedactSecretsTicketAbuse`, `TestRedactSecretsForMatch_delegates` | | Cross-tenant KB snippet | `TestFilterKBSnippetsForCompany_blocksCrossTenant`, `TestBuildAutoReplyMessages_dropsForeignCompanySnippets` | | Rate limit company / platform | `TestAIRateLimiter_*` | | Secrets in log strings | `TestRedactForAutoLog_stripsSecrets` | | LLM disabled by default | `TestTryAutoReplyLLM_refuses` | --- ## Performance notes | Path | Guidance | |------|----------| | FAQ match | Sync, corpus cache (agent 3), GIN on keywords (032), no external I/O | | AI | Async preferred (jobs table in 033); HTTP create must not await LLM | | Lists | Do not embed full `customer_context` (agent 5) | | Rate limits | In-process only — effective RPM ≈ N × replicas (same as marketing/processing) | --- ## ASSUMPTIONs 1. Platform KB has no `company_id` (global help center). Per-tenant KB remains out of scope; if added later, every query **must** filter `company_id` and prompt builders must keep using `FilterKBSnippetsForCompany`. 2. Agent 4 implements `SupportAIRunner` using `BuildAutoReplyMessages` + `InsertAutoSystemMessage` + `aiprovider.ResolveCompleterForRole(..., RoleSupport)` — no second secret store. 3. FAQ match stays free of AI rate limits (contract §6). --- ## Verify ```bash cd apps/api go test ./internal/security ./internal/support -count=1 -run "Redact|SanitizeUntrusted|WrapUntrusted|AIRate|BuildAutoReply|FilterKB|TryAutoReplyLLM_refuses|AutoReplyTimeout" ``` Apply migrations through `035_support_auto_security_perf.sql` (and `034_support_auto_jobs.sql`) before relying on unique indexes / jobs table. ## Rollback Revert `035_*` down migration; remove SupportAI / AIRateLimiter fields usage; restore stub-only `TryAutoReplyLLM` if needed. FAQ match continues to work without AI runner.