에스컬레이션 하드게이트 우회 구멍 차단 + 서버 측 삭제 플로우 추가

발견한 문제: POST /conversations/:id/messages가 sender_mode=twin을
검증 없이 그대로 저장·브로드캐스트하고 있었음 -- 에스컬레이션 게이트는
초안 생성(/draft) 경로에만 있었고 실제 발송 경로엔 없어서, 클라이언트가
/draft를 거치지 않고 바로 twin 메시지를 보내면 안전선을 완전히
우회할 수 있었다.

- ai-service: /draft와 별개인 POST /escalate/check 하드게이트 엔드포인트 추가
- core-backend: AIServiceClient.checkEscalation 추가, 메시지 저장 직전에
  twin 발송이면 무조건 호출하도록 해서 발송이 실제로 일어나는 단
  하나의 지점에서 막음. AI 서비스 응답 불가 시 fail-safe로 발송 차단.
  에스컬레이션되면 저장/브로드캐스트 없이 escalation_logs에만 기록.
  사람이 직접 보내는 메시지는 게이트 대상 아님
- core-backend: DELETE /users/:id 추가 -- 유저가 걸린 모든 행(트윈 설정·
  화이트리스트·연락처·대화참여·메시지·에스컬레이션로그·유저 본인)을
  트랜잭션으로 삭제 (tech-design.md §5 "사용자가 언제든 초기화 가능")
- 온디바이스 암호화·데이터 흐름 대시보드는 Flutter 클라이언트 책임이라
  이 환경에서는 보류, roadmap.md에 근거 남김
This commit is contained in:
Claude 2026-07-30 02:26:02 +00:00
parent fb06306d04
commit 9041dc9b64
No known key found for this signature in database
7 changed files with 437 additions and 22 deletions

View File

