2.6 베타 배포 준비: 실제 초대 코드 시스템 + 운영 지표 + 거부권 안전 불변식 수정

발견한 문제(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 결과 필요)이 끝나야
정할 수 있어서 여전히 보류 -- 이번엔 서버 인프라만 준비함
This commit is contained in:
Claude 2026-07-30 02:51:15 +00:00
parent de8b44d536
commit f79108b9cd
No known key found for this signature in database
5 changed files with 389 additions and 83 deletions

View File

@ -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 등 도입)

View File

@ -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 {

View File

@ -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{

View File

@ -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{},

View File

@ -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 결과 없이 확정하지 않는다.