diff --git a/ai-service/README.md b/ai-service/README.md index acaf786..d5827c5 100644 --- a/ai-service/README.md +++ b/ai-service/README.md @@ -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 쪽 작업) diff --git a/ai-service/app/main.py b/ai-service/app/main.py index cc6f839..0a51303 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -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: diff --git a/core-backend/README.md b/core-backend/README.md index ec7a171..8d41c52 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -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 등 도입) diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go index b3659cb..7bd6df1 100644 --- a/core-backend/aiservice.go +++ b/core-backend/aiservice.go @@ -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 +} diff --git a/core-backend/main.go b/core-backend/main.go index 8208afd..7c3f856 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -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 { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index b087f8b..fa16c61 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -22,17 +23,36 @@ 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" { + switch r.URL.Path { + case "/draft": + var req draftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + 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) - return } - var req draftRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")}) })) 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{ diff --git a/docs/roadmap.md b/docs/roadmap.md index 804a087..41a45bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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가 늦어져도 인프라 작업은 막히지 않는다. 다만