@ -18,6 +18,19 @@ uvicorn app.main:app --reload --port 8001
## API
### `POST /escalate/check`
```json
{ "text": "계좌번호 알려줄래?" }
```
응답: `{ "escalate": true, "reason": "금전" }`
`/draft`와 별개로 존재하는 독립 하드게이트 엔드포인트 — `core-backend`가 트윈(자동발송) 메시지를
저장하기 *직전에* 이걸 직접 호출해서, `/draft`를 거치지 않은 발송 경로도 전부 이 게이트를 통과하게
만든다 (`docs/roadmap.md` Phase 1 §2.4, `AGENTS.md` 안전 불변식). TestClient로 금전/약속/감정
케이스와 비대상 텍스트 전부 확인함.
### `POST /draft`
```json
@ -45,6 +58,6 @@ TestClient로 style_examples/history 두 경로, 에스컬레이션 케이스,
## 아직 없는 것
- `core-backend/`에서 이 서비스를 실제로 호출하는 클라이언트 코드 (지금은 이 서비스 자체만 있음)
- 온디바이스 말투 이력 저장 (이건 클라이언트/코어 백엔드 쪽 책임 — `tech-design.md` §2 참고)
- 사후 알림 + 되돌리기 로그 (코어 백엔드의 `escalation_logs` 테이블과 연동 필요)
- 사후 알림 + 되돌리기 UI/전체 흐름 (코어 백엔드는 이제 에스컬레이션 시 `escalation_logs`에 기록은
하지만, 사용자에게 사후 알림을 띄우고 되돌리는 클라이언트 UX는 아직 — Flutter 쪽 작업)

View File

@ -3,6 +3,7 @@ from typing import List, Optional
from fastapi import FastAPI
from pydantic import BaseModel, model_validator
from .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples
@ -38,6 +39,25 @@ class DraftResponse(BaseModel):
text: str
class EscalationCheckRequest(BaseModel):
text: str
class EscalationCheckResponse(BaseModel):
escalate: bool
reason: str = ""
@app.post("/escalate/check", response_model=EscalationCheckResponse)
def escalate_check(req: EscalationCheckRequest):
"""Standalone hard gate, decoupled from draft generation (AGENTS.md
absolute safety invariants). core-backend calls this directly before
persisting any twin-authored (auto-sent) message, so the gate applies
even to sends that never went through /draft."""
result = check_escalation(req.text)
return EscalationCheckResponse(escalate=result.escalate, reason=result.reason)
@app.post("/draft", response_model=DraftResponse)
def draft(req: DraftRequest):
if req.style_examples is not None:

View File

@ -17,6 +17,12 @@ go run .
export DATABASE_URL="postgres://user:pass@host/dbname"
```
AI 서비스(`../ai-service/`)를 호출하려면:
```bash
export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
```
## 테스트
```bash
@ -24,23 +30,47 @@ go test ./... -v
```
`main_test.go`가 가입/중복코드 거부(409)/메시지 저장/존재하지 않는 대화방(404)/WebSocket
브로드캐스트까지 전부 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한
케이스).
브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/계정
삭제까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와
동일한 케이스 + Go에서 새로 추가된 것들).
## 지금 있는 것 (2.1 코어 백엔드)
## 지금 있는 것
**2.1 코어 백엔드**
- `GET /health` — 헬스체크
- `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409)
- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트
- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트.
`sender_mode: "twin"`인 요청은 저장 전에 반드시 에스컬레이션 하드게이트를 통과해야 한다 (아래 2.4)
- `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저)
- `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트·
연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제 (`tech-design.md` §5
"사용자가 언제든 초기화 가능")
- DB 모델 (`models.go`): `users`, `contacts`, `conversations`, `conversation_participants`,
`messages`, `twin_settings`, `whitelist_rules`, `escalation_logs``../backend/app/models.py`
동일한 스키마
**2.2 AI 서비스 연동**
- `POST /conversations/{id}/draft``ai-service/``POST /draft`를 호출해 초안을 프록시 반환
(`style_examples`/`history` 중 하나 필수, 없으면 400)
**2.4 안전장치 통합**
- `AIServiceClient.checkEscalation` (`aiservice.go`) — `ai-service``POST /escalate/check` 호출.
`/conversations/{id}/messages``sender_mode: "twin"`을 받을 때마다 이걸 먼저 호출해서, 어떤
경로로 왔든(그리고 `/draft`를 거쳤든 안 거쳤든) 트윈 자동발송은 전부 이 게이트를 통과하게 만드는
하나의 초크포인트다. 에스컬레이션되면 메시지는 저장·브로드캐스트되지 않고 `escalation_logs`
기록만 남는다(403). AI 서비스가 응답하지 않으면 fail-safe로 발송 자체를 막는다(502) — 게이트를
확인 못 했다는 불확실성을 자동발송 허용 쪽으로 풀지 않는다.
- 사람이 직접 보내는 메시지(`sender_mode` 기본값 `human`)는 이 게이트를 타지 않는다 — 본인이 직접
쓴 말을 막을 이유가 없다.
## 아직 없는 것 (다음 워크스트림)
- 2.2 AI 서비스 연동 — Python AI 서비스(`poc/tone-corpus/`를 감싼 것)를 `AI_SERVICE_URL`로 호출
(`config.go`에 자리만 만들어둠, 실제 호출 로직은 아직 없음)
- 자율성 엔진(L0~L2) 오케스트레이션 전체 흐름 (`roadmap.md` §2.2) — 지금은 발송 시점 하드게이트만
있고, 초안 생성→승인 대기→자동발송 분기의 나머지는 아직
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
- 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽)
- 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어
로컬 환경에서 진행)
- 푸시 알림 연동
- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음)
- 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입)

View File

@ -52,3 +52,40 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error)
}
return &out, nil
}
type escalationCheckRequest struct {
Text string `json:"text"`
}
type escalationCheckResponse struct {
Escalate bool `json:"escalate"`
Reason string `json:"reason"`
}
// checkEscalation calls ai-service's POST /escalate/check -- the hard gate
// that any twin-authored (auto-sent) message must pass, independent of
// whether a draft was generated through this client. Callers must fail
// closed (treat an error here as "escalate") per AGENTS.md's fail-safe
// invariant: uncertainty must never resolve to an unattended send.
func (c *AIServiceClient) checkEscalation(text string) (*escalationCheckResponse, error) {
body, err := json.Marshal(escalationCheckRequest{Text: text})
if err != nil {
return nil, err
}
resp, err := c.HTTP.Post(c.BaseURL+"/escalate/check", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ai service unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai service returned %d", resp.StatusCode)
}
var out escalationCheckResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -82,6 +82,31 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
req.SenderMode = SenderHuman
}
// Hard gate: a twin-authored send is an unattended action, so it
// must clear escalation_filter regardless of how it got here --
// this is the one chokepoint every twin auto-send passes through,
// so no client or upstream path can bypass it (AGENTS.md absolute
// safety invariants). Human-authored messages are the human's own
// words and are never gated. On any doubt (AI service unreachable
// or erroring) we fail closed and block the send.
if req.SenderMode == SenderTwin {
result, err := ai.checkEscalation(req.Text)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()})
return
}
if result.Escalate {
db.Create(&EscalationLog{
UserID: req.SenderID,
ConversationID: convID,
Reason: result.Reason,
MessageSnippet: req.Text,
})
c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason})
return
}
}
message := Message{
ConversationID: convID,
SenderID: req.SenderID,
@ -143,6 +168,60 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text})
})
r.DELETE("/users/:id", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
// Right-to-erasure ("사용자가 언제든 초기화 가능", tech-design.md §5):
// wipes every row this user's ID appears on. Real chat history lives
// on-device first (tech-design.md §2/§5) -- the server only ever held
// a minimal relay copy, so deleting it here is not a partial erasure.
var messagesDeleted, escalationLogsDeleted int64
err := db.Transaction(func(tx *gorm.DB) error {
if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil {
return res.Error
}
if res := tx.Where("user_id = ?", userID).Delete(&WhitelistRule{}); res.Error != nil {
return res.Error
}
if res := tx.Where("owner_user_id = ?", userID).Delete(&Contact{}); res.Error != nil {
return res.Error
}
if res := tx.Where("user_id = ?", userID).Delete(&ConversationParticipant{}); res.Error != nil {
return res.Error
}
res := tx.Where("sender_id = ?", userID).Delete(&Message{})
if res.Error != nil {
return res.Error
}
messagesDeleted = res.RowsAffected
res = tx.Where("user_id = ?", userID).Delete(&EscalationLog{})
if res.Error != nil {
return res.Error
}
escalationLogsDeleted = res.RowsAffected
return tx.Delete(&user).Error
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"deleted_user_id": userID,
"messages_deleted": messagesDeleted,
"escalation_logs_deleted": escalationLogsDeleted,
})
})
r.GET("/ws/conversations/:id", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {

View File

@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@ -22,10 +23,8 @@ import (
func mockAIService(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/draft" {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.URL.Path {
case "/draft":
var req draftRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
@ -33,6 +32,27 @@ func mockAIService(t *testing.T) *httptest.Server {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")})
case "/escalate/check":
// Mirrors escalation_filter.py's rule set closely enough to
// exercise core-backend's gating logic; the rules themselves
// are verified against ai-service/app/escalation_filter.py directly.
var req escalationCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
out := escalationCheckResponse{}
switch {
case strings.Contains(req.Text, "계좌") || strings.Contains(req.Text, "송금"):
out = escalationCheckResponse{Escalate: true, Reason: "금전"}
case strings.Contains(req.Text, "힘들어"):
out = escalationCheckResponse{Escalate: true, Reason: "감정적으로 무거운 주제"}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
return server
@ -145,6 +165,210 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
}
}
func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
server, db := setupTestServer(t)
signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin1", DisplayName: "민지"})
var user map[string]interface{}
json.NewDecoder(signupResp.Body).Decode(&user)
senderID := uint(user["id"].(float64))
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
strconv.FormatUint(uint64(conv.ID), 10)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer ws.Close()
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "계좌번호 알려줄게",
SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for escalated twin send, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("escalated twin message must not be persisted, found %d rows", count)
}
var logs []EscalationLog
db.Where("conversation_id = ?", conv.ID).Find(&logs)
if len(logs) != 1 || logs[0].Reason != "금전" {
t.Fatalf("expected one EscalationLog row with reason 금전, got %+v", logs)
}
ws.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if _, _, err := ws.ReadMessage(); err == nil {
t.Fatal("expected no broadcast for a blocked escalated message")
}
}
func TestTwinMessageNotEscalatedIsSent(t *testing.T) {
server, db := setupTestServer(t)
signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin2", DisplayName: "하늘"})
var user map[string]interface{}
json.NewDecoder(signupResp.Body).Decode(&user)
senderID := uint(user["id"].(float64))
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "ㅇㅇ 알겠어",
SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for non-escalated twin send, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 1 {
t.Fatalf("expected 1 persisted message, got %d", count)
}
}
func TestHumanMessageBypassesEscalationGate(t *testing.T) {
server, db := setupTestServer(t)
signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "human1", DisplayName: "재민"})
var user map[string]interface{}
json.NewDecoder(signupResp.Body).Decode(&user)
senderID := uint(user["id"].(float64))
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
// A human typing their own money-related message is never gated --
// only unattended (twin) sends are.
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "계좌번호 불러줄게",
SenderMode: SenderHuman,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for human-authored send, got %d", resp.StatusCode)
}
}
func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(allModels...); err != nil {
t.Fatalf("migrate: %v", err)
}
unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
unreachable.Close() // closed immediately -- BaseURL now points at nothing listening
ai := &AIServiceClient{BaseURL: unreachable.URL, HTTP: http.DefaultClient}
gin.SetMode(gin.TestMode)
router := setupRouter(db, newConnectionManager(), ai)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin3", DisplayName: "소연"})
var user map[string]interface{}
json.NewDecoder(signupResp.Body).Decode(&user)
senderID := uint(user["id"].(float64))
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "ㅇㅇ 알겠어",
SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("expected 502 fail-closed when escalation gate is unreachable, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("must not persist a twin message when the safety gate couldn't be checked, found %d rows", count)
}
}
func TestDeleteUserPurgesData(t *testing.T) {
server, db := setupTestServer(t)
signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "erase1", DisplayName: "유나"})
var user map[string]interface{}
json.NewDecoder(signupResp.Body).Decode(&user)
userID := uint(user["id"].(float64))
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: userID,
Text: "안녕",
})
if sendResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode)
}
db.Create(&WhitelistRule{UserID: userID, TopicKeyword: "저녁 약속"})
db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."})
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("delete request: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["messages_deleted"].(float64) != 1 || out["escalation_logs_deleted"].(float64) != 1 {
t.Fatalf("unexpected purge summary: %v", out)
}
var userCount, msgCount, settingsCount, whitelistCount, logCount int64
db.Model(&User{}).Where("id = ?", userID).Count(&userCount)
db.Model(&Message{}).Where("sender_id = ?", userID).Count(&msgCount)
db.Model(&TwinSettings{}).Where("user_id = ?", userID).Count(&settingsCount)
db.Model(&WhitelistRule{}).Where("user_id = ?", userID).Count(&whitelistCount)
db.Model(&EscalationLog{}).Where("user_id = ?", userID).Count(&logCount)
if userCount != 0 || msgCount != 0 || settingsCount != 0 || whitelistCount != 0 || logCount != 0 {
t.Fatalf("expected all user-linked rows purged, got user=%d msg=%d settings=%d whitelist=%d log=%d",
userCount, msgCount, settingsCount, whitelistCount, logCount)
}
req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("second delete request: %v", err)
}
if resp2.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 on repeat delete, got %d", resp2.StatusCode)
}
}
func TestDraftMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{

View File

@ -63,8 +63,18 @@
- [ ] 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능
**2.4 안전장치 통합** (전 구간 필수, 타협 불가)
- [ ] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계
- [ ] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드
- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회
구멍: `POST /conversations/:id/messages``sender_mode: "twin"`을 검증 없이 그대로 저장·
브로드캐스트하고 있었음(초안 생성(`/draft`)만 게이트를 탔고, 발송 자체는 게이트가 없었음). 이걸
막기 위해 `ai-service``/draft`와 별개인 `POST /escalate/check` 하드게이트 엔드포인트를 추가하고,
`core-backend`가 트윈 발송 저장 *직전에* 무조건 이걸 호출하도록 만듦 — 어떤 경로로 왔든 발송이
실제로 일어나는 단 하나의 지점(메시지 저장)에서 걸리므로 클라이언트가 뭘 하든 우회 불가.
AI 서비스 응답 불가 시 fail-safe(발송 차단, 502)로 처리. 사람이 직접 보내는 메시지는 게이트 대상이
아님. 차단·통과·게이트 불능 3케이스 전부 실제 테스트로 확인함 (`core-backend/main_test.go`)
- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제
플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스
암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경(SDK
없음)에서는 진행 불가 — 2.3과 함께 로컬 환경 대기
**2.5 QA/테스트**
- [ ] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 동일하게
@ -101,9 +111,11 @@
`POST /conversations/:id/draft`)도 완료·테스트 통과. 남은 건 자율성 엔진 오케스트레이션, 온디바이스
말투 이력 저장, 사후 알림/되돌리기 로그 — 이건 2.4/2.5와 겹치므로 그쪽에서 이어감. 2.3 Flutter는
여전히 로컬 환경 대기 중
4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) — **다음 작업 대상은 이 항목이지만 Flutter SDK가 없는 이
환경에서는 착수 불가. 2.4(안전장치 통합)으로 순서를 당겨 진행**
5. [ ] 2.4/2.5 안전장치·QA
4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가.
대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함
5. [~] 2.4/2.5 안전장치·QA — 2.4의 서버 쪽 절반(하드게이트 우회 차단, 삭제 플로우) 완료. 나머지는
클라이언트 쪽(온디바이스 암호화·데이터 흐름 대시보드, Flutter 대기)과 2.5 QA(테스트 스위트 승격,
자율성 플로우 통합 테스트) — 다음 작업
6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈
1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만