From f79108b9cda9cae502ce930e9a37047e79bf6e03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:51:15 +0000 Subject: [PATCH] =?UTF-8?q?2.6=20=EB=B2=A0=ED=83=80=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=A4=80=EB=B9=84:=20=EC=8B=A4=EC=A0=9C=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=EC=BD=94=EB=93=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?+=20=EC=9A=B4=EC=98=81=20=EC=A7=80=ED=91=9C=20+=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=EA=B6=8C=20=EC=95=88=EC=A0=84=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발견한 문제(2.6 진행 중): 거부권(peer veto) 안전 불변식이 코드에 전혀 구현되어 있지 않았음. Contact.TwinDisabledByPeer는 스키마에만 있고 어디서도 읽거나 쓰지 않았고, tech-design.md §4는 "대화방 단위 플래그" 라는데 실제로는 상대(Contact) 단위로 모델링돼 있어 설계 문서와도 불일치. Conversation.TwinDisabledByPeer로 옮기고 POST /conversations/:id/veto 추가, 메시지 발송 시 거부권 -> 에스컬레이션 -> 자율성 레벨 순으로 체크(앞 단계가 뒤 단계를 항상 이김)하도록 수정. 2.6 본작업: - 초대 기반 가입: 기존엔 아무 문자열이나 처음 쓰면 통과돼서 실제로는 초대 기반이 아니었음. InviteCode 테이블 + POST /invites(발급) 추가하고 /auth/signup이 미리 발급된 미사용 코드인지 검증하도록 변경 (모르는 코드 400, 이미 쓴 코드 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움 - GET /admin/metrics 추가 -- 메시지 수(휴먼/트윈), 에스컬레이션 사유별 집계, 거부권 발동률(vision.md 거부율 지표의 1차 근사), 초대 코드 발급/사용 수. 생성 지연시간·오류율은 별도 계측 계층이 없어 넣지 않고 문서에 명시 실제 베타 오픈 시점 자체는 roadmap.md §3(PoC 결과 필요)이 끝나야 정할 수 있어서 여전히 보류 -- 이번엔 서버 인프라만 준비함 --- core-backend/README.md | 44 ++++++-- core-backend/main.go | 133 ++++++++++++++++++++++-- core-backend/main_test.go | 211 ++++++++++++++++++++++++++++++-------- core-backend/models.go | 42 +++++--- docs/roadmap.md | 42 ++++++-- 5 files changed, 389 insertions(+), 83 deletions(-) diff --git a/core-backend/README.md b/core-backend/README.md index 99f1ee1..a5fb016 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -29,34 +29,45 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 go test ./... -v ``` -`main_test.go`가 가입/중복코드 거부(409)/메시지 저장/존재하지 않는 대화방(404)/WebSocket +`main_test.go`가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket 브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성 플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/ -계정 삭제까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient -테스트와 동일한 케이스 + Go에서 새로 추가된 것들). +거부권(peer veto, 다른 모든 조건보다 우선)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 +돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들). ## 지금 있는 것 **2.1 코어 백엔드** - `GET /health` — 헬스체크 -- `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409) +- `POST /auth/signup` — 초대 코드 기반 가입. 미리 발급된(`POST /invites`) 미사용 코드가 아니면 + 400, 이미 쓴 코드면 409 (아래 2.6) - `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트. - `sender_mode: "twin"`인 요청은 저장 전에 반드시 에스컬레이션 하드게이트를 통과해야 한다 (아래 2.4) + `sender_mode: "twin"`인 요청은 저장 전에 반드시 거부권·에스컬레이션 하드게이트를 통과해야 한다 + (아래 2.4) +- `POST /conversations/{id}/veto` — 거부권 발동: 이 대화방의 `twin_disabled_by_peer`를 켠다. + 이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이 + 전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음) - `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`와 - 동일한 스키마 + 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴 + 초대 코드는 "사용됨" 상태는 유지한 채 유저 참조만 지운다 (`tech-design.md` §5 "사용자가 + 언제든 초기화 가능") +- DB 모델 (`models.go`): `users`, `invite_codes`, `contacts`, `conversations`, + `conversation_participants`, `messages`, `twin_settings`, `whitelist_rules`, `escalation_logs` — + `../backend/app/models.py`와 동일한 스키마(+ `invite_codes`는 여기서 새로 추가) **2.2 AI 서비스 연동** - `POST /conversations/{id}/draft` — `ai-service/`의 `POST /draft`를 호출해 초안을 프록시 반환 (`style_examples`/`history` 중 하나 필수, 없으면 400) **2.4 안전장치 통합** +- 트윈 발송 시 체크 순서: **거부권 → 에스컬레이션 → 자율성 레벨**. 앞 단계에서 막히면 뒤 단계는 + 아예 확인하지 않는다 — 순서가 바뀌면 안 되는 이유는 거부권/에스컬레이션이 레벨·화이트리스트· + 승인 여부보다 항상 우선해야 하기 때문 (테스트로 확인함) +- 거부권: `conversation.TwinDisabledByPeer`가 켜져 있으면 그 자리에서 403 — AI 서비스 호출조차 + 하지 않는다. 사람이 직접 보내는 메시지는 영향받지 않는다 - `AIServiceClient.checkEscalation` (`aiservice.go`) — `ai-service`의 `POST /escalate/check` 호출. - `/conversations/{id}/messages`가 `sender_mode: "twin"`을 받을 때마다 이걸 먼저 호출해서, 어떤 + `/conversations/{id}/messages`가 `sender_mode: "twin"`을 받을 때마다 이걸 호출해서, 어떤 경로로 왔든(그리고 `/draft`를 거쳤든 안 거쳤든) 트윈 자동발송은 전부 이 게이트를 통과하게 만드는 하나의 초크포인트다. 에스컬레이션되면 메시지는 저장·브로드캐스트되지 않고 `escalation_logs`에 기록만 남는다(403). AI 서비스가 응답하지 않으면 fail-safe로 발송 자체를 막는다(502) — 게이트를 @@ -77,6 +88,15 @@ go test ./... -v - 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved: true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함) +**2.6 베타 배포 준비** +- `POST /invites` — 새 초대 코드 발급(무작위 10자 hex). 아직 발급자 인증이 없어 누구나 호출 + 가능 — 인증/세션이 생기기 전까지는 서버 콘솔·내부 도구에서만 호출한다고 가정 +- `GET /admin/metrics` — `users_total`·`messages_human_total`·`messages_twin_total`· + `escalations_total`·`escalations_by_reason`·`conversations_total`·`conversations_vetoed`· + `peer_veto_rate`·`invites_minted`·`invites_used`. 지금 스키마로 정직하게 계산 가능한 것만 — + `peer_veto_rate`는 vision.md "분신 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님. + 생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것") + ## 아직 없는 것 (다음 워크스트림) - 화이트리스트 규칙 CRUD API (지금은 DB에 직접 넣는 걸로 테스트함 — 유저가 화이트리스트를 실제로 @@ -86,6 +106,8 @@ go test ./... -v - 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽) - 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어 로컬 환경에서 진행) +- 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음) +- `/invites`·`/admin/metrics` 접근 제어 (지금은 인증이 없어 누구나 호출 가능 — 아래 인증 항목과 같이 해결) - 푸시 알림 연동 - 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음) - 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입) diff --git a/core-backend/main.go b/core-backend/main.go index 4090689..e4186fc 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -1,9 +1,12 @@ package main import ( + "crypto/rand" + "encoding/hex" "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -47,6 +50,70 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + r.POST("/invites", func(c *gin.Context) { + code, err := generateInviteCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + invite := InviteCode{Code: code} + if err := db.Create(&invite).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": invite.Code}) + }) + + r.GET("/admin/metrics", func(c *gin.Context) { + // v1-minimal (roadmap.md §2.6): only counts honestly derivable from + // the current schema. Draft-generation latency and AI-service error + // rate need a request-timing/logging layer that doesn't exist yet -- + // not fabricated here, left for that future work. + var usersTotal, humanMessages, twinMessages, escalationsTotal int64 + var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64 + db.Model(&User{}).Count(&usersTotal) + db.Model(&Message{}).Where("sender_mode = ?", SenderHuman).Count(&humanMessages) + db.Model(&Message{}).Where("sender_mode = ?", SenderTwin).Count(&twinMessages) + db.Model(&EscalationLog{}).Count(&escalationsTotal) + db.Model(&Conversation{}).Count(&conversationsTotal) + db.Model(&Conversation{}).Where("twin_disabled_by_peer = ?", true).Count(&conversationsVetoed) + db.Model(&InviteCode{}).Count(&invitesMinted) + db.Model(&InviteCode{}).Where("used_at IS NOT NULL").Count(&invitesUsed) + + type reasonCount struct { + Reason string + Count int64 + } + var reasonCounts []reasonCount + db.Model(&EscalationLog{}).Select("reason, count(*) as count").Group("reason").Scan(&reasonCounts) + escalationsByReason := map[string]int64{} + for _, rc := range reasonCounts { + escalationsByReason[rc.Reason] = rc.Count + } + + var peerVetoRate float64 + if conversationsTotal > 0 { + peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) + } + + c.JSON(http.StatusOK, gin.H{ + "users_total": usersTotal, + "messages_human_total": humanMessages, + "messages_twin_total": twinMessages, + "escalations_total": escalationsTotal, + "escalations_by_reason": escalationsByReason, + "conversations_total": conversationsTotal, + "conversations_vetoed": conversationsVetoed, + // Approximates vision.md's "분신 거부율" metric at conversation + // granularity (vetoed conversations / all conversations) -- + // vision.md doesn't pin down the exact denominator, so treat + // this as a first approximation, not the final definition. + "peer_veto_rate": peerVetoRate, + "invites_minted": invitesMinted, + "invites_used": invitesUsed, + }) + }) + r.POST("/auth/signup", func(c *gin.Context) { var req signupRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -54,9 +121,16 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } - var existing User - if err := db.Where("invite_code = ?", req.InviteCode).First(&existing).Error; err == nil { - c.JSON(http.StatusConflict, gin.H{"detail": "invite_code already used"}) + // 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용 + // 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전 + // 방식은 "초대 기반"이 아니었음. + var invite InviteCode + if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) + return + } + if invite.UsedAt != nil { + c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) return } @@ -67,6 +141,11 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi } db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0}) + now := time.Now() + invite.UsedAt = &now + invite.UsedByUserID = &user.ID + db.Save(&invite) + c.JSON(http.StatusOK, gin.H{"id": user.ID, "display_name": user.DisplayName}) }) @@ -97,10 +176,16 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // 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. Escalation is - // checked before autonomy level, and applies regardless of level or - // whitelist match -- L2 auto-send never overrides it. + // or erroring) we fail closed and block the send. Peer veto is + // checked first (it's a total kill switch for this conversation, + // independent of content), then escalation, then autonomy level -- + // none of the later checks can override an earlier block. if req.SenderMode == SenderTwin { + if conversation.TwinDisabledByPeer { + c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) + return + } + result, err := ai.checkEscalation(req.Text) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) @@ -206,6 +291,31 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text}) }) + r.POST("/conversations/:id/veto", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var conversation Conversation + if err := db.First(&conversation, convID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"}) + return + } + + // Peer veto (PRD.md §3.1, tech-design.md §4, AGENTS.md absolute + // safety invariants): the counterpart asked to talk to the human + // only. One-way for v1 -- no "un-veto" endpoint, matching the + // PRD's "즉시 중단" wording; nothing in scope calls for reversing it. + conversation.TwinDisabledByPeer = true + if err := db.Save(&conversation).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true}) + }) + r.PATCH("/users/:id/twin-settings", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { @@ -254,6 +364,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // 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.Model(&InviteCode{}).Where("used_by_user_id = ?", userID).Update("used_by_user_id", nil); res.Error != nil { + return res.Error + } if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil { return res.Error } @@ -332,6 +445,14 @@ func whitelistMatches(db *gorm.DB, userID uint, text string) bool { return false } +func generateInviteCode() (string, error) { + buf := make([]byte, 5) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + func parseUintParam(c *gin.Context, name string) (uint, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index af6ece5..3e56a5e 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -88,6 +88,28 @@ func postJSON(t *testing.T, url string, body interface{}) *http.Response { return resp } +func mintInvite(t *testing.T, serverURL string) string { + t.Helper() + resp := postJSON(t, serverURL+"/invites", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + return out["code"].(string) +} + +func mustSignup(t *testing.T, serverURL, displayName string) uint { + t.Helper() + resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 signing up %s, got %d", displayName, resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + return uint(out["id"].(float64)) +} + func setAutonomyLevel(t *testing.T, serverURL string, userID uint, level AutonomyLevel) { t.Helper() body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level}) @@ -115,8 +137,9 @@ func TestHealth(t *testing.T) { func TestSignupAndDuplicateRejected(t *testing.T) { server, _ := setupTestServer(t) + code := mintInvite(t, server.URL) - resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "abc123", DisplayName: "지우"}) + resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "지우"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } @@ -126,9 +149,26 @@ func TestSignupAndDuplicateRejected(t *testing.T) { t.Fatalf("unexpected body: %v", out) } - dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "abc123", DisplayName: "dup"}) + dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "dup"}) if dup.StatusCode != http.StatusConflict { - t.Fatalf("expected 409 for duplicate invite_code, got %d", dup.StatusCode) + t.Fatalf("expected 409 for a reused invite code, got %d", dup.StatusCode) + } +} + +func TestSignupRejectsUnknownInviteCode(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "never-minted", DisplayName: "누구"}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for an invite code nobody minted, got %d", resp.StatusCode) + } +} + +func TestMintInviteReturnsUniqueCodes(t *testing.T) { + server, _ := setupTestServer(t) + a := mintInvite(t, server.URL) + b := mintInvite(t, server.URL) + if a == b { + t.Fatalf("expected distinct invite codes, got %q twice", a) } } @@ -143,10 +183,7 @@ func TestSendMessageToMissingConversation(t *testing.T) { func TestSendMessageAndWebSocketBroadcast(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "sender1", DisplayName: "정우"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "정우") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -185,10 +222,7 @@ 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)) + senderID := mustSignup(t, server.URL, "민지") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -237,10 +271,7 @@ func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) { func TestTwinSendBlockedAtDefaultL0(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)) + senderID := mustSignup(t, server.URL, "하늘") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -268,10 +299,7 @@ func TestTwinSendBlockedAtDefaultL0(t *testing.T) { func TestTwinSendRequiresApprovalAtL1(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l1", DisplayName: "서준"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "서준") setAutonomyLevel(t, server.URL, senderID, AutonomyL1) conv := Conversation{IsGroup: false} @@ -300,10 +328,7 @@ func TestTwinSendRequiresApprovalAtL1(t *testing.T) { func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-wl", DisplayName: "가은"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "가은") setAutonomyLevel(t, server.URL, senderID, AutonomyL2) db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) @@ -330,10 +355,7 @@ func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) { func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-nowl", DisplayName: "도윤"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "도윤") setAutonomyLevel(t, server.URL, senderID, AutonomyL2) db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) @@ -359,10 +381,7 @@ func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) { func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-esc", DisplayName: "은서"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "은서") setAutonomyLevel(t, server.URL, senderID, AutonomyL2) // Whitelisted keyword happens to be the same word that also triggers // the money escalation pattern in the mock AI service. @@ -390,10 +409,7 @@ func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) { 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)) + senderID := mustSignup(t, server.URL, "재민") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -431,10 +447,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) { 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)) + senderID := mustSignup(t, server.URL, "소연") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -460,10 +473,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) { 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)) + userID := mustSignup(t, server.URL, "유나") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -514,6 +524,121 @@ func TestDeleteUserPurgesData(t *testing.T) { } } +func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) { + server, db := setupTestServer(t) + + senderID := mustSignup(t, server.URL, "지민") + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + vetoResp := postJSON(t, convBase+"/veto", nil) + if vetoResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 triggering veto, got %d", vetoResp.StatusCode) + } + + // Would otherwise auto-send (L2 + whitelist match + approved), but the + // veto must override every other check. + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403: peer veto must override L2 whitelist + approval, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 0 { + t.Fatalf("no twin message should be sent after veto, found %d rows", count) + } +} + +func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) { + server, db := setupTestServer(t) + + senderID := mustSignup(t, server.URL, "다은") + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + postJSON(t, convBase+"/veto", nil) + + resp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("veto must not block the human's own messages, got %d", resp.StatusCode) + } +} + +func TestVetoMissingConversation(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/conversations/9999/veto", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) { + server, db := setupTestServer(t) + + senderID := mustSignup(t, server.URL, "메트릭") + setAutonomyLevel(t, server.URL, senderID, AutonomyL1) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman}) + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true}) + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin}) + + conv2 := Conversation{IsGroup: false} + db.Create(&conv2) + postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil) + + resp, err := http.Get(server.URL + "/admin/metrics") + if err != nil { + t.Fatalf("get metrics: %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_human_total"].(float64) != 1 { + t.Fatalf("expected 1 human message, got %v", out["messages_human_total"]) + } + if out["messages_twin_total"].(float64) != 1 { + t.Fatalf("expected 1 twin message, got %v", out["messages_twin_total"]) + } + if out["escalations_total"].(float64) != 1 { + t.Fatalf("expected 1 escalation, got %v", out["escalations_total"]) + } + byReason := out["escalations_by_reason"].(map[string]interface{}) + if byReason["금전"].(float64) != 1 { + t.Fatalf("expected 1 금전 escalation, got %v", byReason) + } + if out["conversations_total"].(float64) != 2 || out["conversations_vetoed"].(float64) != 1 { + t.Fatalf("expected 2 conversations, 1 vetoed, got %v", out) + } + if out["peer_veto_rate"].(float64) != 0.5 { + t.Fatalf("expected 0.5 veto rate, got %v", out["peer_veto_rate"]) + } + if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 { + t.Fatalf("expected at least 1 minted/used invite, got %v", out) + } +} + func TestDraftMissingConversation(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ diff --git a/core-backend/models.go b/core-backend/models.go index 10b97c2..3a70df8 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -24,22 +24,37 @@ type User struct { CreatedAt time.Time } -// Contact carries per-peer state like the veto flag (tech-design.md §4: -// twin_disabled_by_peer), independent of any one conversation. -type Contact struct { - ID uint `gorm:"primaryKey"` - OwnerUserID uint `gorm:"not null;index"` - ContactUserID *uint - DisplayName string `gorm:"not null"` - RelationshipNote string - TwinDisabledByPeer bool `gorm:"not null;default:false"` - CreatedAt time.Time +// InviteCode is a pre-minted, single-use code (roadmap.md Phase 1 §2.6 +// "초대 기반 베타 가입 플로우") -- signup validates against this table instead +// of just deduping User.InviteCode, so joining actually requires a code +// someone handed out, not any never-used string. +type InviteCode struct { + ID uint `gorm:"primaryKey"` + Code string `gorm:"uniqueIndex;not null"` + CreatedAt time.Time + UsedAt *time.Time + UsedByUserID *uint } +type Contact struct { + ID uint `gorm:"primaryKey"` + OwnerUserID uint `gorm:"not null;index"` + ContactUserID *uint + DisplayName string `gorm:"not null"` + RelationshipNote string + CreatedAt time.Time +} + +// TwinDisabledByPeer is the veto flag (tech-design.md §4: "대화방 단위 +// 플래그(twin_disabled_by_peer)") -- per conversation, not per contact, so +// it lives here rather than on Contact. Set by POST /conversations/:id/veto +// when the counterpart asks to talk to the human only; checked before any +// twin auto-send in that conversation (main.go). type Conversation struct { - ID uint `gorm:"primaryKey"` - IsGroup bool `gorm:"not null;default:false"` - CreatedAt time.Time + ID uint `gorm:"primaryKey"` + IsGroup bool `gorm:"not null;default:false"` + TwinDisabledByPeer bool `gorm:"not null;default:false"` + CreatedAt time.Time } type ConversationParticipant struct { @@ -91,6 +106,7 @@ type EscalationLog struct { var allModels = []interface{}{ &User{}, + &InviteCode{}, &Contact{}, &Conversation{}, &ConversationParticipant{}, diff --git a/docs/roadmap.md b/docs/roadmap.md index 62849c5..607f042 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,8 +36,9 @@ - [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함 - [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함. 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 -- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules - — `core-backend/models.go` (GORM), `backend/app/models.py`(Python 프로토타입)와 동일 스키마 +- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings, + escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py` + (Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가) - [ ] 푸시 알림 서비스 연동 `backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것. @@ -76,6 +77,13 @@ 실제로 일어나는 단 하나의 지점(메시지 저장)에서 걸리므로 클라이언트가 뭘 하든 우회 불가. AI 서비스 응답 불가 시 fail-safe(발송 차단, 502)로 처리. 사람이 직접 보내는 메시지는 게이트 대상이 아님. 차단·통과·게이트 불능 3케이스 전부 실제 테스트로 확인함 (`core-backend/main_test.go`) +- [x] **발견 및 수정**: 거부권(peer veto) 안전 불변식이 코드에 전혀 구현되어 있지 않았음 — + `Contact.TwinDisabledByPeer` 필드만 스키마에 있고 어디서도 읽거나 쓰지 않았고, `tech-design.md` + §4는 "대화방 단위 플래그"라는데 실제로는 상대(Contact) 단위로 모델링돼 있어 설계 문서와도 + 불일치했음(2.6 작업 중 발견). `Conversation.TwinDisabledByPeer`로 옮기고 `POST + /conversations/:id/veto` 추가, 메시지 발송 시 **거부권 → 에스컬레이션 → 자율성 레벨** 순으로 + 체크(거부권이 전부보다 우선) — L2 화이트리스트 매칭 + `approved:true`여도 거부권이 켜져 있으면 + 무조건 차단되는 것까지 테스트로 확인함 - [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경(SDK @@ -94,9 +102,20 @@ 환경 대기 **2.6 베타 배포 준비** -- [ ] 초대 기반 베타 가입 플로우 -- [ ] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 -- [ ] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) +- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 + 실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고 + `/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드 + 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자 + 인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것) +- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 + `/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 + 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 + 보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요 +- [~] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — `GET /admin/metrics`로 + 카운트 기반 데이터(메시지 수, 에스컬레이션 사유별 집계, 거부권 발동률, 초대 코드 발급/사용 수)는 + 노출함. **대시보드 UI 자체와 생성 지연시간·오류율**은 아직 없음 — UI는 Flutter/관리자 웹 쪽이고, + 지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지 + 않고 명시적으로 비워둠) #### 3. PoC 결과가 있어야 정할 수 있는 것 (맨 마지막 — 그 전엔 손대지 않는다) @@ -125,11 +144,14 @@ 의존이라 2.3(Flutter) 대기 4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 -5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 삭제 플로우. 2.5: `ai-service` - pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 로직이 필요해서 - 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽(온디바이스 암호화· - 데이터 흐름 대시보드·수동 QA) — Flutter 대기 -6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 +5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5: + `ai-service` pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 + 로직이 필요해서 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽 + (온디바이스 암호화·데이터 흐름 대시보드·수동 QA) — Flutter 대기 + - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST + /invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈 + 시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것 +6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 §3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다.