BRID — Barbelic Resource ID
작성: 2026-08-19 (오너 확정 설계의 레포 정본화), 세션 자원 편입 2026-08-29. 자원(운동 종목·세션)의 UUID가 어디서 왔는지를 사람이 읽을 수 있는 구조화 문자열로 고정하고, 그 문자열에서 UUID를 결정적으로 파생하는 통일 식별 체계다. test/bridIdentitySqlContract.test.mjs가 이 문서의 핵심 조항과 구현의 일치를 검증한다.
왜 — 키 체계 3층과 BRID의 위치
외부 앱(WodUp 등)의 종목 하나가 우리 DB에 들어올 때 키는 세 번 모습이 바뀐다.
| 층 | 예 | 정체 |
|---|---|---|
| 1. raw provider id | 128946 | 외부 앱 자체의 키. 우리는 의미 부여 없음 |
| 2. 스코프드 provider 키 | wodup:user:49268:128946 | 인입 normalizer가 provider명+스코프(global/user/gym/synthetic)를 입힌 것. DB에 저장되며 매핑 테이블의 왼쪽 항 |
| 3. BRID | brid:exercise:external:<owner>:wodup:user:49268:128946 | 우리 쪽 통일 식별 문자열. UUID의 결정적 파생 원천 |
정본 종목과 유저 custom은 외부 출신이 아니므로 3층(BRID)에서 바로 시작한다. BRID 이전에는 3층이 lift-guild: 접두 시드 4계열로 흩어져 있었고 문법 정본이 없었다(→ 하단 "동결된 legacy 시드").
문법
brid:<resource>:<lineage>:<tail...>- 항상
brid로 시작한다. - 머리 3조각(
brid/resource/lineage)만 위치로 해석한다:split(':', limit=4). 전체 split 금지 — tail이 콜론을 품으므로 전체 split은 오해석 사고(#453류 키공간 불일치)의 재발 경로다. - 머리 필드 charset은 소문자
[a-z0-9-]. - tail은 불투명(opaque) — 콜론 포함 가능, 발급 후 verbatim 보존. 대소문자· 공백·특수문자를 사후 정규화하지 않는다(결정성의 생명줄).
- 머리/꼬리 분류 기준: 머리에는 모든 BRID가 예외 없이 갖는 보편 필드만 둔다. owner는
official-catalog에 없어 보편이 아니므로 tail 소속이다. 머리 필드 추가는 이 문법의 유일한 호환 파괴 변경이므로 금지 — 새 필드 구성이 필요하면 lineage 추가 + tail 서브 문법 정의로 해결한다.
resource
자원 종류. exercise(2026-08-19)와 session(2026-08-29 편입 — 하단 세션 절). 향후 batch 등으로 확장 가능하다.
lineage — 명명 원천(naming authority) 선언
"이 이름을 누가 지었고, 어느 범위 안에서 유일한가"를 답하는 닫힌 enum. 행에서는 owner_user_id·source 두 컬럼으로 판별한다(2026-09-03 #1175: 별도 origin 컬럼은 이 두 값으로 100% 유추되어 퇴역했다).
| lineage | 명명 원천 | tail 서브 문법 | 유일성 범위 | 행 판별 |
|---|---|---|---|---|
official-catalog | 바벨릭 공식 카탈로그 | <slug> (발급 당시 스냅샷) | 전역 | owner_user_id is null |
user-custom | 유저의 직접 입력 | <owner_uuid>:<compact 이름키> | 소유자 내 | 소유자 있음 · source = 'barbelic' |
external | 외부 provider의 키 체계 | <owner_uuid>:<스코프드 provider 키> | 소유자 내 | 소유자 있음 · source <> 'barbelic' (source_ref = provider 원본 키) |
- "닫힌"의 뜻은 자유 문자열 금지 + 값 추가는 오너 결정(거버넌스 규칙)이다. 추가 자체는 문법 개정 없이 가능하고 기존 UUID에 무영향이다(장래 후보:
org,derived). provider는 lineage로 승격하지 않는다 — 새 provider가 와도 enum은 자라지 않고, provider 구분은 스코프드 키의 첫 조각이 담당한다. - 비전역 계보(
user-custom,external)는 tail 첫 조각에 owner UUID가 필수다. 무스코프 발급은 builder가 거부한다. - 인입산 custom을
user-custom계보에 합치지 않는 이유: 동명이면 같은 BRID → 같은 UUID로 융합되어, 손으로 만든 종목과 인입산 종목이 카탈로그 레벨에서 구분 불가가 된다(전량 제거 원칙 붕괴). "유저 custom과 동일 취급"은 권한· 수명주기의 이야기고 정체성 공간은 분리한다. - 정합성 트라이앵글: lineage
external⇔source <> 'barbelic'(소유자·source_ref필수 —exercises_source_ref_shape_check) ⇔ 외부 인입 전량 제거 대상. 세 값은 항상 일치해야 한다.
tail 세부 규칙
official-catalog: 발급 당시의 카탈로그 slug. BRID 속 slug는 발급 시점 스냅샷일 뿐, 이후 slug 변경과 무관하다 (기존 "slug는 식별자가 아니다" 원칙과 이렇게 양립한다). builder가^[a-z0-9][a-z0-9-]*$를 강제한다.user-custom:brid_compact_name_key_v1로 정규화한 이름키. 정규화 = 소문자화 후 라틴 문자·숫자·한글 외 전부 제거. 표기 변형("원 암 로우(변형)" / "원암로우 (변형)")이 같은 키로 접힌다 = 같은 종목.external: 2층 스코프드 provider 키를 그대로 잇는다. 스코프드 키가 provider명으로 시작하므로 별도 provider 필드가 없다(구 시드의 이중 접두 해소). builder가 콜론 없는 raw id(128946)를 거부한다 — 무스코프 키는 정체성을 발급받을 수 없다.
UUID 파생
uuid = 조립(md5(brid))md5 hex 32자를 8-4-4-4-12로 자르되 3번째 그룹 첫 니블을 5, 4번째 그룹 첫 니블을 8로 고정한다(digest 13·17번째 문자는 버려짐). legacy 시드와 동일한 조립식이며, 구현은 public.brid_uuid_v1(text) 하나뿐이다. 고정 벡터는 supabase/tests/database/brid_identity_v1.test.sql이 실 DB에서, test/bridIdentitySqlContract.test.mjs가 독립 JS 구현으로 이중 검증한다.
발급 규칙
external계보는 매핑 실패분에만 발행한다. 매핑에 성공한 provider 키는 실체를 만들지 않고exercise_external_mappings행(스코프드 키 → 정본 UUID)으로 번역만 한다. 성공분에 UUID를 발행하면 같은 운동에 실체가 둘 생긴다(카탈로그 정본 원칙 위반).- 발급 주체는 DB 함수 단일 소유다:
brid_for_official_exercise_v1/brid_for_user_custom_v1/brid_for_external_exercise_v1→brid_uuid_v1. 클라이언트·Edge에서 BRID 문자열을 임의 조립해 UUID를 만드는 것은 금지. - 발급된 BRID는
exercises.brid컬럼에 저장한다(nullable, 부분 unique). 저장 목적은 "이 UUID가 어디서 왔나"의 즉답 — legacy 시드는 함수 안에서 생성 후 소멸해 디버깅이 불가능했던 것의 교정이다. 형식은exercises_brid_format_check가 닫힌 lineage enum까지 강제한다.
예외 없음 — legacy 시드 4계열은 은퇴했다
모든 종목은 BRID를 갖고, 그 UUID는 BRID에서 파생된다. 이것이 이 문서의 유일한 규칙이고, 예외는 없다. DB가 그렇게 강제한다: exercises.brid는 not null 이고, 행 트리거가 id = brid_uuid_v1(brid)를 요구하며, BRID 없이 들어온 행은 랜덤 id를 받는 대신 거부된다.
한때는 그렇지 않았다. 아래 4계열이 UUID를 만들던 시절이 있었고, 그때 발급된 행은 exercises.brid가 null인 채로 남아 있었다 — 한 테이블에 정체성 체계가 둘이었고, 행만 봐서는 어느 쪽이 만든 것인지 알 수 없었다. 20260820250000이 그 행들을 전부 자기 계보의 BRID로 재발급해 그 시대를 끝냈다.
| legacy 시드 | 용도 | 현재 |
|---|---|---|
lift-guild:exercise:<slug> | 2026-07 UUID 컷오버: 시스템 카탈로그 이관 | 은퇴 — official-catalog 계보로 재발급됨 |
lift-guild:user-exercise:<user>:<구id> | 컷오버: legacy 유저 custom 이관 | 은퇴 — user-custom 계보로 재발급됨 |
lift-guild:external:<provider>:<키> | 전역 placeholder 정체성 | 은퇴 — placeholder 자체가 폐지됨 |
lift-guild:wodup-user-custom:<user>:<키> | 미매칭 custom 실체화 | 은퇴 — external 계보로 재발급됨 |
이 표는 역사로 남긴다. 옛 UUID가 어디서 나왔는지 추적할 일이 생기면 여기가 출발점이고, 그 문법은 여전히 동결이다 — 소급 수정하지 않는다. 다만 그 어떤 행도 더는 이 레시피로 발급되지 않는다.
주의: legacy external 시드는 provider 인자와 스코프드 키의 이중 접두 (...external:wodup:wodup:user:...)를 가졌다. 이는 버그가 아니라 동결된 역사다.
인입산 custom이 external 계보인 이유
재발급에서 (당시 구분값) origin='user'이면서 source='wodup'인 행 — 인입 중 카탈로그가 못 맞춰 실체화된 종목 — 은 provider 키로 키잉되는 external 계보로 갔다. 이름이 아니다. WodUp에서 wodup:global:123과 wodup:user:49268:456이 둘 다 "Back Squat"으로 표시되는 것은 흔하고, 이름으로 키잉했다면 두 종목이 한 UUID로 융합되어 기록이 되돌릴 수 없게 합쳐졌을 것이다. 계보를 따라 당시의 origin도 external로 옮겨졌고, 그 결과 정합성 트라이앵글이 회복된다.
적용 상태와 이행
- 기반(20260819140000): builder·파생 함수,
exercises.brid저장, 형식 게이트, 이중 검증 테스트까지 = 완성. 이 시점의 라이브 발급 경로는 아직 legacy 시드와 랜덤 UUID를 쓰고 있었다. - 발급 배선 1단계(20260820110000): 앱 내 custom 생성 (
create_custom_exercise)은brid_for_user_custom_v1파생으로, 정식 카탈로그 생성(create_catalog_exercise_engine)은brid_for_official_exercise_v1파생으로 전환됐다. 두 경로 모두 발급한 BRID 문자열을exercises.brid에 함께 저장한다. 같은 소유자의 같은 정규화 이름은 같은 종목을 반환하고(멱등), 활성 정식 종목과 같은 이름의 custom 생성은 거부하며 기존 정식 종목을 에러 detail로 안내한다. 이 전환과 함께 앱 내 custom의source도'user'에서 네이티브 토큰 으로 합쳐졌다. - 1단계화(20260820140000): 매핑 실패는 전역 placeholder를 거치지 않고 그 자리에서
brid_for_external_exercise_v1파생 오너 스코프 종목이 된다. "기존 행 조회 우선, 부재 시에만 발급" 순서를 지켜 중복 발급을 막는다. - 전면 재발급(20260820250000): legacy 시드로 발급됐던 기존 행 전부를 자기 계보의 BRID로 재발급하고, 참조 23개 테이블·id 배열 2곳·jsonb 페이로드를 함께 옮겼다. 이후
exercises.brid는 not null이고id = brid_uuid_v1(brid)가 행 트리거로 강제된다 — BRID 없는 종목도, BRID에서 나오지 않은 UUID도 존재할 수 없다.
세션 — 두 번째 BRID 자원 (2026-08-29, 이슈 #879)
세션은 종목에 이어 두 번째로 BRID 체계에 편입된 자원이다. 새 발급 체계를 발명한 것이 아니라, DB가 이미 세션을 유일하게 식별하고 있던 자연키 (user_id, source, source_ref) — 유니크 인덱스 sessions_source_ref_uidx·비공백 제약·저장 멱등이 전부 그 위에서 동작 — 를 문자열로 승격했다.
계보 (오너 설계 2026-08-28)
| lineage | 명명 원천 | tail 서브 문법 | 유일성 범위 |
|---|---|---|---|
barbelic | 앱 자체 기록 | <owner_uuid>:<source_ref> | 소유자 내 |
external | 외부 앱의 세션 키 | <owner_uuid>:<source>:<source_ref> | 소유자 내 |
- 종목과 달리 세션에는 전역 계보가 없다 — 세션은 반드시 소유자와 출처가 있다. 비-barbelic
source(wodup·motra·이후 추가분)는 전부external로 접힌다. provider는 lineage로 승격하지 않는다는 규칙 그대로, 어느 앱인지는 tail 선두의source조각이 담당한다 — 세션의source_ref는 스코프 접두 없이 저장되므로 (4954146같은 bare 키), external tail은<source>:<source_ref>결합으로 종목의 "스코프드 provider 키"와 동형을 만든다. - tail 불투명 원칙 그대로:
source_ref는 verbatim 보존(콜론 포함 — barbelic 실데이터가local:local-workout:<uuid>모양이다), 사후 정규화 없음.
barbelic source_ref 계보 규약 (2026-08-29, 이슈 #880 — 오너 결정 a안)
barbelic 세션의 source_ref는 클라이언트가 드래프트 생성 시 발급하며, 어디서 시작했는지(출처)가 곧 발급 규약이다 — 출처는 별도 컬럼이 아니라 BRID 정체성에 내장된다(태어난 사실, 불변).
| 시작 경로 | source_ref 규약 | 발급 지점 |
|---|---|---|
| 일반 시작(홈·계획·수기) | local:<operationId> | workoutSourceRef (workoutDraftCache) |
| 그룹 보드 "운동하러 가기" | group-board:<group_id>:<board_date>:<operationId> | groupBoardWorkoutSourceRef (workoutDraftCache) |
operationId=local-workout:<uuid>— 같은 보드에서 여러 번 시작해도 세션이 구분되는 유일성 꼬리. 좌표(그룹 uuid·YYYY-MM-DD)가 어긋나면 발급하지 않고 기본local:*로 조용히 떨어진다 — 보드 사정이 운동 저장을 막지 않는다.- 소비: 그룹 보드 완료 행 RPC(
get_group_board_day_sessions_v1)가group-board:<group>:<date>:접두 매칭으로 "이 보드에서 시작해 완료한 세션"을 판정한다. 세션의 date와 무관하고(자정 넘긴 완료도 그 보드 소속), 보드 행이 지워졌다 재생성돼도 좌표 매칭이라 완료가 유지된다. - 규약 추가 시 원칙: 새 출처는 새 접두 하나로 추가하고(
<출처>:<좌표...>:<op>), 기존 접두의 의미는 불변 — 발급된 정체성은 재해석하지 않는다. - 소급 예외(#875, 20260831220000): 계보 발급 배포(08-29) 전에 저장된 세션은 출처가 없고 id가 source_ref에서 파생돼 소급 발급이 불가능하다 — 컷오버 전 보드 3장의 수행 세션만
group_board_session_links테이블(일회성 백필, definer RPC 전용)로 귀속시켰고, 완료 행 RPC는 접두 매칭 ∪ 링크로 판정한다. 새 링크 행을 쓰는 경로는 없다 — 앞으로의 판정 정본은 여전히 계보 접두다.
발급 — 트리거 단일 지점
brid_for_session_v1(user_id, source, source_ref) → brid_uuid_v1. 종목은 계보마다 레시피 함수가 달라 쓰기 경로가 계산하고 트리거는 게이트만 서지만, 세션은 레시피가 하나이고 입력이 전부 not null이라 행 트리거(enforce_session_identity_row)가 직접 파생한다: INSERT에 brid·id가 없으면 채우고, 온 값은 파생값과 일치할 때만 통과(불일치 22023). 그 결과 insert 경로 3곳(save_session_v5_engine·wodup 인입· motra 인입)은 함수 수정 없이 커버되고, 무BRID INSERT는 구조적으로 불가능하다. session.id(구 sessions)의 gen_random_uuid() 기본값은 제거됐다 — 정체성은 발명되지 않는다 (파생 또는 거부). id는 전 행 불변, brid 불변, brid 보유 행은 정체성 입력 (user_id·source·source_ref)도 불변이다.
BRID는 세션 행(session)에서 끝난다. 다섯 층(session, session_exercise, session_exercise_part, exercise_set, exercise_set_part) 가운데 세션 아래 네 층의 id는 서버가 만드는 무작위 uuid이며 BRID 문자열을 갖지 않는다(이슈 #1215 오너 결정 D5).
이행
- 발급 경로(20260829100000): brid 컬럼(부분 유니크·형식 체크
sessions_brid_format_check— 닫힌 계보 enum) + 발급 트리거. 픽스처가 쓰던 명시 무작위 id 14파일은 파생값 리터럴로 치환됐다(값이 틀리면 트리거가 22023으로 즉사 — 런타임 자가 검증). - 정체 컬럼 정리·uuid 전환(#1175, 2026-09-03):
origin·external_payload·is_external_placeholder·client_request_id퇴역. 판별은owner_user_id·source, 외부 인입만source_ref(공급자 원본 키,(owner_user_id, source, source_ref)유일). 라벨 공식은brid_label_exercise_v2(source, owner_user_id, id).exercises.id와 모든 참조 컬럼은uuid타입이고brid_uuid_v1은uuid를 돌려준다 — 정규식 CHECK 대신 타입이 모양을 보장한다. 앱 커스텀 종목의 id는 앱이 발급해 보낸다 (create_custom_exercisepayloadid, 같은 id 재전송은 기존 행). - 전면 재발급(20260829110000): brid null이던 기존 전 행을 자연키 파생으로 재발급하고,
sessions(id)를 무는 FK(적용 시점 pg_constraint 카탈로그에서 생성 — 수기 나열 금지)와 소프트 참조 4열(user_exercise_pr_events.session_id · user_pr_exercise_summary_snapshots의 best_load/best_estimated_1rm_session_id · workout_mutation_receipts.session_id)·content_takedowns(target_type='session')를 함께 옮겼다. jsonb 재작성은 없다 — raw_payload는 프로바이더 원본(불변)이고 세션 id를 품는 jsonb가 실측상 없다. 이후session.brid(구sessions)는 not null — BRID 없는 세션도, BRID에서 나오지 않은 세션 UUID도 존재할 수 없다. 상주 게이트:supabase/tests/database/session_brid_issuance.test.sql(발급·거부·불변식) ·session_brid_cutover.test.sql(봉인·전 행 파생 항등).
관련 문서
- 종목 정체성 하드 컷오버(ADR):
docs/data/exercise-identity-hard-cutover.md - 종목 데이터 모델 정책:
docs/policies/exercise-model/ - 외부 인입 재설계 설계서(오너 결정 이력 포함): 세션 외부 artifact — 결정 요지는 본 문서에 정본화됨. 명칭 결정에서 기각된 후보:
barbelic(source 토큰과 중의성),canonical(exercise-model 정책 용어와 충돌 — 정책상 유저 custom도 canonical 종목).