Skip to content

Workout·Plan·Catalog 의 첫 수직 통합 — feature resource·command·무효화 표 계약 (v0.18.0 A09, 이슈 #1342)

  • 상위: ADR §3 · 도메인 계약 §10–§15 · Owner 범위 조회 캐시(A08) · 쓰기 파이프라인(#1199) · dispatcher·reconciler(S05) · 충돌 후속 행(S06) · 영수증 세대(D02) · Workout editor(A12) · Plan editor(A13)
  • 코드: src/react/resources/{plannedSessionDetailResource,exerciseCatalogResource,requestSignal}.ts · src/react/features/workout/queries/{sessionDetailView,receiptInvalidation}.ts · src/react/features/workout/editor/adapters/controllerSave.ts · src/react/features/plan/{commands/planWriteCommand,queries/planDetailSelectors}.ts · src/react/features/catalog/queries/catalogSelectors.ts · 연결 지점 controllers/{remoteDataController,sessionDetailStore,workoutWriteController}.ts · features/completed-workout/pendingSavesFeatureBinding.ts
  • 검사: tests/react/a09VerticalIntegration(착수 검사 5) · plannedSessionDetailResource·exerciseCatalogResource(resource 12) · a09EditorSaveParity(저장 조립 동치 5) · a09ReceiptInvalidation(무효화 표 7) · planRoundTripHarness(컨트롤러 → 조립 → 대기열 → dispatcher → RPC 사슬 12) · 브라우저 CASE-016/024/029/033/040/041/042/044/045/047(기존)
  • 서버 변경: 없음. 마이그레이션 없음.

1. 유저 이야기 — 이 계약이 지키는 것

유저 A 가 폰으로 계획을 고쳐 저장하고, 달력에서 지난 기록을 열어 보고, 운동을 끝내 기록을 올린다. 이 계약 뒤에는 세 가지가 한 갈래로 간다.

  1. 읽기는 owner 범위 캐시 한 벌. 완료 상세·계획 상세·종목 카탈로그가 같은 store(A08)를 지난다 — 늦게 온 옛 응답·다른 계정의 응답은 캐시가 버리고, 화면을 떠나면 요청이 취소되며, 상세를 열어 본 뒤의 수정·삭제는 같은 상세를 다시 읽지 않는다.
  2. 쓰기는 준비 → 기기 대기열 → dispatcher 한 갈래. 계획 수정·삭제도 완료 기록과 같이 온라인이든 아니든 기기에 먼저 적히고 뒤에서 전송된다. 어느 갈래로 갈지는 편집기가 만든 저장 뜻의 capability 값(A13)이 정하고, 컨트롤러·화면은 다시 판정하지 않는다. 새 계획 생성만 온라인 명령이다(서버 id 발급).
  3. 저장 규칙은 편집기 한 벌. 완료 기록의 실패 세트 canonical 화·기록 프로필·체중 계수·완료 판정·저장용 uuid 는 편집기(A12 exercisesForSave)가 정하고 컨트롤러는 그것을 부른다. 답장(영수증)이 오면 "무엇을 다시 읽을지" 는 순수 표가 고른다.

2. 폴더 구조 (feature 별 command·query)

폴더역할이번 트랙의 파일
resources/**owner 범위 조회 캐시 primitive + 읽기 모델별 resource(키 함수·store 공장·loader)plannedSessionDetailResource.ts·exerciseCatalogResource.ts·requestSignal.ts(공통 신호 도구) — 완료 상세는 A08 sessionDetailResource.ts
features/<영역>/queries/**resource 위의 feature-local 선택자(순수). 화면·컨트롤러가 읽기 모델을 해석하는 규칙workout: sessionDetailView.ts(친구 세션 읽기 전용 판정)·receiptInvalidation.ts(무효화 표) · plan: planDetailSelectors.ts(카드+상세 합치기) · catalog: catalogSelectors.ts(동기화 필요 판정)
features/<영역>/commands/**S03 순수 조립기 + feature command(저장 뜻 → 조립 → 대기열 port)plan: planWriteCommand.ts(enqueuePlanEdit·enqueuePlanDelete) — workout 은 S03 prepareCompletedSession.ts 그대로
features/<영역>/editor/**headless editor(A12/A13)와 그 adapterworkout: adapters/controllerSave.ts(prepareEditorExercises — 컨트롤러가 편집기 규칙을 부르는 자리)
controllers/**resource 를 셸당 하나 만들고 fetchAdopted 로 읽는 자리, 저장 진입점remoteDataController(store 3개)·sessionDetailStore(상세 열기)·workoutWriteController(저장 진입점)

규칙: resource 는 React·controller 를 모른다(A08). queries 는 resource 의 데이터/스냅샷만 받는 순수 함수다. commands 는 저장소·전송을 모르고 enqueue port 를 받는다. 화면(ui/**)은 resource·controller 를 import 하지 않는다(props 로 받는다).

3. 읽기 — resource 3종과 사용 예

resource낡음loader 가 지키는 것소비자
완료 상세(A08)(owner, get_session_detail, id, v1)무효화 전까지 fresh취소 신호·10초 제한, 빈 응답 채택 안 함상세 열기(sessionDetailStore.openCompletedWorkoutDetailloadSessionDetail)·쓰기 원천 확인(ensureSessionDetail) — 같은 캐시
계획 상세(owner, get_planned_session_detail, id, v1)무효화 전까지 fresh취소 신호·10초 제한, 못 찾음·다른 id 는 채택 안 함(planned_session_detail_missing·…_identity_mismatch)loadPlannedSessionDetail(카드의 updatedAt 과 캐시가 다르면 다시 읽음 — isPlannedSessionDetailCurrentprimePlannedSessionDetail(저장 직후 setgetPlannedSessionDetail(fresh 만) → planViewModelsOf
종목 카탈로그(owner, sync_exercise_catalog, "catalog", v1)요구 순번 > 가진 순번기기 사본은 seed(언제나 stale, 서버 응답을 덮지 않음). 홈 힌트에 못 미치면 한 번 더(2회째 force)ensureExerciseCatalog·refreshExerciseCatalog(syncExerciseCatalog) → 전역 종목 목록·gymData 투영(어댑터, §7)
ts
// 컨트롤러 — 계획 상세 읽기(채택된 응답만)
const key = plannedSessionDetailResourceKey(user.id, planId);
const force = options.force || !isPlannedSessionDetailCurrent(store.peek(key), card.updatedAt);
const plan = await fetchAdopted(store, key, (signal) => loadPlannedSessionDetailResource({ api: BarbelicApi, user, profile, plannedSessionId: planId, signal }), { force });

// feature 선택자 — 카드 목록에 채택된 상세를 합친다(갱신 시각이 다른 상세·무효화된 상세는 카드만)
const plans = planViewModelsOf(cards, (card) => getPlannedSessionDetail(card.id));

앱 시작에 전체 raw history 를 가져오지 않는다 — 부팅은 get_home_dashboard(당월 요약) 하나이고, 상세는 실제 선택 시 읽는다.

4. 쓰기 — 저장 뜻 → 조립 → 대기열 → dispatcher

명령capability(G03 §11)진입점조립적재전송·답장
완료 기록 생성·수정·삭제durablesaveUiWorkoutSession·updateUiSession·deleteUiSession편집기 규칙(prepareEditorExercises) → S03 prepareCompletedSession*queueWorkoutMutation / queueCompletedWorkoutCreatedispatcher(S05) → save_session_v5/delete_session_v5 → 영수증 → §5 표
계획 수정·삭제durablesaveUiWorkoutPlan·updateUiSession(계획)·deleteUiSession(계획)enqueuePlanEdit·enqueuePlanDelete(S03 preparePlanEdit/Delete)같은 queueWorkoutMutation같은 dispatcher(계획 포트 workoutPlanCommands)
새 계획 생성onlinesaveUiWorkoutPlan(create)없음workoutPlanCommands.save 직접. 오프라인이면 그 자리에서 실패 표시
ts
// 컨트롤러 — 계획 수정. 갈래는 intent.capability 가 정한다(A13 union). 온라인 RPC 를 먼저 부르지 않는다.
if (intent.capability === "durable") {
  const outcome = await enqueuePlanEdit({ intent, userId, plan, affectedDates, enqueue: (input) => queueWorkoutMutation(userId, epoch, input) });
  if (!outcome.ok) throw new SavePreparationFailure(outcome.error);      // 적재 실패만이 이 시점의 오류(§2 ②)
  primePlannedSessionDetail(localPlan); setPlans(…);                     // 기기 반영(§2 ③)
}

지키는 것: 편집 원문·멱등 키·지문은 조립기가 만들고(S03) 컨트롤러가 덧쓰지 않는다. 전송 중 추가 편집은 후속 행(S04)으로 남고, 개정번호 충돌 재전송은 후속 행으로 먼저 영속화된다(S06). 확정 문구는 적재 때 띄우고(announced), 답장 성공은 무음, 한 번이라도 못 보낸 행만 "동기화했어요" 1회(쓰기 파이프라인 §4).

5. 답장 → 무효화 표 (features/workout/queries/receiptInvalidation.ts)

전송 결과(DispatchResult) 하나에 표 하나. binding 은 표를 순서대로 실행만 한다.

결과 × 행확정 사본상세 무효화목록에서 떼기서버 최신본 수렴목록(피드·검색)셸·달력피드통계 세대 폴링
committed · 완료 생성새 카드(receipt)그 id프로필 셸 + 날짜○(≥1)
committed · 완료 수정같은 id 교체(keepNewer)그 id셸 + 날짜
committed · 완료 삭제그 id셸 + 날짜
committed · 계획 저장계획 상세○(영수증 개정번호 이상만 채택)셸 + 날짜없음(세대 0)
committed · 계획 삭제계획 상세셸 + 날짜없음
committed + recovered · 5종없음(역사 요청은 현재 상세 아님)종류별 idmissing 확인 후만○(receipt revision 이상)완료 기록만셸 + 날짜완료 기록만없음(과거 세대)
already_gone그 id셸 + 날짜없음(서버 변경 없음)
unknown없음(성공으로 그리지 않음)서버 대상이면 강제 조회없음
blocked / held / deferred
  • receipt 후 상세 조회는 화면 열기와 같은 owner resource/fetchAdopted를 소비한다. 최소 개정번호를 loader와 소비자 반환 경계에서 검사해 fresh cache·진행 중 요청 합류도 보호하고, owner 이탈/대체 null과 명시 missing을 구분한다. 계획의 카드와 상세는 같은 canonical 응답으로 갱신하며 preservePlans:true 셸 갱신은 진행 중인 계획 상세 조회를 비우지 않는다.
  • 중복 영수증(채택 장부)·다른 owner 의 행은 어느 칸도 켜지 않는다.
  • 저장 단계(G03 §15): device_persisted = 대기열 행, server_committed = 영수증, stats_published = 통계 세대 폴링이 applied ≥ requested 를 확인한 뒤. 세대를 아직 따라잡지 않은 통계는 fresh 로 표시하지 않는다(reconcileCompletedWorkoutReadModelsgenerationPublished 뒤에만 정확 달력 재조회).
  • "셸"(홈 대시보드 reloadRemoteData) 열은 홈 읽기 모델이 아직 A10 의 resource 가 아니라 남은 항목이다 — A10 이 옮기면 그 열을 지운다.

6. 증명 (2026-09-08 실측)

조건증거
온라인 생성·수정·삭제 / 오프라인 수정 → 재시작 → 복구 / 답장 유실 → 같은 identity 재전송 / 전송 중 추가 편집브라우저 CASE-024·033 / CASE-042 / CASE-041 / CASE-040·045 (npm run ci:local --full 브라우저 레인)
계획 생성 온라인·수정/삭제 durable, 원문·id/hash 보존planRoundTripHarness 12(컨트롤러 → 조립 → 메모리 대기열 → 실제 dispatcher → 계획 명령 → 저장소 → RPC) · CASE-016·029
owner 전환 뒤 늦은 read/receipt 무효plannedSessionDetailResource·exerciseCatalogResource(owner 떠남 → 요청 취소·항목 삭제·늦은 응답 거부) · a09ReceiptInvalidation(다른 owner 의 결과 0) · CASE-044
저장 규칙 한 벌a09EditorSaveParity: 같은 payload 로 옛 4단계와 편집기 한 벌의 S03 requestHash 동일(live·backfill). 알려진 차이 2건 명시(§8)
UI 이벤트 → outbox → RPC → 영수증 → resource 추적계획: planRoundTripHarness(RPC 인자·영수증·대기열 비움) · 완료 기록: CASE-029(IndexedDB pendingSaves → RPC → PostgreSQL → 재진입)

7. 남는 호환 어댑터 (소비자 · 담당 · 제거 조건)

어댑터소비자담당제거 조건
commitExerciseCatalog(store 채택 → 전역 종목 목록 setRemoteExercises + gymData.EXERCISES 병합)모바일·데스크톱 화면(exerciseCatalog prop)U02/U03·A10화면이 카탈로그를 resource 스냅샷으로 받으면 삭제
createPlannedSessionDetailCoordinator 4 인스턴스(PR 종목 상세·연도·기록·이력)PR 화면A10resource 로 옮기면 함수째 삭제(계획 상세는 이미 빠짐)
runMinimumVersionSingleFlight(연도 활동)홈 연도 활동A10같음(카탈로그는 이미 빠짐)
services/readModelQueryCache.ts(달력 문자열 키)달력 storeA10ResourceKey(owner·range) 로 옮기면 삭제
무효화 표의 shell 열(reloadRemoteDatarefreshAuthenticatedProfile·loadProfileFeed홈·프로필·피드A10·A11각 읽기 모델이 resource 가 되면 표의 열을 invalidate(key) 로 바꾸고 env 함수를 지운다
pendingSavesFeatureBinding env 의 화면 함수(setPlans·setSelectedSession·달력 날짜)달력·상세 화면U02/U03·A10화면이 resource 스냅샷을 구독하면 확정 사본 교체를 store.set 으로 바꾼다
completedWorkoutCommands.save/delete(온라인 직접 쓰기 명령 — 제품 호출처 0, 전송 계약 테스트만)workoutWriteSurvival·completedWorkoutCommands 검사A15/R01검사를 dispatcher 포트(createCompletedWorkoutWritePort) 기준으로 옮기면 삭제
WorkoutFlowBinding·desktopAppguard*SavePayload(화면 관문)저장 콜백U02/U03화면이 편집기 상태를 직접 들면 관문이 곧 편집기라 사라진다. 컨트롤러 쪽 editor_invalid 판정이 fail-closed 뒷받침

8. 이 트랙이 확정한 것·전제

  • 계획 수정·삭제의 확정 문구는 완료 기록과 같은 immediate 규칙("수정하였습니다"/"계획을 저장하였습니다")이고, 오프라인 전용 안내("기기에 보관했어요…")는 없다. 정책표(쓰기 파이프라인 §4)는 바꾸지 않았다.
  • 저장 조립의 알려진 차이(옛 4단계 → 편집기, a09EditorSaveParity 가 명시): ① 자유 기록(note) 행 체중 계수 옛 0 → 편집기 null(A12: 체중 계수는 종목에만) ② 진행 중 운동에서 종목 완료 + 체크 안 한 값 있는 세트 — 옛 normalize 는 기록으로 쳤고 편집기는 "사용자가 완료한 세트만"(A12 §"진행 중은 완료 세트만"). 둘 다 A12 계약이 정본이다.
  • 모바일 저장 payload 에는 실패 세트 별칭이 없다 — controllerSave.mergeDraftSetAliases 가 최신 초안 스냅샷에서 별칭만 가져온다(종전 lgCanonicalizeWorkoutUiAliases 의 두 번째 인자와 같은 뜻). U02 가 화면을 편집기 상태로 옮기면 이 병합은 사라진다.
  • 캐시·홈 행의 이름은 각각 한 모양(camelCase updatedAt / 화면 RPC 행 catalog_version)이다 — 이중 키 폴백을 두지 않는다.

9. Phase 3 인계

받는 작업가져가는 것
U02/U03(화면 이식)§2 폴더 규약 · §3 resource 3종(화면은 스냅샷을 props 로) · §7 의 화면 함수 어댑터 · 관문 → 편집기 상태 직접
A10(달력·PR·홈·피드 resource)§3 패턴(키 함수 + store 공장 + loader + fetchAdopted) · §5 표의 shell 열 제거 · §7 의 조정자 4개·단일 비행·달력 문자열 키 캐시
A11(그룹·소셜)§5 표에 행 종류(보드 저장 등)를 더할 때 "세대 0 → 통계 없음" 규칙
S09(복구 화면)unknown·blocked 행은 표에서 격리 안내 수로만 세고 성공으로 그리지 않는다 — 복구 입력은 S06 근거·장부
A15/R01§7 의 completedWorkoutCommands.save/delete 삭제 조건

Phase 2 종료 연결 보완 (2026-09-08)

이 문서의 receipt 조회 미구현·직접 API 재조회 인계는 Phase 2 종료 기록으로 갱신한다. 원문 보존·명시 owner·기존 저장 정책은 유지한다. 역사 영수증을 현재 상세로 취급하지 않고, 복구 ACK와 canonical resource 수렴을 구분한다. 이 보완의 병합·staging 상태는 해당 기록에서 확인한다.

A11 — Profile·Social·Group·Import 통합 (2026-09-10, #1419)

적용 대상은 release/v0.18.0 후보다. 구현·검증 기록의 PR/병합 상태가 기준이며 문서 게시를 앱 배포로 보지 않는다.

영역서버 값의 소유자·키화면·쓰기 경계
프로필features/profile/profileResource.ts, owner/profile 한 항목로그인 초기값·workspace가 같은 snapshot. 이름·사진·신체 데이터 등 확정 쓰기는 이전 읽기를 무효화. profileFeatureBinding은 필드별 쓰기 순서와 owner epoch를 확인한다. 원자 저장 payload는 그대로다.
내/친구 피드features/social/feedResource.ts, owner/target/limit/3필드 cursor첫 페이지부터 연결된 페이지만 선택·id 중복 제거·200행 상한. refresh 중 append는 새 첫 페이지의 cursor로 이어간다. boot·탭 진입·친구 화면이 같은 store를 사용한다.
팔로우·검색·댓글·좋아요·차단features/social/*Store.ts, owner/대상/조회 조건서버 응답은 resource, pending·입력·낙관 표시는 UI 상태다. 확정 댓글/좋아요는 먼저 시작한 읽기를 막는다. unfollow/block은 친구 대상 resource를 제거하고 열린 대상 화면을 닫는다.
그룹features/group/groupStore.ts, owner/kind/group/date 또는 month/session목록·초대·라운지·보드·완료자·월 표시·출석·상세를 분리. 보드와 완료자 독립 로딩. 권한 거절은 이전 데이터 표시를 막고 그룹 목록에서도 제거한다.
알림features/group/notificationStore.ts, owner/cursor · notificationTargetStore.ts, owner/session페이지 연결·읽음 cutoff와 확정 ID 유지. 알림 actor를 세션 owner로 추정하지 않고 상세와 소셜 owner를 대조한다.
인입 관찰features/import/importJobResource.ts, owner/jobId서버 snapshot과 단일 관찰 루프를 공유한다. importFeatureBinding은 I01 DTO를 화면에 연결하며 parser/worker/원문은 소유하지 않는다.

동일 키에 소비자 두 명이면 전송은 한 번이다. 한 소비자가 떠나도 나머지는 유지되며 마지막 소비자 취소는 전송 신호로 전달된다. owner epoch 전환은 같은 계정 재로그인도 이전 응답·ACK와 분리한다. feature 구독 해제·dispose 후 owner listener가 원래 수로 돌아오는 조립 검사가 있다.

기존 remoteDataController의 피드 원본 state/ref·수동 prefetch 응답 사본, following loadedOwner, 그룹 boardCache/loadedMarkMonths, 친구/알림의 조회 token·원격 상태 사본은 제거됐다. gymData.PROFILE_FEED*, 화면 props, legacy 행 mapper는 읽기 전용 투영으로 남는다. AnyRecord·snake case 행 계약과 공통 셸의 최종 축소는 A16/A15가 소유한다. 커스텀 종목·소셜 계정 연결·온보딩 lifecycle을 이번 트랙에서 새로 이식하지 않는다.

U02 모바일 화면 이식 (2026-09-10, #1420)

§7의 WorkoutFlowBinding 저장 관문은 모바일에서 제거됐다. 화면이 현재 공통 편집기의 typed view와 command를 직접 소비하므로 저장 때 화면 배열을 다시 편집기로 여는 경로가 없다. 저장 시도와 이후 입력을 분리하고 실제 A09 대기열/영수증/통계 세대로 상태를 표시한다. S09 runtime은 owner를 격리하고 메뉴의 보관·복구 화면에 목록→미리보기→명시 확인→상태를 제공한다. 세부 책임과 검증 범위는 모바일 편집 연결을 따른다.

10. A15 기능별 binding과 최종 shell 경계 (2026-09-10, #1531)

적용 대상은 v0.18.0이다. 앱/문서 PR·release SHA와 검사 환경은 A15 적용 기록을 따른다. release 통합은 Production 배포와 다르다.

조립과 상태의 소유

앱 시작은 app.tsx의 인증/데이터 gate → owner store provider → appController의 명시적 feature provider → 플랫폼 route/overlay host 순서다. appController가 runtime과 store set을 한 번 생성하고 기존 owner 전환 순서에 따라 보존·교체한다. 화면은 controller 전체 결과를 받지 않는다. 기존 실제 206필드 renderCtx 및 ControllerRenderContext 경로는 제거됐다. 각 context는 자기 feature의 계약을 선언하고 전체 controller 타입에서 Pick하거나 전역 등록기로 조회하지 않는다.

소유 경로(앱 src/react 기준)담당하는 실제 동작shell에 남는 것
features/active-workout/*Binding · workoutEntryIntent시작/복원/계획 선택/중단, 현재 owner와 진입 요청 확인, pagehide·visibilitychange 등록/해제runtime/store/route callback 주입
features/completed-workout/*Binding · workout-plan/planEntryBinding완료/계획 편집 열기·닫기, 상세 확인, 명령 연결route·overlay 조립
features/calendar/*Binding · daySummaryBinding날짜 상태, 월/하루/범위 선택·조회, pending 사본 합치기·하루 상세 투영읽기 resource 한 벌 연결
features/home/*Binding · desktopHomeProjection홈 bootstrap·표시 데이터·연도/일 상세와 요청 취소feature 입력 주입
features/pr/*Binding · volume/*Binding직접 기록·지표 쓰기, PR view·상세/이력 읽기, 리포트 투영·재조회같은 통계 resource 연결
features/profile/profileBinding · onboarding/onboardingBinding · import/importOutcomeBinding프로필/온보딩/인입 명령과 결과·무효화, 계정 삭제 기기 보관함 정리인증 gate·법적 문서 overlay
features/social/*Binding · group/*Bindingfeature selector·소셜 명령·알림·친구 범위계정별 resource/store 주입
features/navigation/* · desktopApp · mobileApp탭·화면 스택·drawer·복귀 위치·global overlay host해당 책임 자체

계정 변경의 feature cache reset을 root에 다시 나열하지 않는다. 기존 owner store host와 각 feature 소유 reset을 따른다. 직접 기록 배열은 owner 변경 layout에서 비우며 이전 owner 요청의 성공 응답은 채택하지 않는다. 초안 복원은 owner UI 준비와 remote ready를 기다리고, 이미 복원/편집한 상태를 늦은 결과로 덮지 않는다. 조회 실패는 인증 owner를 제거하지 않는다.

구독·표시·쓰기 보존

  • Desktop route binding은 mounted 상태를 유지하고 자신의 활성 화면만 그린다. Home 일 상세는 owner/탭/같은 탭 재선택/해제 시 요청을 취소한다. PR은 필요한 cell detail/loading/error 사본만 구독한다.
  • Mobile은 기존 TabStackProvider·MobileTabsRegion의 방문한 탭 보존과 useSyncExternalStore 선택 구독을 유지한다. session 날짜/시간·그룹 catalog identity·프로필 resource 소비는 각각 기능 모듈에 있다.
  • Home은 eager이고 나머지 기존 화면은 lazy import와 stale chunk recovery를 유지한다. import·chunk 비용의 추가 최적화는 U05 소유다.
  • WorkoutFlowBinding은 넓은 gymData 대신 exerciseCatalog와 StatsGeneration 형태의 readModelGeneration을 받는다. 모바일/데스크톱의 기존 editor·validation·저장 포트는 그대로이며 날짜·원본 id·세트 입력·늦은 저장 중 추가 입력을 보존한다.

실제 변경 예시와 후속 경계

tests/browser/a15/home.tsx는 appController/mobileApp/desktopApp import 없이 실제 DesktopHomeBinding·DesktopNavigationProvider·DesktopHomeScreen을 렌더한다. Home의 이름 입력만 바꾸면 화면 이름이 갱신되고 현재 owner의 기록 명령으로 전달된다. 탭 왕복·계정 변경·해제 뒤 늦은 일 상세 응답은 닫은 modal을 다시 열지 않는다. 동일 소유 원칙은 desktopFeatureBindings/mobileFeatureBindings 단위 검사와 mutation 증거로 확인했다.

후속정확한 인계
A16feature UI bridge의 기존 ComponentType<any>/AnyRecord와 mapper 안의 dual shape. scripts/check-typescript-boundary-gate.mjs의 A15 이전분 13곳이 목록이다. appController의 명시적 any 허용은 제거. 전체 앱 any 제거로 집계하지 않는다.
U05실제 feature별 화면 import 경로, DesktopBindings mounted 유지·Home eager/secondary lazy, context 값 재생성·구독 비용. 현재 bundle 성능 개선량은 주장하지 않는다.
R01§7의 completedWorkoutCommands.save/delete는 제품 호출자 0·전송 계약 검사 소비를 유지한다. 검사를 dispatcher 포트로 옮긴 후 삭제한다. 옛 controller 재수출·화면 파일 문자열 단언은 실제 소유 파일로 옮겼으며 감사 선언은 pending-changes에 남긴다.
U06/R05native shell·실서비스 인증부터 저장까지 전체 여정·Production 성능/감각 품질은 이 fixture 검사 범위 밖이다.

11. A16 타입·변환 경계 (2026-09-11, #1534)

A15 뒤 활성 앱의 범용 any bridge·alias와 내부 camel/snake 중복 해석을 실제 feature/domain 계약으로 이전했다. §9~10의 A16 인계 항목은 A16 적용 기록개별 보존 경계로 이어진다. 기록의 release 병합 상태와 Production 배포는 구분한다.

Repository 소비자는 services/domains/repositoryComposition.ts의 실제 factory instance를 사용한다. 이 파일은 인스턴스 조합만 맡고, RPC 실행·검증/취소, 진단, transport, raw export는 각각 해당 runtime/repository가 맡는다. 구 barbelicRepository·repositoryPorts·services/index를 재생성하지 않는다.

원시 응답은 domain codec, 확정 PR/볼륨 집계는 domain projection, session→화면/draft와 home/profile/feed/volume 표현은 각 feature의 projection/adaptor가 맡는다. 서로 다른 입력 상태·DTO·ViewModel을 통합 타입으로 합치지 않는다. PR 화면의 volume 조합은 controller/binding이 실제 volume owner의 결과를 연결한다.

PR 상세 store·방문 취소·fragment/archive 조회 수명주기는 features/pr/queries/prExerciseQueryBinding.ts가 소유한다. remoteDataController.ts는 필요한 조립과 callback을 연결하고 같은 store/ref/map/flag를 중복 보유하지 않는다. 기존 owner epoch·stale 응답·조회 순서·취소 규칙은 유지한다.

버전별 persistence와 복구 원문/manifest/checksum, pound-origin provenance는 지원 조건이 남은 좁은 입력 경계에서 유지한다. 활성 앱 explicit-any 예외는 0개이며, 정규식 dual-key 잔존 14곳을 모두 같은 객체의 내부 이중 해석으로 취급하지 않는다. 각 항목의 실제 공급원·소비자·제거 조건은 A16 경계 기록을 따른다.

R01 퇴역 반영 (2026-09-11)

§7에서 인계한 제품 호출0 completedWorkoutCommands.save/delete와 전용 오류 봉투를 R01에서 제거했다. 준비기→실제 dispatcher 포트로 기존 ID/hash/revision/실패 보존 검사를 옮겼다. openEditor/openDetail·대기열 내구 경계·전송 제한은 유지한다. 최종 경계와 검증을 따른다.

12. 응답 처리 층 — 채택 알림·미리 받기·양보 규칙 (#1610 Phase 3, 2026-09-15)

유저 A 가 앱을 켜고 홈이 뜬 직후 리포트를 누른다. 그 순간 뒤에서는 리포트 개요(약 600KB)·종목 카탈로그(약 800KB) 미리 받기가 도착해 검증·투영되고 있을 수 있다. 이 절은 그 응답 처리가 A 의 누름을 늦추지 않도록 자원 스토어와 미리 받기가 지키는 규칙이다.

  1. 채택 알림은 그 키의 구독자에게만. 자원 스토어(resources/resourceStore.ts)는 요청 시작·채택·오류·무효화·제거를 (a) 그 키를 구독한 소비자(store.subscribe(key)useResourceSnapshot)와 (b) 스토어 전체 선택자 구독자(store.subscribeAlluseResourceStoreSelector, 같은 값이면 이전 참조를 돌려줘 렌더를 만들지 않는다)에게만 알린다. 조립 루트는 자원을 구독하지 않는다(#1610 Phase 2 — docs/gates/render-ledger-1610.md §5). 같은 스토어의 다른 키·다른 스토어·다른 owner 의 채택은 알림을 만들지 않는다. 채택은 owner·키 버전 울타리(fenceIntact)를 지난 응답만 한다(A08).
  2. 큰 미리 받기는 유저의 요청이 끝나고 화면이 그려진 뒤, 입력이 없는 창에서 시작한다. 탭 데이터 워밍(달력 이번달·오늘 상세 → 그룹 → 리포트 개요, features/navigation/tabWarmBinding.ts), 홈의 PR 개요 미리 받기(controllers/screenLoadPolicyController.ts), 카탈로그 증분 동기화(같은 파일)는 유휴 시점에 더해 services/inputActivity.tswaitForInputQuiet 를 지난 뒤에 시작한다. "조용한 창" = ⓐ 마지막 입력 뒤 700ms 동안 입력 없음 ⓑ 유저의 요청(규칙 6)이 진행 중이지 않음 ⓒ 마지막 유저의 요청이 끝난 지 1.5초 이상(뒤따르는 요청과 화면 그리기가 이어질 시간). 상한 6초 — 계속 누르거나 요청이 길어도 미리 받기가 영영 밀리지는 않는다. ⓑⓒ 가 없으면(입력 뒤 700ms 만) 유저가 누른 화면의 큰 응답 처리와 미리 받기(카탈로그 0.8MB) 처리가 정확히 같은 창에 겹친다 — 2026-09-15 실측(docs/gates/render-ledger-1610.md §6). 부팅 셸과 나란히 나가는 첫 미리 받기(PR 개요·피드 첫 페이지, 스플래시 아래)는 입력이 있을 수 없어 그대로다.
  3. 응답 처리는 입력과 유저의 요청에 양보한다. 화면 RPC 관문(services/screenRpcRuntime.ts)은 응답을 받은 직후 입력이 대기 중이거나 최근 120ms 안에 있었으면 검증·투영 앞에서 매크로태스크 한 차례를 양보하고(yieldIfInputBusy), 200KB 이상 응답은 채택·화면 갱신 앞에서 한 차례 더 양보한다. 배경 요청(규칙 6)의 응답은 그에 더해 유저의 요청 사슬이 끝나고 입력도 조용할 때까지(진행 중인 유저의 요청 없음 + 마지막 요청 뒤 1.5초 + 마지막 입력 뒤 700ms, 상한 3초) 검증·투영을 미룬다 (waitForForegroundQuiet) — 조용한 창에 시작한 미리 받기라도 응답은 그 뒤 유저가 연 화면의 로딩 도중 도착할 수 있고(실측: 홈에서 시작한 카탈로그 응답이 리포트 개요 투영·렌더와 설계 요청 사이에 끼어 150~250ms@4x), 시작을 미루는 것만으로는 이것을 막지 못한다. 양보·미룸은 요청 번호·owner 울타리·재시도 규칙을 바꾸지 않는다 — 늦게 채택될 뿐 다른 값이 채택되지 않는다.
  4. 투영은 응답이 바꾼 것만 다시 한다. 리포트 개요 투영(buildVolumePropsFromOverview)은 PR 보드·카드 계산과 분리돼 있어 개요 응답이 도착해도 카탈로그 병합·정렬은 다시 하지 않는다(features/pr/PrViewProvider.tsx).
  5. 워커 채택은 실측으로 정한다. parse→검증→투영을 워커로 옮기는 안(#1603 ③)은 위 규칙을 적용한 뒤 판정 러너(겹침 시나리오 report-open-collide, 리포트·달력 cold/warm 1·4년 20표본 2묶음)에서 남는 이득이 어댑터 몫보다 작으면 채택하지 않고 수치로 기록한다 — 결과는 docs/gates/render-ledger-1610.md §6.
  6. 요청의 출처는 호출자가 표시한다 — 표시가 없으면 유저의 요청이다. 화면 RPC 는 시작할 때 beginScreenWork(rpcName, origin) 로 출처를 정한다. ⓐ 배경(origin: "background")은 화면 공개가 그 응답을 기다리지 않는 순수 미리 받기에만 붙인다 — 지금은 탭 워밍의 리포트 개요(tabWarmBindingloadPrDashboard({ origin: "background" }) → 자원 로더 → 저장소 → 관문) 하나다. 카탈로그 동기화는 카탈로그 공개 상태가 동기화 진행 중이면 pending 이고 (catalogPublicationOf), 홈의 PR 개요 미리 받기는 홈 공개가 PR 상태를 합치므로, 둘 다 배경이 아니다(시작만 규칙 2 의 조용한 창을 기다린다). ⓑ 표시가 없는 요청은 유저의 요청(origin: "input")이다 — 응답 처리를 미루지 않는다. 시작 시각으로 배경을 추정하지 않는다: 추정으로 배경이 된 요청에 유저가 합류하는 자리를 모두 알 수 없어, 2026-09-15 실측(p3e)에서 탭 워밍이 조용한 창에 시작한 달력 이번달 요청에 유저의 달력 진입이 합류했는데 응답 채택이 1.7초 미뤄졌다(달력 cold 데이터→공개 201 → 1,717ms). ⓒ 유저의 호출이 진행 중인 배경 요청에 합류하면(같은 자원 키를 기다리게 되면) 그 요청을 유저의 요청으로 승격한다(promoteScreenWork, loadPrDashboard·ensureExerciseCatalog 의 유저 경로) — 배경 응답 미룸(규칙 3)이 유저를 기다리게 하지 않는다. 새 미리 받기를 배경으로 표시하려면 같은 자원을 기다리는 유저 경로에 승격을 함께 둔다. 출처는 완료 기록(screen_rpc:completed.origin)에 남는다.