Skip to content

v0.18.0 도메인 계약 — 식별자·날짜·단위·입력 상태·명령·영수증·조회·통계 세대 (이슈 #1282, G03)

한 문장 — v0.18.0 을 나눠 맡는 여러 작업자가 같은 id·같은 단위·같은 "저장이 어디까지 됐는가" 를 추측 없이 공유하게 하는 타입 계약이다. 코드 정본은 src/react/contracts/{core,commands,resources,ports,persistence}/** 이고, 이 문서는 그 코드가 왜 그 모양인지와 누가 확장하는지를 적는다. 새 기능·새 라이브러리·새 오프라인 동작은 없다 — 제품 정책 P1~P10(ADR §2)은 값 그대로이고 이름만 붙였다.

  • 이슈: #1282 (계획 ID G03). 선행: G01 ADR §3-2(typed 경계)·§3-5(기존 store 완성), G05 장부 §7-1(도메인·공유 계층 목록).
  • 코드 계약 버전: CONTRACTS_CORE_VERSION = 1 (contracts/core/result.ts). 문서 버전 v1 (2026-09-07).
  • 검사: tests/react/contracts/*.test.mjs(행동 검사 31건(컴파일 fixture 실행 1건 포함)) · tests/react/contracts/fixtures/*.fixture.ts(컴파일 fixture — 금지 조합이 반드시 타입 오류) · tests/react/contractsImportBoundaries.test.mjs(계층 의존 허용표).
  • 유지되는 현행 계약: 완료 기록 쓰기 파이프라인(정책표·행 상태·실패 판정의 값), 쓰기 원천(사본 역할 꼬리표), 원본 불변, 화면 RPC 계약(영수증 v2·freshness). 이 문서는 그 값들을 타입으로 옮길 뿐 바꾸지 않는다.

1. 읽는 법 — 유저 A 의 메모 수정 한 건

유저 A 가 지하 헬스장에서 운동을 끝내고 완료 화면에서 메모를 고쳐 [메인으로]를 누른다. 이 한 건이 아래 계약을 순서대로 지난다.

순서일어나는 일계약코드
메모 칸의 값이 "입력 중" 인지 "확정" 인지 판정한다. 무게 칸에 "12." 만 쳐 둔 상태면 저장하지 않는다§6 입력 중 상태core/input.ts
수정 명령을 조립한다: 어느 세션(SessionId)을, 어느 개정번호(Revision)로, 어느 사본(WritableCopy)에서. 표시용 사본으로는 조립이 안 된다(컴파일 오류)§3 식별자 · §10 사본 역할 · §12 명령core/ids.ts · resources/snapshot.ts · commands/mutation.ts
명령의 종류가 update_completed_session 이므로 정책표가 durable 이라고 답한다 → 기기 대기열에 적는다(불변 요청 + 시도 메타 queued)§11 정책표 · §12 PreparedMutationcommands/capability.ts
화면은 즉시 바뀐다. 이때 그 기록의 저장 단계는 device_persisted 이고 통계는 "동기화 후 반영" 이다§15 저장 단계commands/stage.ts
지상에 올라오면 전송기가 save_session_v5 를 부른다. 실패하면 §14 표가 행을 어디로 보낼지 정한다(일시 → 대기, 인증 → 보류, 개정 충돌 → 재전송, 영구 → 격리)§14 실패 분류commands/conflict.ts
영수증이 오면 server_committed. 영수증의 statsRequestedVersion 을 홈·달력 읽기 모델의 applied_version 이 따라잡으면 stats_published — 그제야 숫자를 믿는다§13 영수증 · §9 세대 · §15commands/receipt.ts · resources/generation.ts
그 사이 A 가 로그아웃하고 B 가 로그인했다면 owner 세대가 달라져 A 의 답장은 B 의 화면에 적용되지 않는다§8 owner 세대resources/owner.ts

다음 릴리스에서 이 길이 실제 코드로 바뀌는 순서: S01(대기열 codec) → S02/S04(원자 적재·claim) → A12/A13(binding 이 §6·§12 채택) → A08(캐시가 §10 채택) → U02/U03(화면이 §15 로 상태 표시).

2. 결과 타입 (core/result.ts)

경계를 넘는 함수는 예외 대신 Result<T, E> 를 돌려준다({ok:true, value} | {ok:false, error}). 유효성 실패는 ValidationError{code, message, path} 이며 code 는 안정된 토큰이다(문구가 아니라 판정 이름 — 화면 문구는 UI 몫).

저장물을 읽는 함수는 DecodeResult<T> 세 갈래를 돌려준다. unknown_versionmalformed 는 원문(raw)을 같은 참조로 돌려주고, 받은 쪽은 그 행을 지우거나 덮어쓰지 않는다(ADR §5 재생성 없음). 이것이 S01 이 "옛 행 보존" 을 증명하는 기준 모양이다.

3. 식별자 (core/ids.ts)

지금 Uuid·IsoDateString·IsoDateTimeString 은 전부 string 별칭이라(341곳) 세션 id 자리에 종목 id 를 넣어도 컴파일이 통과한다. 브랜드 타입은 값 모양은 같되 "어느 종류인가" 를 타입에 싣는다. 값을 만드는 길은 parse* 하나뿐이고, as SessionId 캐스팅은 허용표 검사가 계약 층 밖에서 잡는다(A 계열 이식 때 추가).

종류타입누가 만드나모양
canonical idUserId·SessionId·SessionExerciseId·SessionExercisePartId·ExerciseSetId·ExerciseSetPartId·SynonymId·GroupId·(기존) ExerciseId서버만(다섯 층 세션 id 는 영수증 v2 로 받는다 — 세션 모델 §8)uuid v1~5, 소문자 정규화
mutation identityClientMutationId(멱등 키)·RequestHash(요청 지문)앱(durableMutationIdentity) — 재발급 없음(P3)uuid / 소문자 hex 64
source identitySourceIdentity{source: SourceName, sourceRef: SourceRef}앱(barbelic)·인입(wodup·motra)소문자 토큰 / 비어 있지 않은 문자열
local keyLocalRowKey앱 화면 사본local: 접두, uuid 모양 거부 — 서버로 id 로 보내지 않는다

요청 지문의 정의(어느 필드를 어떤 순서로 해시하는가) — S01(이슈 #1285, 2026-09-07)이 하나로 정했다. 정본 코드 persistence/requestHash.ts(canonicalRequestHashInput), 해시는 구현 층 src/react/persistence/codecs/requestHash.ts.

정의입력(키 정렬 JSON, undefined 제거)누가 썼나상태
통합 정의(= 옛 B){contractVersion: 1, kind, userId(소문자·trim), sourceRef(trim), payload} → SHA-256 hex 64새 명령 조립기(S03 이후 전부) · 종전 계획 저장·계획 삭제·그룹 보드(barbelicRepository, 전송 시점)현행
옛 정의 A{mutationKind, payload, sourceRef}v0.17 까지의 완료 기록 생성·수정·삭제 조립(completedWorkoutCommands)·생성 행에 수정 합치기(pendingWorkoutSaves)역사 — 이 정의로 만들어진 기존 행의 지문은 다시 계산하지 않는다(행에 적힌 값이 정본)

서버는 자기 지문(workout_mutation_request_hash_v1)으로 멱등 비교하고 앱 지문은 client_request_hash 로 되돌려 대조만 하므로, 정의를 바꿔도 서버 멱등성은 영향받지 않는다. 계획 행(save_plan·delete_planned_session)은 기기 행 본문에 지문이 없고 전송 시점에 만들어진다 — codec 은 이를 결함으로 보지 않는다(persistence/pendingSaveRow.ts).

4. 날짜·시각·순서 (core/time.ts)

타입규칙
CalendarDate업무 달력 날짜 YYYY-MM-DD기기(브라우저) 로컬 달력 날짜(barbelicShared.dateKey 와 같은 규칙). 화면 RPC 의 p_today·p_as_of·date 가 이 값실재하는 날짜만(2월 30일 거부). 시각이 붙은 값 거부
Instant절대 시각 ISO 8601 — Z 또는 ±hh:mm 필수(AGENTS.md §9). 서버 created_at·updated_at·committed_at, 앱 savedAt·sentAt오프셋 없는 2026-09-07T10:00:00 거부. Date.parse 가 2월 30일을 3월 2일로 넘기는 것을 따로 막는다
TimeZoneIdIANA 이름. calendarDateInZone(instant, zone) 으로 자정 경계 판정이 필요한 곳만모르는 이름 거부
RecordOrderKey{date, createdAt, id}기록 정렬·keyset 커서 — 피드·세션 검색 RPC 의 p_before_date·p_before_created_at·p_before_id 세 값날짜 → 만든 시각 → id

시간대 계약의 소유자 = D06·A06(장부 §7-1). 서버는 브라우저 로컬 오늘을 받아 current_date ± 1 3일치 스냅샷으로 자정 차이를 흡수한다(화면 RPC 계약 "Shared Dashboard Freshness"). 이 문서는 "달력 날짜와 절대 시각을 섞지 않는다" 만 고정한다.

5. 단위·정밀도 (core/units.ts)

Kg·Lb·Meters·Seconds·Reps·Kcal·Rpe·Percent 브랜드. 정하는 것은 둘뿐이다: ① 단위마다 다른 브랜드(kg 자리에 lb 를 넣으면 컴파일 오류) ② 정밀도 = DB 컬럼 자릿수 그대로(무게 numeric(8,2) 소수 2자리, RPE numeric(3,1) 소수 1자리·1.0~10.0, 횟수·초·칼로리 정수).

상한 값(999,999.99kg·반복 2,000·휴식 3,600 등)은 이 파일에 없다. 정본은 한도 장부COMPLETED_WORKOUT_WRITE_LIMITS(A02 소유)이며 parseKg(value, { max }) 처럼 호출자가 넘긴다 — 같은 숫자를 두 곳에 두지 않는다. 표시용 반올림(kg 1kg 단위)은 kgDisplay.ts(A02). 수치 정책 P2 의 소유자 = A02(운동)·D01(통계).

6. 입력 중 상태 (core/input.ts)

InputCell<T> = empty | typing(raw) | valid(raw, value: T) | invalid(raw, reason)
사용자가 한 것저장 시
아무것도 안 적음 / 지움 / 공백만emptynull
0 을 적음valid(0)0 은 비어 있음이 아니다0
"12."·"-"·"1e" 처럼 아직 숫자가 아님typing — 화면은 raw 그대로 보여 준다저장 금지(input_incomplete) — 대기열에 넣지 않고 그 자리에서 안내(쓰기 파이프라인 §6 "적재 전 검증")
숫자이지만 계약 위반(정밀도·상한·음수)invalid(reason)저장 금지, 이유 표시

storedValueOf(cell) 이 저장 직전 판정 한 곳이다. 입력 중 문자열/null/0 판정의 소유자 = A12(운동 binding)·A13(계획 binding) — 이 파일은 규칙만 준다. 데스크톱·모바일은 이 셀을 공유하고 표현만 다르다(총괄 §2).

7. 모양의 출처 (shape origin, core/shapes.ts)

같은 세션이 네 모양으로 앱을 지난다. 브랜드가 "어느 층에서 만들어졌는가" 를 컴파일 시간에 잇는다.

브랜드누가 만드나
DbRow<T>서버 행·RPC 응답 그대로(snake_case)repository 층만(markDbRow)
ExternalUnknown바깥에서 온 검증 전 값(IndexedDB·localStorage·native bridge·파일) + origin 한 단어저장소를 여는 쪽
Dto<T>앱 내부 표현 하나(camelCase)codec(RowCodec: DbRow → Result<Dto>)·decoder
ViewModel<T>화면이 그리는 모양view mapper(ViewCodec: Dto → ViewModel)

변환 지점은 셋이고 소유자는 허용표 §3에 있다. 검증 없는 as 로 unknown 을 DTO 로 바꾸지 않는다. 옛 코드가 어긋난 곳(as unknown as 17·as any 11·dual-key 기준선)은 A16 이 0 으로 내린다.

8. owner 범위와 세대 (resources/owner.ts)

OwnerScope{userId: UserId, epoch: OwnerEpoch}. epoch 는 "이 탭에서 owner 가 바뀐 횟수" 이며 같은 사용자가 다시 로그인해도 올라간다 — 현행 ownerScopeController.isCurrentRemoteUser(userId, expectedUserRevision) 의 두 값에 이름을 붙인 것이다(14개 파일이 손으로 들고 다니던 쌍). 규칙: 명령·조회·캐시는 전부 scope 를 키로 가지며, 결과를 적용하기 전에 isSameOwnerScope 로 비교한다. 기기 저장물(대기열 행)은 세대를 넘어 살아남으므로 isSameOwnerUser(userId 만)로 본다 — 행은 사용자별이다(쓰기 파이프라인 §8).

초안(S08, 이슈 #1326): 진행 중 운동 초안의 인증 계정·주인·복원 번호·마무리 펜스는 src/react/controllers/workoutDraftLifecycle.ts 의 typed 상태 하나가 들고, 주인은 인증 계정과 같을 때만 전이한다. 계정 전환은 이전 계정의 마지막 초안 보존 저장이 끝난 뒤 새 계정 상태를 열고, 같은 계정의 다음 복원은 그 저장 뒤에 읽는다 — 순서·서버 펜스 규칙의 정본은 초안 보호 정본 "Lifecycle and Ordering" 절.

9. 통계 세대 (resources/generation.ts)

어디서 오나
Receipt.statsRequestedVersion영수증 v2 stats_requested_version이 저장이 요구한 세대. 완료 기록 ≥ 1, 계획·보드 0
StatsGeneration{requested, applied, stale, dirtyFrom, appliedAt, lastError}읽기 모델의 freshness(홈·달력·PR·볼륨)stale === requested > applied — 서버가 같은 불변식을 검사한다
isGenerationPublished(generation, receipt.statsRequestedVersion)applied >= requested 이고 stale 이 아닐 때만 반영 완료. 요구 세대 0 은 판정 대상이 아니다(false)

세대 번호는 사용자별 단조 증가 정수다. 통계 계산 구조(단일 작성자·DAG·발행)의 소유자 = D01·D04·D08·D11(ADR §3-4). 이 문서는 "화면이 언제 숫자를 믿는가" 만 고정한다.

10. 조회 스냅샷과 사본 역할 (resources/snapshot.ts)

ResourceKey{owner, kind(읽기 모델 이름 = 화면 RPC 이름), id?, range?, asOf?, contractVersion}resourceKeyString 은 결정적이다. ResourceSnapshot<T>{key, status, data: Dto<T>|null, fetchedAt, generation, error, inflight} 의 상태는 다섯 가지뿐이다(inflight 는 상태와 별개로 "이 키의 요청이 진행 중인가" — A08 #1338, 로딩 표시를 스냅샷만으로 파생).

status화면
freshstaleMs 안의 서버 응답그대로 그린다
stale오래됐거나 통계 세대가 뒤처짐그리되 배경에서 다시 읽는다
invalidated쓰기 답장이 이 키를 무효화옛 값을 보여 줄 수는 있으나 fresh 로 오해하지 않는다
pending아직 한 번도 못 받음로딩
error마지막 읽기 실패(data 는 마지막 성공값)오류 + 마지막 값

구현(A08 #1338, 2026-09-08): src/react/resources/resourceStore.ts 가 이 모양의 실제 typed 구현이다 — 상태 판정 순서(pending → error → invalidated → stale → fresh)·채택 울타리 넷·취소 정책·스냅샷 참조 규칙의 정본은 Owner 범위 조회 캐시 계약. 종전 readModelQueryCache(문자열 키)는 그 위의 어댑터로 남고(제거 담당 A10), bootReadModelCache 의 시드는 seed(항상 stale)로 들어온다.

사본 역할 두 브랜드(쓰기 원천 계약 §2writeSource 꼬리표를 타입으로):

  • WritableCopy<T>get_session_detail 응답(detail) 또는 영수증 채택 사본(receipt)에서만. 수정 명령의 원천이 될 수 있다.
  • PendingOverlay<T> — 대기열 행으로 화면에 덧그린 표시용 사본(display). 쓰기 원천이 될 수 없다(ADR §3-5). UpdateBase<T>{base: WritableCopy<T>} 에 넣으면 컴파일 오류(fixture commands.fixture.ts ①).

11. 명령별 online/durable 정책표 (commands/capability.ts)

값의 정본 = ADR P1 + 쓰기 파이프라인 §4·§10. 바꾸는 것은 오너 결정(전역 지침 §26 ③). 표에 없는 명령은 기본값이 없다 — 새 명령은 표에 한 줄을 넣어야 컴파일된다(Record 완전성).

capability명령
durable (5)기기 대기열 먼저 → 화면 즉시 → 뒤에서 전송. 오프라인 동작save_workout·update_completed_session·delete_completed_session·save_plan·delete_planned_session
online (18)서버가 답해야 끝난다. 오프라인이면 그 자리에서 실패 표시create_plan(서버 발급 id 필요 — 추측으로 오프라인화 금지), 직접 입력 PR·기록 지표 4, 신체 지표, 즐겨찾기 2, 커스텀 종목 2, 팔로우 2, 온보딩·동의·아이디, 그룹 보드, 신고·차단
auth (4)로그인·로그아웃·계정 연결·계정 삭제 — 대기열 없음sign_in·sign_out·link_account·delete_account
upload (2)파일 업로드 — 대기열 없음upload_import_file(Wodup·Motra·InBody)·upload_profile_photo

동작별 "답장을 기다리는가" 표 WRITE_WAIT_POLICY(진입점 9개: wait 3 · immediate 6)는 쓰기 파이프라인 §4 표와 개수가 같아야 한다 — 테스트가 문서 표를 세어 대조한다(commands.test.mjs).

12. 명령·준비된 변경 (commands/mutation.ts)

prepare(순수 조립) → DurableCommand{kind, owner, target, identity, payload, affectedDates}
                  → PreparedMutation{request: DurableCommand(불변), attempt: AttemptMetadata(가변)}
                  → dispatch → Receipt
  • MutationKind하나다(대기열 5종, 기기 행이 이 이름을 갖는다). 서버 문은 WIRE_MUTATION_KIND(→ save_session/delete_sessionMUTATION_RPC_NAME(→ save_session_v5/delete_session_v5) 대응표로 간다. 영수증의 mutation_kind 는 wire 이름이다. 종전에 대기열 union 과 영수증 union 이 달랐던 것(F3)을 이 대응표가 닫는다.
  • MutationTarget = create{source} | existing{sessionId, source, expectedRevision: Revision}. Revision 은 양의 정수 브랜드.
  • 불변/가변 분리(S01 입력 → S01 확정, 이슈 #1285): request 는 서버 멱등 장부의 열쇠라 한 번 적히면 바뀌지 않는다. attempt{state, enqueuedAt, sentAt, heldAt, blockedAt, blockedCode, deferredAt, announced, claim, successorOf, evidence} 는 전송기가 바꾼다. claim{tabId, expiresAt, fence}·successorOf·evidence{attemptCount, lastAttemptAt, lastErrorCode} 는 v0.18.0 계약(ADR §3-1)이며 S01 공존 실험(CASE-039) 통과 뒤 S04 가 켰다(2026-09-08, 이슈 #1334 — 계약): 전송기가 claim 획득·ACK 적용 때 claim·evidence·writer 를 적고, 전송 중 행 위의 새 편집은 successorOf 로 이어진 후속 행이 된다. 기기 행에서는 이 셋이 추가 전용 필드 라 옛 번들(v0.17.x)이 쓴 행에는 없고 옛 번들은 값을 무시하므로 "없음" 을 오류로 읽지 않는다(persistence/pendingSaveRow.ts, codec §5 후퇴 규칙). PreparedMutation 은 codec 이 기기 행에서 만드는 메모리 표현이고, 기기 행의 배치(평면 v1)는 그대로다(§19).
  • 같은 clientMutationId 에 다른 지문 = isIdentityReuse → 적재 거부(fail-closed, 쓰기 파이프라인 §10).
  • OnlineCommand·AuthCommand·UploadCommand 는 owner(auth 는 없음)와 payload 만 갖는다. DurableCommand<"create_plan"> 은 존재하지 않는다(fixture ②).

13. 영수증 (commands/receipt.ts)

현행 영수증 v2·WorkoutMutationReceipt 와 필드가 같고 식별자·시각·세대에 브랜드를 붙였다. 두 지문을 구분한다: requestHash(서버 canonical, 멱등 기준) / clientRequestHash(앱 지문 되돌림, 대조용). receiptMatchesRequest 는 멱등 키와 앱 지문이 둘 다 맞아야 참이다.

답장 핸들의 결과 ReceiptOutcome 은 영수증만이 아니다: receipt | already_gone(삭제 대상이 이미 없음 — 통계 재검증 생략) | unknown(다른 탭이 먼저 올려 행이 사라짐 — 상세 강제 조회로 수렴, 성공으로 그리지 않는다) | held | deferred | blocked. 종결 판정은 isTerminalOutcome.

S05 구현(2026-09-08, 이슈 #1336 — 계약): 영수증 대조는 persistence/dispatch/receiptReconciler.ts 가 한다 — owner·mutationKind(WIRE_MUTATION_KIND)·보낸 요청의 멱등 키·앱 지문·sessionId·serverRevision·세대(완료 ≥ 1, 계획 0)가 맞는 영수증만 채택하고, 없거나 어긋나면 행을 격리 보존(LG_RECEIPT_MISSING·LG_RECEIPT_MISMATCH)한 채 unknown 을 낸다. 채택 장부가 같은 영수증의 화면 반영을 1회로 막는다. dispatcher 의 typed 결과(DispatchOutcome)와 이 union 의 대응은 persistence/dispatch/types.ts receiptOutcomeOf.

14. 쓰기 실패·충돌·사본 (commands/conflict.ts)

WriteFailure union = 쓰기 파이프라인 §6 표를 타입으로. classifyWriteFailure(signal) 은 그 표의 참조 구현이며 우선순위는 영구 거부 > 충돌 > 인증 > 일시("42501 은 5xx·timeout 과 함께 와도 영구 거부", §10).

kind코드행 처분(dispositionOf)
transient네트워크·408·429·5xx·57014·timeoutrequeue
auth_expired401/403/PGRST301hold
revision_mismatch{actualRevision?}40001/LG409resend_with_revision(#1173 D1 나중 도착 우선)
target_missingP0002/LG001 삭제일 때treat_done
idempotency_key_reused·source_ref_deleted·app_update_required·permanent{code}LG001 conflict_type / LG426 / 42501·22xxx·23xxx·42xxx·P****·PGRST1xx/2xx·LG_WORKOUT_*block

S05 가 채택했다(2026-09-08, 이슈 #1336): 정본 구현은 persistence/dispatch/failure.ts classifyDispatchFailure 하나이며 이 참조 구현(classifyWriteFailure)을 부른다. 종전 directWorkoutWrite.classifyDirectWorkoutWriteError·isolatePendingWorkoutSaveFailure·holdPendingWorkoutSaveFailure 는 그 결과를 옛 이름으로 옮기는 껍데기이고, pendingWorkoutMutationFlush.isRevisionConflictError·isTargetMissingError 는 지웠다. 코드·메시지 근거가 없는 실패는 일시가 아니라 격리(permanent{LG_WORKOUT_UNCLASSIFIED})다 — 계약 §3.

충돌 사본(ADR §3-3, 자동 field merge 없음): Conflict{failure, losingMutation, copy, recoveredBy} · ConflictCopy = snapshots{local, base?, remote?} | history_ref{ref} | none{reason, scope} — 사본을 얻을 수 없으면 isFullyRecoverable 이 false 이고 화면은 완전 복구로 표시하지 않는다(S09). 복구는 새 durable mutation 이며 삭제 영수증·tombstone 을 지우지 않는다.

S06 구현(2026-09-08, 이슈 #1341 — 계약): revision_mismatch 의 처분 resend_with_revision후속 행으로 먼저 영속화한 뒤 보낸다. 재조립한 요청(새 멱등 키·새 지문)은 persistence/conflict/** 가 원 행 제거 + 후속 행 쓰기 + 근거를 원자 교체 하나로 적고, 전송 루프가 그 행을 보낸다 — 재시작·답장 유실 뒤 같은 id·지문이 다시 나가 서버가 재생 영수증을 돌려준다. 근거(행 필드 conflict, §19)는 원 요청 원문(local 사본)·서버 근거(actual_revision 숫자만이면 remote 사본 없음 — 발명 금지 / 상세를 읽었으면 스냅샷)·user_fact_history 참조(server_only)를 타입으로 보존하고, 확정 뒤에는 요약 장부에 남는다. 위 ConflictCopynone{reason, scope} 는 그 가용성 표기와 대응한다(S09 가 RecoveryDescriptor 로 좁힌다). 후속 행을 못 적으면 원 행이 남고 미저장 요청은 보내지 않는다(LG_SUCCESSOR_PERSIST_FAILED·LG_CONFLICT_STORM, 기기 저장 일시 실패).

15. 저장 단계 (commands/stage.ts)

unknown(0) < device_persisted(1) < server_committed(2) < stats_published(3)
단계증거화면
device_persisted{row: queued/sending/held/blocked, enqueuedAt}대기열 행즉시 반영, 통계는 "동기화 후 반영", 격리면 배지
server_committed{receipt}영수증확정. 통계는 아직 옛 세대일 수 있음
stats_published{receipt, generation}applied >= statsRequestedVersion홈·달력 숫자가 이 기록을 반영
unknown{reason: other_tab/handle_lost/not_tracked}없음성공으로 그리지 않는다. 상세 강제 조회로 수렴

stageAfterReceipt(receipt, generation): 계획 저장(요구 세대 0)은 server_committed 에서 끝난다. statsReflect(stage) 가 "숫자를 믿어도 되는가" 한 함수다. 종전에 이 단계가 여섯 필드(state·writeSource·syncPending·sync_pending|sync_blocked 문자열·statsRequestedVersion·applied_version)에 흩어져 있던 것(F5)을 이 union 하나로 말한다. 보완 추정으로 unknown 을 올리지 않는다.

16. feature 별 좁은 port (ports/queries.ts·ports/writes.ts)

feature 는 BarbelicApi 150여 export 전체가 아니라 자기 port 하나를 주입받는다. port 의 입력은 이 문서의 camelCase DTO 뿐이고 Supabase client·DB 행·화면 RPC 이름을 요구하지 않는다. 입력 필드 이름은 현행 저장소 로더가 실제로 읽는 이름(date·from·to·sessionId·exerciseIds·cursor{beforeDate, beforeCreatedAt, beforeId}·limit)이다 — 계약이 이름을 새로 짓지 않았다.

port영역(장부 §3)확장 담당
CalendarQueryPort(월·일·범위·세션 상세·계획 상세·기록표 2)calendar·workout·planA06(읽기 codec)·A03
HomeQueryPort·PrQueryPort(+즐겨찾기 2)·VolumeQueryPorthome·pr·volumeA06·D01
CatalogQueryPort·CatalogWritePortcatalogA03(카탈로그·프로필 담당, #1330) — 검색 신호 결과만 A06
FeedQueryPort(피드·검색·친구 6)·SocialWritePortsocialA10
GroupQueryPort·GroupWritePortgroupA11
WorkoutWritePort(durable 3)·PlanWritePort·ManualRecordPortworkout·plan·prA02·A12·A13
ProfilePort·OnboardingPortprofile·onboardingA03(#1330) — 아이디·성별 3개 결과만 A04, 화면 소비자 camelCase 전환은 A09
ImportPortimportA05(#1406) 결과 고정(ports/importDto.ts) — 파서·identity 는 I01, durable 잡 소비자는 D09, resource 구독은 A11
AuthPortauthA07/B01
AdminPort(신고·운영 점검표·유저 검색)·UserDataExportPort (ports/adminPorts.ts)admin·profileA05(#1406) — 관리자 port 는 일반 파사드에 없다. 앱 쪽 구현은 domains/repositories/adminRepository.ts(조립 adminComposition.ts, 앱 진입점 그래프 밖)이고 관리자 브라우저 화면은 v0.17.7 부터 Barbelic-docs/admin 이 소유한다. 본인 내보내기는 일반 파사드

결과 타입은 지금 파사드가 돌려주는 모양을 아직 브랜드화하지 않았으므로 unknown 이다 — 도메인 codec 이 Dto<…> 로 좁힐 때 그 port 파일의 그 줄에서 바꾼다(§18). 컴파일 fixture ports.fixture.ts 가 현행 파사드 함수를 port 16개에 대입해 서명이 맞음을 증명한다(파사드 함수가 없어지면 실패).

정정(2026-09-07, A03 #1330): 위 표의 카탈로그·프로필 담당은 총괄 카드·A01 추출 지도와 어긋나 있어 A03 으로 바로잡았다(계약 의미 불변). A03 이 첫 번째로 결과를 좁혔다 — CatalogQueryPort.syncExerciseCatalogData·listOwnCustomExercises, CatalogWritePort 2개는 ports/catalogDto.ts(CatalogExerciseRecord·ExerciseCatalogSyncResult), ProfilePort 의 workspace·신체 지표·이름·사진 4개와 OnboardingPort 2개는 ports/profileDto.ts(ProfileRecord·SignedProfileRecord·ProfileWorkspaceRecord·OnboardingStateDto) 로. 온보딩 상태는 codec 이 camelCase 로 바꾼 진짜 Dto<…>(§7 브랜드)이고, 프로필 행·workspace 는 아직 서버 컬럼 이름 그대로 화면에 가므로 브랜드 없는 열린 레코드로 적었다 — camelCase 전환은 A09/A12/A13 이 그 파일에서 좁힌다.

정정(2026-09-09, A05 #1406): ImportPort 결과 5개를 ports/importDto.ts 로 좁혔다. Wodup 업로드·조회는 WodupImportJobDto(서버 status 그대로 + 단계 phase pending/running/succeeded/failed + terminal 일 때만 outcome), 시작은 WodupImportStartResult(accepted / already_running / already_terminal), Motra 는 MotraImportResultDto(동기, kind: "completed" 하나), InBody 는 A03 의 ProfileWorkspaceRecord — 비동기 접수와 동기 완료를 같은 boolean 으로 합치지 않는다. 관리자는 §16 표 밖의 별도 port(ports/adminPorts.ts)이며 ports/adminDto.ts 의 신고 행은 화면이 서버 컬럼 이름으로 읽으므로 필수 열만 고정한 열린 레코드다(camelCase 전환은 U03).

17. 공용 runtime port (ports/runtime.ts)

port구현
TransportPort.callRpc(name, args, options)RPC 이름(literal)·인자 map 만 안다. 응답 unknown — 어댑터가 검증해 Dto 로A01(barbelicRepository.callScreenRpc/callMutationRpc 를 이 모양으로)
ResourceCachePort<T> get/fetch/subscribe/seed/invalidate/invalidateOwnerowner 별 불변 스냅샷 + 구독 + 시드 + 무효화. fetchsignal 은 소비자의 대기 취소, loader 가 받는 signal 은 요청 자체의 취소(마지막 소비자 이탈·owner 떠남)A08 완료(resources/resourceStore.ts, 계약; 이슈 #1338)
OutboxPort enqueue/list/updateAttempt/remove대기열 행. enqueue 오류 = identity_conflict / target_deleted / storage(적재 시점에 사용자에게 보이는 유일한 오류)S02(원자 적재 — 구현 PendingWorkoutSaveStore.replace + persistence/outbox/**, 계약)·S04(claim·fence·후속 행 — 구현 persistence/outbox/claimPlan.ts + 전송기 pendingWorkoutSaves.ts, 계약)·S01(codec)
DispatchPorts·FeatureInvalidationPort대기열 전송(dispatcher)이 받는 단일 sender(WorkoutWritePort + 계획 포트 + S03 재전송 조립)·영수증 조회 포트, 화면이 받는 typed 결과 port 하나(applyDispatchResult)S05 — 구현됨(2026-09-08, 이슈 #1336): persistence/dispatch/** + features/completed-workout/pendingSavesFeatureBinding.ts·pendingSavesDispatchPorts.ts, 계약
ClockPort nowMs/now/today"지금" 과 "오늘". 테스트가 고정 시계를 주입앱 셸(G02 tests/support/clock.mjs 와 짝)

A01 구현: rpcTransport.ts와 도메인 추출 정본. core 버전·기존 RPC 의미는 유지한다. servicePorts.ts가 실제 feature port 17개(기존 fixture의 실제 수)를 연결한다.

18. 공통 DTO 의 소유자와 확장 절차 (D01·S01·U01 이 같은 파일을 동시에 고치지 않게)

파일계약·통합 책임누가 무엇을 더할 수 있나
contracts/core/**G03(HQ 타입 경계) — 계약·소비자 영향과 겹치는 변경을 조율새 브랜드·새 단위·새 결과 갈래는 계약 영향·소유권을 확인한 담당자가 PR로 구현·검증하고 자동 통합 큐에 요청(운영 정본 §3·§4). HQ 상주·별도 머지 승인 불필요
contracts/commands/**·contracts/resources/**G03 → 채택 뒤 S 계열(commands)·A08(resources)S 계열은 AttemptMetadata·WriteFailure 에 필드 추가(옵션·nullable 만), D01 은 StatsGeneration 에 필드 추가. 기존 필드 이름·뜻 변경은 소비자 영향을 확인하고 HQ와 계약 조율
contracts/ports/queries.ts·writes.ts영역별 담당(§16 표)자기 영역 port 의 결과 타입을 unknownDto<…> 로 좁히기, 자기 port 에 멤버 추가. 다른 영역 port 는 만지지 않는다 — 한 PR 이 두 영역의 port 를 고치면 리베이스 대상
contracts/ports/runtime.tsA01(transport)·A08(cache)·S02/S04(outbox)각자 자기 port 만
contracts/persistence/**S01형식 추가·버전 올림은 PERSISTED_ORIGINS 한 줄 + decoder 등록. 참조 decoder pendingSaveV1Decoder 는 S01 codec 이 생기면 그쪽이 정본
기존 types/*.ts(16)G03(운영 정본 §3)옮기지 않는다(소비자 수백 곳). 새 필드는 옵션으로만. 행 모양(supabase.ts·screenRpc.ts)은 repository·codec 층만 import(허용표)

절차: ① 장부 §4-5 통지 댓글(자기 이슈 + 소유 역할 이슈) ② 자기 영역 파일만 고친 PR ③ tests/react/contracts/** 에 행동 검사(구현 복사 금지) ④ 계약 문서 해당 절 한 줄 갱신(같은 PR). 계약 코드 버전 CONTRACTS_CORE_VERSIONcore 의 기존 의미가 바뀔 때만 올린다(필드 추가는 안 올린다).

운영 개정(2026-09-07, #1313): 위 소유권은 겹치는 코드·계약 변경을 조율하기 위한 경계다. 이미 배정된 이슈 범위의 변경을 구현할 때 별도 파일 허가나 HQ의 랜딩 라벨을 기다리지 않는다. 검증을 마친 담당자가 npm run landing:request -- --pr <N>으로 요청한다. 계약의 뜻·검증 기준·소비자 책임은 바꾸지 않는다.

19. 기기 저장물의 형식·버전과 decoder (persistence/envelope.ts·decoder.ts)

형식저장소현재 버전버전 필드
barbelic.pendingSaveIDB barbelic-workout-local-cache/pendingSaves1version
barbelic.workoutDraftIDB …/drafts4version
barbelic.workoutDraftArchive같은 스토어, archive:user: 접두 키1version
barbelic.bootReadModellocalStorage barbelic:boot-read-model:v1:owner:1version
barbelic.appShellSnapshotIDB barbelic-read-snapshots/appShellSnapshots3schemaVersion
(대기열 미러 v2, S07)localStorage barbelic:pending-workout-saves:mirror:v2:{row,meta,tomb}:<id> · …:v2:gen · …:v2:preserved + 옛 배열 barbelic:pending-workout-saves:mirror행 형식은 v1 그대로(본문/메타를 나눠 적음)manifest generation

옛 행에는 형식 이름이 없다 — 읽은 저장소 위치가 곧 형식이다. 새 행(S01 이후)은 format 을 함께 적는다. Decoder<T> 는 (형식, 버전) 하나만 알고, 레지스트리는 같은 (형식, 버전) 중복과 현재 버전 decoder 누락을 등록 시점에 거부한다(앱이 자기가 쓴 행을 못 읽는 상태 방지). decodePersisted 는 모르는 버전 → unknown_version, 깨진 행 → malformed, 둘 다 원문 참조 보존. 옛 버전 행은 decoder 가 현재 내부 표현으로 올려 읽되 저장물을 새 버전으로 다시 쓰지 않는다(재생성 없음). 초안·보관 행의 savedAt·expiresAt·보관 시각과 서버 백업(checkpoint) 의 만료 판정은 store 가 주입받은 ClockPort 하나로 계산한다(S08, 이슈 #1326) — 테스트는 고정 시계로 7일·3일 경계를 검증한다.

대기열 행(S01, 이슈 #1285): 형식 계약은 persistence/pendingSaveRow.ts(PendingSaveRowV1 — 평면 v1, kind 5종 요청 본문 union, 전송 메타, 추가 전용 메타 format·claim·successorOf·evidence·writer), codec 은 src/react/persistence/codecs/pendingSave/**. 옛 행(#1173 이전)의 없는 kindsave_workout, 없는 stateblockedAt 있으면 blocked 아니면 queued(쓰기 파이프라인 §3) — 규칙의 유일한 자리는 inferPendingSaveKind/State. 보존 대조 입력은 손으로 적은 tests/react/contracts/fixtures/persisted-rows.json(현행 v1·옛 행 2·미래 v2·깨진 행 3)과 실제 번들 writer 가 남긴 tests/fixtures/pending-rows/{v0.16.0,v0.17.0}.json(각 11행) 둘이다. 저장 형식은 v1 하나를 유지하고 새 필드는 추가만 한다 — 옛 번들이 새 행을 그대로 읽고 보내고 지울 수 있어야(rollback) 하기 때문이다. 규칙·공존 실험·활성화 조건의 정본은 pending-save-codec.md. S07(이슈 #1337): IDB 버전은 6 에 고정(올리면 옛 탭이 VersionError 로 대기열을 잃는다), 인덱스는 메모리 카탈로그, 미러는 행별 키 + tombstone + 세대, 복구는 "DB 가 새로 만들어졌을 때" 만 — 추가 전용 필드 recovery — 정본 outbox-storage-index-mirror.md. S06(이슈 #1341): 충돌 재전송의 후속 행에만 있는 추가 전용 필드 conflict(원 요청 정체성·원문, lineage, 서버 근거, 사본 가용성, 이력 참조) — 정본 outbox-conflict-successor.md §3. 요약 장부는 localStorage barbelic:conflict-ledger:v1:<owner>(형식 v1, 항목 100건/owner).

20. 계약 소유자 표 (이슈 완료 조건 ④)

계약소유자이 문서가 고정한 것
시간대·날짜D06(달력·홈·통계 RPC)·A06(읽기 codec)§4 — 달력 날짜 ≠ 절대 시각, 브라우저 로컬 오늘
수치 정밀도·단위A02(운동 kgDisplay·setPurposePolicy·한도)·D01(통계 정책)§5 — 브랜드와 DB 자릿수, 상한은 호출자
입력 중 문자열·null·0A12(운동 binding)·A13(계획 binding)§6 — 네 상태 union 과 저장 직전 판정
외부 provider(카카오·구글·애플)A07(인증 lifecycle)·B01(서버 API)§16 AuthPort 는 provider 이름만 안다(SocialAuthProvider)
native bridgeN01(계약문 native-bridge.md, 장부 §4-4)§7 ExternalUnknown{origin:"bridge:…"} 로만 들어온다
인입 출처 행(source <> 'barbelic')I01·#1236 D5§3 SourceName 이 앱/인입을 가른다

21. 결정 기록과 인계

  • ADR-5 이행 — 새 조회·상태 라이브러리를 도입하지 않는다. 근거는 허용표 §5. 한 번의 결정이며 두 구현을 같이 두지 않는다.
  • G03 → S01: ① 요청 지문 정의 통합(§3) ② PreparedMutation 불변/가변 분리와 AttemptMetadata(§12) ③ DecodeResult·PERSISTED_ORIGINS·fixture(§19). 구형 탭 공존 실험은 S01 몫 — 이 계약은 선언이지 안전 증거가 아니다(ADR §3-1).
  • G03 → S03(완료, 2026-09-07, 이슈 #1333): §12 의 "prepare(순수 조립) → DurableCommand → PreparedMutation" 을 실제 조립기가 구현했다 — save-preparation.md. durable 5종 전부 PreparedMutation 을 만들고, 수정 원천은 WritableCopyRole 을 타입·validator 로 요구하며, 준비 오류(SavePreparationError)는 §17 OutboxEnqueueError 와 다른 union 이다.
  • G03 → A13(완료, 2026-09-08, 이슈 #1340): §6 의 입력 중 상태(InputCell)를 계획 편집 모델이 채택했다 — plan-editor-model.md. 확정값(숫자·null)과 입력 중 원문(inputs)을 분리해 저장은 확정값만 싣고, 입력 중이면 저장이 막힌다. §11 의 create_plan(online) / save_plan·delete_planned_session(durable) 은 저장 intent union(PlanSaveIntent)이 타입으로 구분한다. §5 정정: 초(duration_seconds numeric(12,3))·거리(numeric(12,3))·칼로리(numeric(8,2))는 DB 자릿수대로 소수를 허용한다 — "횟수·초·칼로리는 정수" 는 횟수에만 맞다.
  • G03 → A12(완료, 2026-09-08, 이슈 #1339): §6 InputCell 의 첫 소비자 — 운동 편집기의 숫자 칸 전부가 numericCell(IME 조합 중은 원문만 보관)로 판정되고, §3 LocalRowKey 가 행 키, 저장 intent 는 S03 조립기로 간다 — workout-editor.md. 공통 순수 정책은 contracts/workout/**(허용표 §2 갱신).
  • G03 → D01: StatsGeneration·isGenerationPublished(§9)·Receipt.statsRequestedVersion(§13). 계산 구조는 D01.
  • G03 → A01: TransportPort(§17)·DbRow → Dto 변환 지점(§7)·DB 행 타입 기준선 3파일(허용표 §4).
  • G03 → A08(완료, 2026-09-08, 이슈 #1338): ResourceCachePort(§17)·ResourceSnapshot(§10)의 실제 구현 resources/resourceStore.ts. §17 port 에 subscribe·seed, §10 스냅샷에 inflight, resources/owner.ts 에 store 가 소비하는 좁은 OwnerScopePort 를 더했다(필드 추가 — core 버전 불변). 첫 소비자 = 완료 세션 상세(resources/sessionDetailResource.ts). A09 인계는 계약 문서 §11.
  • G03 → U01/U02/U03: ViewModel 브랜드(§7)·PersistenceStage(§15)·InputCell(§6) 을 표현 계약이 읽는다. designContract.ts 는 마커 레지스트리이며 이 계약이 손대지 않는다.
  • G03 → G04: ResourceSnapshot·ClockPort 가 관측 기준선의 키(owner·kind·contractVersion)와 시계 주입점이다.
  • 게이트: npm testtests/react/contracts/**(행동 31건 + 컴파일 fixture 3파일(금지 조합 16줄))·contractsImportBoundaries(4건)를 돈다. package.json 은 만지지 않았다(HQ 통합 슬롯).