Skip to content

유저 기록 원본 불변 계약 (이슈 #1236)

원칙 P0 (오너 승인 2026-09-04): 유저가 기록한 것은 원본이고, 앱이 계산·해석한 것은 투영이다. 원본은 유저 본인만 바꾸며, 바꿔도 이전 버전이 남는다. 시스템(마이그레이션·트리거·백필·서버 함수·관리자)은 원본을 절대 고치지 않고 해석만 바꾼다. 원본에 맞지 않는 값은 숨기는 것이지 지우는 것이 아니다.

계기: 2026-08-13 마이그레이션 20260812001100(PR #407)이 "기록 유형에 없는 값은 자리표시자 0이다"라고 가정하고 유저가 실제로 입력한 세트 무게 16건을 null로 바꿨다(이슈 #1235). 테스트는 작성자의 가정만 검사했고, CI·로컬 검증은 빈 DB에서 돌았고, 되돌릴 사본은 없었다. 절차를 하나 더 얹는 대신 시스템이 원본을 건드릴 길 자체를 없애는 것이 이 계약이다.

1. 세 등급

유저 테이블의 모든 컬럼은 아래 셋 중 하나의 등급을 가진다. 등급 목록의 정본은 supabase/contracts/user-fact-columns.json(v2)이고, 계약 테스트 tests/react/userFactColumnsContract.test.mjs가 "등급 없는 컬럼"과 "스키마에 없는 컬럼"을 잡는다. 새 컬럼을 추가하는 마이그레이션은 같은 PR에서 등급을 등재해야 한다.

등급누가 바꾸나
원본(fact)유저가 입력했거나 유저의 행위로 정해진 값. 기록 시점에 고정된 규칙 스냅샷(체중 계수·무게 배수·기록 유형)도 여기 — 바꾸면 과거 기록의 뜻이 바뀐다무게·반복수·RPE·휴식·메모·세트 종류·순서, 세션 날짜·제목·시간·체중·메모, 종목 선택·기록 유형 선택, 프로필, 커스텀 종목 이름·기록 유형, 그룹 글·댓글·좋아요·멤버십그 행을 소유한 유저의 저장 요청만. 시스템은 수리 티켓으로만(§3)
투영(projection)규칙과 계산으로 원본에서 나온 값effective_load, stats_load_kg, stats_effective_load_kg, 세트 스코어, 등급, 정규화된 이름, 집계, measurement_type(기록 유형의 호환용 거울)시스템이 자유롭게 다시 계산한다. 원본이 그대로면 언제든 재생산 가능해야 한다
시스템(system)서버가 관리하는 열id, created_at, updated_at, server_revision, 출처(source·source_ref·imported_at·raw_payload), 요청 해시, 그룹 멤버의 role(승계 트리거만 바꾼다)서버

2. 파생 규칙

  • R1 등급. 유저 테이블의 모든 컬럼은 등급이 있다(§1). 등급 누락 = 계약 테스트 실패.
  • R2 원본은 유저 본인만 바꾼다. 원본 컬럼의 변경과 행 삭제는 그 행의 소유 유저(등급 목록의 owner 규칙)의 저장 요청으로만 가능하다. 행위자는 로그인 신원(auth.uid())이거나, 서버 대리 실행 엔진이 문에서 한 번 읽어 넘긴 명시 신원(lift_guild.actor_user_id, 명시적 신원 전달 계약)이다. 시스템 경로는 원본을 쓸 수 없다. 유일한 예외는 수리 티켓 선언(§3).
  • R3 원본 변경은 이력이 남는다. 원본이 바뀌거나 행이 지워질 때 이전 행 전체가 추가만 되는(수정·삭제 불가) 이력 테이블 user_fact_history에 자동 기록된다 — 어느 테이블·어느 행·이전 값·바뀐 컬럼·행위자(유저 또는 티켓)·주인·시각. 복원 = 이력에서 한 행을 되살리는 것(restore_user_fact_v1, 티켓 필요, 복원도 이력에 남는다).
  • R4 규칙이 바뀌면 투영만 바뀐다. 기록 유형·단위·종목 프로필·계산 공식이 바뀌면 "무엇을 보여주고 계산하느냐"만 바뀌고 원본은 그대로다. 프로필에 없는 원본 값은 숨김이지 삭제가 아니다. 저장 시 프로필 밖 값은 거부한다 (Completed set contains a value outside recording_fields). 숨겨진 값이 있는 행을 유저가 다시 저장할 때, 앱이 그 값을 보내지 않았다는 이유로 서버가 지우지 않는다(저장 함수의 수정 경로는 기록 유형에 없는 값을 기존 값으로 둔다). 절대 조용히 버리지 않는다. 숨긴 값은 어떤 계산에도 섞이지 않는다 — 통계·표시 함수는 원본 열(load·assist_kg·reps)이 아니라 기록 유형을 보고 채운 투영 열(stats_load_kg·stats_effective_load_kg·stats_reps, 트리거 project_exercise_set_part_values_v1)만 읽는다. 원본 reps를 읽어도 되는 함수는 쓰기·검증·인입·투영·자체 필터 읽기뿐이고 게이트 tests/react/repsProjectionReadersGate.test.mjs가 허용 목록으로 고정한다(#1256).
  • R5 마이그레이션은 스키마와 투영만 바꾼다. 원본 컬럼에 대한 UPDATE/DELETE는 금지다. 원본을 옮겨야 하면 (컬럼 이전·타입 변경·정정) 수리 티켓 + 사전 유실 증명(npm run db:loss-audit) + 이력 자동 보관이 함께 있어야 한다. 보호 트리거 해제·원본 테이블/컬럼 DROP·TRUNCATE도 티켓 없이는 거부된다(DB 층 이벤트 트리거 + 정적 검사).

3. 수리 티켓 — 시스템이 원본을 만지는 유일한 문

정당한 데이터 수리(예: #1235의 기록 유형 정정)는 트랜잭션 안에서 티켓을 선언하고 진행한다.

sql
begin;
select set_config('lift_guild.repair_ticket', 'issue#1235', true);   -- 이 트랜잭션 안에서만 유효
update public.session_exercise_part set recording_fields = array['load','reps'] where id in (...);
-- 보호 트리거가 이전 행을 user_fact_history에 ticket='issue#1235'로 기록한 뒤 통과시킨다
commit;

티켓 형식은 넷이고 전부 이력의 repair_ticket에 그대로 남는다.

형식누가
issue#N사람이 이슈로 승인한 데이터 수리. 이슈에는 무엇을 왜 바꾸는지, 유실 증명(db:loss-audit 리포트), 오너 승인이 있어야 한다issue#1235
admin:<함수명>관리자 화면의 대리 수정 함수 — 본문 첫머리(관리자 권한 검사 바로 뒤)에서 호출 동안 선언한다. 관리자 권한 검사는 그대로admin:admin_rename_performance_detail
pgtap:fixturepgTAP 픽스처가 원본 테이블을 직접 UPDATE/DELETE(upsert 포함)할 때 파일 첫머리에 선언 — 보호 자체를 시험하는 파일은 쓰지 않는다pgtap:fixture
  • 티켓이 있어도 이력은 자동으로 남는다. "이력 없이 바꾸기"는 어떤 문으로도 불가능하다.
  • 마이그레이션 정적 검사(check:migrations)는 원본 컬럼 DML을 발견하면 같은 파일에 티켓 선언과 증명 줄이 있는지 확인한다. 마이그레이션은 명시 신원(lift_guild.actor_user_id)을 흉내 낼 수 없다(엔진의 p_actor 전달 한 줄만 허용).

4. 보호에서 빼는 것과 연쇄 (오너 결정 D5, 2026-09-04 + Phase 1 설계)

  • 인입 출처 행sourcebarbelic이 아닌 행(WodUp·Motra 인입)은 재인입 시 전량 삭제가 전제이므로 보호 대상에서 뺀다. 등급 목록의 exempt 항목이 이 조건을 적는다. 유저가 인입된 세션을 앱에서 편집해 저장하면 그 저장은 유저 저장이므로 R2·R3이 그대로 적용된다.
  • 공식 카탈로그 종목exercisesowner_user_id가 있는 커스텀 행만 보호한다(protectWhen). 공식 카탈로그는 관리자 카탈로그 작업의 영역.
  • 시스템 삭제가 허용된 행(systemDeleteAllowedWhen) — 만료된 서버 백업(workout_draft_checkpoints, 만료 뒤), 멤버가 0명이 된 그룹(승계 트리거가 지운다). 만료 전 백업·멤버가 있는 그룹은 주인만.
  • 계정 삭제 연쇄 — 주인 계정이 auth.users에 더 이상 없으면 행위자·티켓 없이도 통과하고 이력을 남기지 않는다(개인정보 삭제). 이력 테이블은 owner_user_id로 계정 삭제와 함께 지워진다 — 이것이 "추가만 가능" 규칙의 유일한 예외. 두 사용자를 함께 가리키는 표(차단 user_blocks, 신고 content_reports)는 주인이 아닌 쪽 계정을 등급 목록의 accountRefs에 등재하고, 그 계정이 없어진 경우도 같은 연쇄로 통과한다 — 차단당한 사람·신고당한 사람이 계정을 지울 수 있어야 한다(이슈 #1624, 2026-09-15). 팔로우처럼 주인이 anyOf인 표는 주인 판정에 두 계정이 모두 들어 있어 별도 등재가 필요 없다.
  • 부모 삭제 연쇄 — 세션을 지우면 종목·세부 종목·세트·세부 세트가 함께 지워진다. 자식 행의 트리거는 부모 행이 이미 없으면 "부모의 트리거가 판정했다"로 보고 통과하며, 행위자·티켓이 있으면 이력만 남긴다(등급 목록 parents). 신고 표는 신고 대상 세션을 부모로 둔다 — 신고된 세션을 주인이 지우면 그 신고 행도 함께 사라진다(#1624). 그룹 보드(그룹 운동 계획 = session.group_id가 있는 계획 세션)는 공지·채팅과 같은 그룹 콘텐츠라 그룹을 부모로 둔다 — 그룹이 사라지면(그룹장의 해산, 마지막 멤버 이탈) 멤버가 올린 보드도 함께 사라지고, 해산한 그룹장이 행위자로 이력에 남는다. 보드로 실제 한 운동(완료 세션)은 출처 열(origin_kind/origin_ref)로 이어지므로 남는다(#1630, #1061 G3).
  • 참조 해제 연쇄 — 부모가 지워질 때 행은 남고 참조만 비워지는 외래키(ON DELETE SET NULL, 예: 신고 표의 target_comment_id)는 등급 목록 parentsonGone: "detach"로 등재한다. 그 열만 비우는 UPDATE는 가리키던 행이 실제로 없을 때 연쇄로 통과하며, 행위자·티켓이 있으면 이력을 남긴다. 신고 행 자체(신고자의 원본)는 그대로 남는다(#1624).
  • 선언 누락 게이트 — 원본 열의 외래키(계정 참조 CASCADE, 등재 표 참조 CASCADE/SET NULL)가 owner·accountRefs·parents(onGone) 어디에도 선언돼 있지 않으면 계약 테스트 tests/react/userFactCascadeDeclarations.test.mjs(QA1)가 실패한다. 선언이 없으면 주인이 살아 있는 한 그 연쇄가 42501로 막히기 때문이다(#1624에서 차단당한 계정·신고당한 계정의 계정 삭제와 신고된 세션·댓글의 본인 삭제가 그렇게 막혔고, #1630에서 멤버가 보드를 올린 그룹의 해산이 그렇게 막혔다). 게이트의 "알려진 미선언" 목록은 #1630으로 비었다 — 새 연쇄는 선언하거나 외래키 정의를 바꿔야 PR이 통과한다.
  • 참조는 지우지 않는다 — 참조된 쪽을 못 지운다 — 유저가 고른 참조 열(세트의 %기준 종목 exercise_set_part.load_percent_base_exercise_id, 세부 종목의 표기 session_exercise_part.synonym_id)은 가리키던 종목·동의어가 사라질 때 시스템이 비우지 않는다(ON DELETE SET NULL 폐기). 대신 참조되는 동안은 종목·동의어를 지울 수 없다(NO ACTION, 계정 삭제 연쇄가 순서와 무관하게 통과하도록 DEFERRABLE INITIALLY DEFERRED — 세부 종목의 exercise_id와 같은 방식). 커스텀 종목은 원래 비활성화만 하고 지우지 않으며 관리자 동의어 삭제는 쓰이는 동의어를 거부하던 정책을 DB 정의로 옮긴 것이다. 카탈로그 정리 마이그레이션이 쓰이는 종목·동의어를 지우려면 참조를 먼저 옮겨야 한다 — 티켓이 있어도 참조를 소리 없이 비울 수 없다(#1630).
  • 관리자 대리 수정은 빼지 않는다 — 티켓 방식(§3 admin:)으로 이력을 남기며 진행한다.

5. 어느 층에서 무엇이 막히나

장치하는 일
스키마 계약등급 목록 JSON v2 + 계약 테스트등급 누락·스키마와 어긋난 등재를 PR에서 잡는다. 보호 마이그레이션의 마커 구간 = 생성기 출력인지 대조 (Phase 0·1)
행 쓰기원본 보호 트리거(테이블마다 1개 <t>_zz_protect_user_facts_v1, 원본 컬럼 목록은 등급 목록에서 생성기 scripts/migrations/generate-user-fact-protection.mjs가 뽑는다)원본 컬럼 변경·행 삭제 시: 보호 밖·연쇄(§4) → 통과, 소유 유저면 이력 후 통과, 티켓이면 이력 후 통과, 그 외 42501. 투영만 바뀌면 통과. TRUNCATE는 티켓 필요 (Phase 1)
이력user_fact_history, 추가만 가능(계정 삭제 연쇄 제외)모든 원본 변경·삭제의 이전 버전 + 복원 함수 (Phase 1)
규칙 변경값 삭제 트리거 폐지, 투영 계산 함수 project_exercise_set_part_values_v1(기록 유형을 보고 effective_load·stats_* 계산), 부모 변경 시 재투영, 저장 수정 경로의 숨은 값 보존프로필 변경 = 표시·계산 변경. 원본 무변경 (Phase 2)
시스템 쓰기 경로서버 함수 48개 분류(§6): 유저 경로 / 투영·시스템 갱신 / 인입 / 관리자·백필(티켓) — 엔진은 명시 신원 선언인입은 제외 조건으로, 관리자·백필은 함수 설정의 티켓으로 (Phase 3)
스키마 변경(DDL)이벤트 트리거 2개(sql_drop·ddl_command_end) + check:migrations 확장 + db:loss-audit보호 테이블·원본 컬럼·보호 트리거/함수의 DROP, 보호 트리거를 끈 ALTER를 티켓 없이는 거부. 타입 변경은 정적 검사가 막는다 (Phase 4)
복구매일 보호 테이블 데이터 사본 90일(D2) + 이력 복원 함수 + rollback.md 값 복원 절특정 유저의 특정 행을 되살린다. 전체 DB 롤백은 종전대로 금지 (Phase 5)
수정 화면은 원본을 그대로 들고 다닌다표기 문자열(투영)을 원본 자리에 넣지 않는다 (#1235 휴식 NaN)

잔여 위험(명시): 슈퍼유저 권한(supabase_admin)은 이벤트 트리거를 받지 않는다. 마이그레이션은 postgres로 돌아 이벤트 트리거를 받는다. 이벤트 트리거는 ALTER COLUMN … TYPE을 구분하지 못해 정적 검사에 맡긴다. (반복수를 직접 읽던 통계·표시 함수 20개는 #1256에서 투영 stats_reps로 옮겼다 — 잔여 위험 해소.)

6. 시스템 쓰기 함수 분류 (#1215 최종 스키마 실측, 원본 테이블에 UPDATE/DELETE 하는 함수 48개)

갈래판정함수
유저 경로 (행위자 = 주인)통과 + 이력save_session_v5_engine·delete_session_v5_engine·write_session_children_v5(명시 신원), set_profile_handle_v1, set_profile_sex_v1, complete_onboarding_v2/v4, apply_onboarding_progress, apply_onboarding_consents, withdraw_user_consent, save_manual_pr, delete_manual_pr, save_manual_record_metric_v1, delete_manual_record_metric_v1, set_user_exercise_favorite, replace_user_exercise_favorites, set_own_custom_exercise_active, create_catalog_exercise(공식 행만), set_session_like_v1, set_group_board_like_v1, delete_session_comment_v1, delete_group_board_comment_v1, add_group_notice_v1, delete_group_chat_message_v1, disband_group_v1, block_user_v1, unblock_user_v1, unfollow_user_v1, clear_workout_draft_checkpoint_v1, get_workout_draft_checkpoint_v1(만료 삭제), update_completed_session_v4_engine·delete_completed_session_v4_engine(폐기됨, LG426)
투영·시스템 컬럼만 갱신통과, 이력 없음refresh_session_effective_loads, touch_completed_session_revision_from_child_v1, group_members_departure_v1(역할 승계·빈 그룹 삭제), admin_resolve_content_report_v1(처리 상태 컬럼)
인입 출처 행만 (D5)통과, 이력 없음import_wodup_batch_to_canonical_engine, backfill_wodup_bodyweight_snapshot_v1, backfill_wodup_session_timing_v1, apply_completed_atomic_values_v1(옛 v4 경로만 호출)
관리자 배치본문 첫머리의 admin: 티켓 + 이력admin_delete_performance_detail, admin_rename_performance_detail, admin_set_default_exercise_synonym_v1, remove_import_data_v1
이미 폐기된 함수(schema.sql 에 옛 정의만 남음)없음 — 되살리지 않는다remap_wodup_user_customs_to_canonical_v1·materialize_wodup_unmatched_user_customs_v1(#1175), backfill_session_hierarchy_v1(#1215 실행 후 삭제), update_completed_session_v4_engine·delete_completed_session_v4_engine(v5 로 대체)
폐지·축소없음rescrub_session_exercise_sets_v1, scrub_planned_set_atomic_values_v1(값 삭제 트리거, D3), set_exercise_set_effective_load(투영 함수로 대체), populate_wodup_atomic_set_values_v1의 "기록 유형 밖 값 null 처리" 블록(제거 — 인입 값 채움만 남김)

7. 적용 지점과 순서 (오너 결정 D4)

이 계약의 첫 적용 대상은 #1215(운동 기록 계층 리모델링)가 만든 운동 기록 테이블 5개 session·session_exercise·session_exercise_part·exercise_set·exercise_set_part와 유일한 원본 쓰기 경로 save_session_v5다. Phase 1은 나머지 유저 테이블 18개(프로필·커스텀 종목·체중·수기 기록·즐겨찾기·동의·서버 백업· 그룹·댓글·좋아요·팔로우·차단·신고)에도 같은 생성기로 보호 트리거를 만든다 — 등급 목록에 등재된 테이블 전부.

원본 컬럼 이름을 바꿀 때(이슈 #1245 실측, 2026-09-04): 보호 트리거 함수는 원본 컬럼을 이름으로 하나씩 비교하므로 rename 뒤에는 옛 본문이 그 테이블의 UPDATE/DELETE 마다 42703 으로 실패한다. 순서 = ① rename 마이그레이션 ② 같은 PR 에서 등급 목록(owner.column·columns·parents[].column)의 열 이름을 바꾸고 ③ 생성기 출력 전체를 새 *_user_fact_history_and_protection_v1.sql(첫 줄에 수리 티켓 set_config('lift_guild.repair_ticket', 'issue#NNNN', true), 원본 DML 없음)로 재발행한다. 계약 테스트가 마지막 슬러그 파일의 마커 구간을 생성기 출력과 대조한다.

8. 오너 결정 장부

번호결정 (2026-09-04)
D1P0와 R1~R5 승인
D2시점 복원(PITR)은 재정 문제로 켜지 않는다. 보호 테이블 데이터 사본을 매일 따로 저장(90일) — User data daily copy 워크플로, 첫 실행 2026-09-04 성공
D3규칙 변경 시 맞지 않는 값은 숨김. 값 삭제 트리거 2개(rescrub_session_exercise_sets_v1, scrub_planned_set_atomic_values_v1) 폐지. 숨긴 값은 재저장 시 건드리지 않는다
D4#1215 랜딩 직후 첫 후속 랜딩으로 적용
D5인입 출처 행은 보호에서 제외(재인입 = 전량 삭제 전제). 관리자 대리 수정은 티켓 방식 유지

9. Phase 현황

Phase내용상태
0이 문서 · 등급 목록 · 계약 테스트 · 전역 규칙완료 (PR #1243)
1이력 테이블 + 보호 트리거 (등급 목록의 유저 테이블 23개)마이그레이션 user_fact_history_and_protection_v1
2규칙 변경 = 투영 (값 삭제 트리거 폐지, 투영 계산 함수, 저장 수정 경로 숨은 값 보존)마이그레이션 recording_fields_change_is_projection_v1 · 2-2(반복수 투영·읽기 함수 교체)는 후속 이슈 #1256
3시스템 쓰기 경로 재분류 (§6)마이그레이션 system_write_paths_reclassified_v1
4DDL 보호 + 마이그레이션 검사 + 유실 증명 도구도구(PR #1243·#1247) + 마이그레이션 user_fact_ddl_guard_v1
5데이터 사본 cron + 복원 절차완료 (PR #1243, 첫 사본 2026-09-04)
6나머지 유저 테이블 확장Phase 1 에 흡수(등급 목록의 23개 테이블 전부)

D14 불변 행의 물리 쓰기 생략 (2026-09-10)

세션 자식의 변경 행 저장은 값이 같은 행을 UPDATE하지 않는다. 실제 원본 변경·삭제와 재정렬에 필요한 위치 이동은 기존 보호 트리거와 이전 행 이력을 유지한다. 체중·계수·배율·기록 항목 변경은 필요한 투영만 다시 쓰며 숨겨진 원본은 보존한다. 복원 뒤 같은 ID의 재편집, 중간 DML 실패의 전체 롤백과 owner/revision 거부를 실제 DB에서 검증한다.