v0.18.0 G03 — 식별자·시각·단위가 전부 string/number 이고 저장 단계·owner 세대·조회 스냅샷에 이름이 없어 층마다 추측하던 것에서 공유 계약 층·명령별 정책표·계층 의존 허용표까지 (2026-09-07)
- 기간: 2026-09-07 ~ 2026-09-07 (세션 1개
08b11132, 오너 지시 "1282 진행해줘" → 분석·계획 게시 → "고") - 랜딩: PR #1311(Phase 1~6 한 PR, squash) — 마이그레이션·엣지 함수·앱 화면 변경 없음. 앱 번들에 실리는 것은
src/react/contracts/**의 validator 런타임 코드뿐이며 기존 코드가 import 하지 않으므로 동작 변화 0. 선행 G01e6150af0·G0580572704머지 확인 뒤 착수(mainb0e5076a) - 설계서: 없음 — 분석·Phase 계획은 이슈 #1282 댓글("예상 효과·개선사항" 절 포함)
- 정본:
docs/contracts/v0-18-0-domain-contracts.md(계약 §1~§21) ·docs/architecture/v0-18-0-layer-dependency-table.md(허용표·기준선·ADR-5 이행) · 코드src/react/contracts/{core,commands,resources,ports,persistence}/**(20파일,CONTRACTS_CORE_VERSION = 1) · 총괄 문서2026-09-07-v0-18-0-architecture-roadmap.md§12 G03 행 - 도구: 없음(새 스크립트 없음). 장부 규칙
scripts/architecture/coverage-inventory.json4줄 추가 +--render - 게이트: 새 행동 테스트
tests/react/contracts/{coreIds,coreTime,coreValues,commands,persistence}.test.mjs30건 + 컴파일 fixturetests/react/contracts/fixtures/{coreBrands,commands,ports}.fixture.ts(compileFixtures.test.mjs1건이 tsc 실행, 금지 조합 16줄 전부 오류) + 경계 래칫tests/react/contractsImportBoundaries.test.mjs4건. 전부npm test자동 편입. 로컬npm run check전체 통과(정적 게이트 14·테스트 2,694+). 마이그레이션·pgTAP·e2e 미접촉 - 버그리포트: 없음(수리 건 아님)
- 계약: 새 계약 문서 1(위). 기존 계약(쓰기 파이프라인·쓰기 원천·ADR·화면 RPC)은 한 문장도 바꾸지 않았다 — 값을 타입으로 옮겼을 뿐.
package.json·기존types/*.ts·tests/audit/manifest.json무변경
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 조사·기준선(코드 실측 F1~F13)·계획 게시·착수 댓글 | ✅ 이슈 댓글 |
| Phase 1 | 핵심 타입 — 브랜드 식별자 12종·달력 날짜/절대 시각·단위 8종 정밀도·입력 중 상태 union·Result/DecodeResult·모양 출처 브랜드 | ✅ f84aeebe |
| Phase 2 | 명령 종류 통일 + wire 대응표·PreparedMutation(불변/가변)·영수증·실패 분류 참조 구현·충돌 사본·저장 단계 3+1·owner 세대·조회 스냅샷·통계 세대·명령별 정책표(durable 5·online 18·auth 4·upload 2) | ✅ 2be72dbc |
| Phase 3 | feature 별 좁은 port 16 + runtime port 4, 파사드가 port 를 만족하는 컴파일 fixture | ✅ 9f0c517f |
| Phase 4 | 저장물 형식·버전 envelope·decoder 레지스트리·대기열 v1 참조 decoder·fixture 7행 | ✅ daabfb59 |
| Phase 5 | 계층 의존 허용표 문서·경계 래칫 테스트(기준선 3파일)·장부 규칙·--render | ✅ 347bf1a6 |
| Phase 6 | 계약 문서·이 기록·등록 2곳·총괄 G03 행 갱신안·후속 인계·PR | ✅ PR #1311 |
1. 배경
v0.18.0 은 기존 단일 앱을 62개 작업으로 모듈화한다. Phase 1-4 부터 S01(저장 codec)·D01(통계 DAG)·A01(transport 분리)·U01(표현 토큰)·G04(관측 기준선)가 같은 앱을 동시에 고친다. 이들이 "이 값은 서버 id 인가 앱 임시 id 인가", "이 사본으로 수정 요청을 만들어도 되나", "저장 완료가 기기에 적힌 것인가 서버 확정인가 통계 반영까지인가" 를 각자 코드 안에서 추측하면 타입만 늘어도 결합이 남는다(총괄 §2, ADR §3-2). G03 은 그 의미를 한 곳의 타입·계약 문서로 고정해 넘기는 작업이다.
2. 문제 제기
식별자·시각·단위가 전부 원시 타입이라 잘못된 조합이 컴파일을 통과했다
Uuid·IsoDateString·IsoDateTimeString 이 string 별칭(합계 341곳), 브랜드는 ExerciseId 하나뿐. 세션 id 자리에 종목 id 를, 달력 날짜 자리에 UTC 시각을 넣어도 통과.
저장 단계 3종이 여섯 필드에 흩어져 있었다
기기 보관(대기열 행 state) · 서버 확정(영수증) · 통계 반영(freshness.applied_version ≥ statsRequestedVersion)이 실제로 있는데, 이를 말하는 타입이 없어 writeSource·syncPending(7파일)·sync_pending|sync_blocked 문자열(10파일)·statsRequestedVersion(3파일) 로 각자 표현.
명령 종류 union 이 둘이고 요청 지문 구현이 둘이었다
대기열 PendingWorkoutMutationKind(5) 와 영수증 WorkoutMutationKind(5)가 서로 달랐고, 지문 생성은 durableMutationIdentity·directWorkoutWrite.workoutMutationRequestHash 두 곳.
owner 세대·조회 스냅샷·decoder 결과에 이름이 없었다
isCurrentRemoteUser(userId, revision) 의 두 값을 14개 파일이 손으로, readModelQueryCache·bootReadModelCache·owner 스토어 7개가 각자 모양, 저장물 형식 버전은 대기열 행에만.
UI 가 DB 행 타입을 import 하지 않는 것은 관행이었지 검사가 아니었다
types/ 한 폴더에 DB 행 모양(supabase.ts 1,030줄은 손으로 쓴 것·screenRpc.ts 1,582줄)·내부 DTO·화면 모델이 섞여 있고, 기존 게이트는 UI ↛ services/controllers 만 봤다.
3. 해결 방안
원칙 (오너 결정 없음 — 전제로 진행, 2026-09-07)
- 전제 1: 제품 정책 P1~P10 은 값 그대로, 이름만 붙인다. 정책표 값 = 쓰기 파이프라인 §4·§10.
- 전제 2: 새 경로
src/react/contracts/{core,commands,resources,ports,persistence}. 기존types/*.ts는 옮기지 않는다(소비자 수백 곳, A01·U01 과 충돌). codec 구현은 S01, 도메인 변환 구현은 A02~A06. - 전제 3: 검증은 행동 검사 + 컴파일 fixture(
tests/**라 boundary gate 의 suppression 금지 대상 밖).package.json무변경. - 전제 4: 새 라이브러리 없음(ADR-5). 새 게이트는 새 테스트 파일.
- 전제 5: ADR 파일은 손대지 않고 새 계약 문서 1개.
- 전제 6: CI 1회 —
src/react/**런타임 코드가 번들에 실리고 verify 잡이 실제로 검증.
접근
| 안 | 내용 | 채택 |
|---|---|---|
| A. 공유 계약 층 + 의존 방향 검사 | 브랜드 타입·union·port 를 contracts/ 에 두고, 허용표를 테스트가 지킨다. 기존 코드는 옮기지 않고 A 계열 이식 때 채택 | 채택 — 필드 하나씩 고치면 다음 담당이 또 다른 필드를 만든다(§22 근본 구조) |
B. 기존 types/*.ts 를 재배치·개명 | 폴더 이름으로 계층을 표현 | 불채택 — 16파일 소비자 수백 곳, 동시 진행 중인 A01·U01 과 같은 파일 충돌, 앱 동작 위험 |
C. 범용 Entity/Repository<T> 프레임워크 | 모든 feature 를 한 generic 으로 | 불채택 — 이슈 지시("generic AnyRecord 하나로 묶지 않는다"), feature 별 좁은 port 가 요구 |
| D. TanStack Query 도입 | 조회 캐시를 라이브러리로 | 불채택 — ADR-5. 근거는 허용표 §5(현행 캐시가 키 버전·stale·무효화·합류를 이미 갖춤, owner 전환·세대 대기를 다시 만들어야 함) |
4. 적용한 내용
Phase 1 — 핵심 타입 (f84aeebe)
contracts/core/{result,ids,time,units,input,shapes}.ts. Result/ValidationError/DecodeResult(원문 보존 3갈래) · Brand<T,Tag> 와 canonical id 8·mutation id 2·source identity·LocalRowKey · CalendarDate(실재 날짜만)·Instant(Z/오프셋 필수)·TimeZoneId·RecordOrderKey · 단위 8 브랜드와 정밀도(상한은 호출자) · InputCell(empty/typing/valid/invalid)과 storedValueOf · DbRow/ExternalUnknown/Dto/ViewModel + RowCodec/ViewCodec. 테스트 14건.
Phase 2 — 명령·영수증·충돌·단계·자원·세대 (2be72dbc)
contracts/commands/{capability,mutation,receipt,conflict,stage}.ts · contracts/resources/{owner,generation,snapshot}.ts. COMMAND_CAPABILITY(Record 완전성으로 새 명령은 표 필수)·WRITE_WAIT_POLICY · MutationKind 하나 + WIRE_MUTATION_KIND·MUTATION_RPC_NAME · DurableCommand/PreparedMutation{request, attempt}(claim·successorOf 자리, 비활성) · Receipt·ReceiptOutcome(unknown 포함) · WriteFailure·classifyWriteFailure·dispositionOf·Conflict/ConflictCopy · PersistenceStage·stageAfterReceipt·statsReflect · OwnerScope · ResourceKey/ResourceSnapshot/WritableCopy vs PendingOverlay · StatsGeneration/isGenerationPublished. 테스트 9건 + fixture 2파일(금지 조합 14줄).
Phase 3 — port (9f0c517f)
contracts/ports/{queries,writes,runtime}.ts. 조회 7·쓰기 9·인증 1·runtime 4(TransportPort·ResourceCachePort·OutboxPort·ClockPort). fixture ports.fixture.ts 가 파사드 barbelicApi 함수를 port 에 그대로 대입.
Phase 4 — 저장물 형식·decoder (daabfb59)
contracts/persistence/{envelope,decoder}.ts. PERSISTED_ORIGINS 5형식·readEnvelope·Decoder·createDecoderRegistry(중복·현재 버전 누락 거부)·decodePersisted·참조 pendingSaveV1Decoder. fixture 7행 + 테스트 7건.
Phase 5 — 허용표·게이트·장부 (347bf1a6)
docs/architecture/v0-18-0-layer-dependency-table.md · tests/react/contractsImportBoundaries.test.mjs(ui·features·앱 셸 ↛ DB 행 타입 — 기준선 3파일 래칫 / contracts/** ↛ 구현 / core 는 core 만 / 문서 앵커) · coverage-inventory.json 규칙 4줄 · 장부 §3 재생성.
Phase 6 — 문서·인계 (PR #1311)
계약 문서 §1~§21 · 이 기록 · 사이드바·README 등록 · 총괄 G03 행 갱신안(#1279 댓글) · G04/S01/D01/A01/U01 인계 댓글.
주요 결정과 그 근거
MutationKind는 대기열 이름을 정본으로(save_workout등): 기기에 이미 적힌 행이 이 이름을 갖는다. 영수증 쪽 이름은 wire 대응표로 — S01 decoder 가 이름 변환을 하지 않게.- 상한 값을 계약에 복제하지 않음: 한도 장부와
COMPLETED_WORKOUT_WRITE_LIMITS가 정본. 같은 숫자 두 곳 = 다음 개정에서 어긋남. - port 결과 타입은
unknown: 파사드가 아직any를 돌려주는데 여기서 모양을 지어내면 거짓 계약. 도메인 codec 이 좁힐 때 그 줄에서 바꾼다(문서 §18). - 기준선 래칫(DB 행 타입 import 3파일): 지금 고치면 A01·A15 파일과 충돌. 늘지 못하게만 막고 소유자를 적었다.
- 참조 구현 둘(
classifyWriteFailure·pendingSaveV1Decoder)을 계약 층에 둔 이유: 표를 타입만으로 두면 테스트할 수 없다. S05/S06·S01 이 채택할 때 한쪽만 남기는 조건을 문서에 적었다.
작업 중 드러난 것
Date.parse("2026-02-30T…Z")가 3월 2일로 통과 —parseInstant가 날짜 부분을 따로 검사.- 컴파일 fixture 의
@ts-expect-error가 정말 오류를 요구하는지 probe 파일로 확인(TS2578, exit 2). fixture tsconfig 는src/react/global.d.ts(window 확장)·allowJs(app-config.js) 가 있어야 파사드를 import 할 수 있다. withdrawUserConsent는 파사드에 없다 — port fixture 가 잡아 port 에서 뺐다(계약이 파사드와 어긋나지 않게 하는 앵커가 첫 실행에 작동).- 새 경계 테스트가
appController.tsx → types/screenRpc(직접 입력 기록 행 타입)를 잡았다 — controllers 2파일과 함께 기준선 3파일. - 장부 도구가 main 에서 이미 실패: G02(#1310)·#1277 의 파일 12개(
tests/db/**·tests/fixtures/stats-oracle/**·tests/react/{dateIndependence,faultInjectionTools,lineEndingPolicy,ownerSwitchFixture,testSupportDeterminism,browserClockFixture}.test.mjs·src/react/services/authSessionGuard.ts)와.gitattributes가 규칙 없이 들어왔다(장부 §4-5 ⑤ 미이행). 이 PR 은 내 경로만 규칙을 넣었다 — HQ 갱신안에 적음. - Bash 도구의 작업 디렉터리가
cd뒤에 세션 안에서 유지된다 — 절대 경로로 되돌려야 한다.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 브랜드 식별자 종류 | 1(ExerciseId) → 12(+LocalRowKey) |
| 식별자·날짜·단위·명령·사본 잘못된 조합의 컴파일 실패 | 0 → 부정 fixture 16줄 전부 실패 확인 |
| 저장 단계를 말하는 타입 | 없음(흩어진 필드 6종) → PersistenceStage 1(+unknown) |
| 명령 종류 union | 2개(불일치) → 1개 + wire 대응표 |
| 명령별 capability | 산문 → COMMAND_CAPABILITY 29개(durable 5·online 18·auth 4·upload 2) + 문서 대조 테스트 |
| export 된 feature port | 0(지역 선언 4) → 16 + runtime 4, 파사드 대입 fixture 통과 |
| 저장물 decoder 결과 모양 | 없음 → DecodeResult 3갈래 + fixture 7행 |
| UI/features ↛ DB 행 타입 검사 | 없음(관행 0건) → 래칫 테스트(기준선 3파일, 0 유지) |
| 계약 층 ↛ 구현 검사 | 없음 → 테스트(0건) |
| 행동 테스트 / 컴파일 fixture / 경계 래칫 | — → 30건 / 3파일(+실행 테스트 1) / 4건 = 35건 |
로컬 npm run check | 통과(정적 14·테스트 2,694+) |
| 앱 동작 변화 | 0 — 기존 코드가 새 계약을 import 하지 않음(채택은 S01·A 계열) |
| 미검증 | 계약의 실제 채택 효과(F3~F7 흩어진 필드 감소)는 S01·A12/A13·A08 랜딩 뒤에야 측정된다. 구형 탭 공존 안전성은 S01 몫 |
6. 이번 개선으로 향상된 것
병렬 작업자가 같은 이름으로 말한다
S01·D01·A01·U01·G04 가 "서버 id / 멱등 키 / 임시 키", "기기 보관 / 서버 확정 / 통계 반영", "쓰기 가능 사본 / 표시용 사본" 을 한 파일의 타입으로 가리킨다. 추측이 컴파일 오류로 바뀐다.
오프라인 정책을 실수로 넓힐 수 없다
DurableCommand<"create_plan"> 은 타입이 없다. 새 명령은 정책표에 한 줄을 넣어야 컴파일된다 — P1 이 문서가 아니라 Record 완전성으로 지켜진다.
옛 저장물을 지우지 않는 모양이 정해졌다
모르는 버전·깨진 행이 원문째 돌아오는 DecodeResult 와 fixture 7행이 S01 codec 의 합격 기준이다.
구조적으로 남는 것
계약 층 contracts/{core,commands,resources,ports,persistence} · 계층 의존 허용표 + 래칫 테스트 · 명령별 정책표 상수 + 문서 대조 테스트 · 계약 소유자 표(§20)·DTO 확장 절차(§18) · ADR-5 결정 기록 · 장부 규칙 4줄.
남은 것
- S01: 요청 지문 정의 통합(§3),
PreparedMutation채택, 버전별 codec 과 구형 탭 공존 실험 — 이 계약은 선언이지 안전 증거가 아니다. - A01/A08/A15: DB 행 타입 기준선 3파일을 0 으로(허용표 §4). A02~A06: port 결과 타입
unknown→Dto. A12/A13:InputCell채택. U02/U03:PersistenceStage로 상태 표시. - HQ: 총괄 §12 G03 행 갱신(제안 댓글), 장부 도구 실패(G02·#1277 파일 12개 미분류) 처리,
package.json별칭 여부(계약 테스트는 이미npm test에 든다 — 별칭 불필요). - 릴리스 v0.18.0 뒤
[v0.18.0 반영완료]+ 이슈 닫기(G01·G02·G05 와 같은 처리).