Skip to content

체인지로그

시간에 따른 아키텍처 결정과 문서 갱신이고 최신순이다. 아홉 달에 걸친 항목 167개다.

이 문서는 날짜가 붙은 서사다. 무엇이 언제 바뀌었고 무엇을 깨뜨렸는가. 그것이 구현하는 결정은 14-decisions에 자기 카드를 갖는다. 그 카드가 지속되는 진술이고 이 항목은 역사다.

바로 가기: 2026년 9월 · 2026년 8월 · 2026년 7월 · 2026년 6월 · 2026년 5월 · 2026년 3월 · 2026년 2월 · 2026년 1월 · 2025년 12월

2026년 9월 1 entry

📅 2026-09-04 — 무권한 이탈 폴백을 철회로 기록했다. 컨트랙트에서 빠진 지 나흘 뒤다

📝 문서만 · 코드 0줄

v3-157claimRedemptionFallbackPlatformPool에서 사라졌다(2026-08-31). 그리고 그것은 함수가 아니라 보장이었다. 즉시 상환 풀의 요청이 만들어진 뒤 7일이 지나면 누구나 정산할 수 있게 했고, 그것이 *"응답을 멈춘 운영자가 홀더를 온체인에 가둘 수 없다"*를 약속이 아니라 컨트랙트의 성질로 만들던 장치다. v3-155가 리저브를 풀 밖으로 보내면서 그 함수가 지급하던 잔액이 구조적으로 0이 됐다. 아무도 구제할 수 없고, 남겨 두면 리버트되는 보장이 된다.

이제 이탈권은 리저브 지갑이 오프체인에서 요청에 자금을 넣는 것에 기댄다. 누구나 할 수 있던 행위가 키 보유자의 재량 행위로 바뀌었다. 정리 작업이 아니라 맞교환으로 기록한다.

🔴 v3-31의 절반은 살아 있다. 회차 풀은 무권한 executeEpoch와 대리 청구를 유지하므로 정산된 회차는 Aset이 움직이든 말든 지급된다. 즉시 상환 쪽만 없어졌다.

카드 둘에 배너를 붙였고 어느 것도 다시 쓰지 않았다: v3-28(freeze 부분은 그대로다. 비대칭 게이트·72시간 이탈 창·7일 자동 만료 모두 살아 있음), v3-31(구현 완료가 아니라 절반 구현).

🔴 이 패스는 의도적으로 미완이고, 카드가 어디가 남았는지 적어 놨다. 여섯 페이지가 폴백을 현재형으로 서술하고 있고 그중 하나는 08a-contract-reference §3.5 비수탁 보장 절 안이다. 가장 나쁜 자리다. FALLBACK_NOTICE_DAYS도 아직 live 상수로 올라 있고, 컨트랙트 주석 2곳(PlatformKYCSoulbound.sol:75 · PlatformPoolBase.sol:128)이 남아 있다. 조용히 두지 않고 적는다. 철회된 보장이 어딘가에 아직 적혀 있는 것이 지금 기록하는 그 실패이기 때문이다.

⚠️ approveRedemption은 같은 제거에 들어갔지만 이유가 다르고 철회된 보장이 아니다. 요청 이후 새 입금으로 리저브가 늘어난 드문 경우를 정산하던 것인데, 입금이 더 이상 리저브를 늘리지 않으니 그 경우는 드문 게 아니라 불가능해졌다.

출처: CH 컨트랙트 라운드 2026-08-31. JY 기록 2026-09-04.

2026년 8월 45 entries

📅 2026-08-30 — 27일에 내린 제품 결정 일곱이 카드를 얻고, 문서들이 서로 다르게 말하던 재투자 규칙을 정리했다

📝 문서만 · 코드 0줄

2026-08-27에 결정 일곱이 닫혔고 29일에 문서에 적혔다. 그중 무엇도 반영되지 않았다. 각각을 날짜와 명시적인 "아직 반영 안 됨"과 함께 기록한다. 여기서의 실패 양상은 끝난 것처럼 읽히다가 다음 독자의 진실이 되는 문서이기 때문이다. 카드는 v3-149부터 v3-155까지다.

  • v3-149 — 즉시 상환에는 취소가 없다. 회차 흐름은 그대로이고 온체인 분기는 남는다
  • v3-150redemption_gating_bps는 표면에서 빠지고 컨트랙트와 컬럼은 그대로다
  • v3-151 — 재투자를 모든 화면에서 뺀다. 경로를 닫는 것은 allow_rolloverfalse 기본값이다
  • v3-152executeWindDown이 NAV를 다시 계산하지 않는다. 새 풀에만 해당하고 Clones는 고정된다
  • v3-153 — 이자는 납입 자본에 붙고 상각은 청구액을 움직이지 않는다
  • v3-154apy_rateaccrual_rate_bps가 발생 모드에 따라 배타적이 된다
  • v3-155 — 리저브가 외부 지갑으로 라우팅되고 풀 안의 카운터가 사라진다

🔴 컨트랙트가 빠지는 그것을 여전히 지고 있는 곳에서는 그렇게 적었다. 청산과 R10 절은 그대로 둔다. 이미 배포된 모든 풀에 대해 정확히 그대로 참이고, 그 풀들은 구현체를 영구히 유지한다.

같은 작업에서 코드가 맞고 문서가 낡았던 정정 다섯: allow_rollover에는 온체인 쌍둥이가 있다(setAllowRollover). NAV 유도는 수동이 아니다. runPricingSweep이 돌고 만들어지지 않은 것은 DPD → OJK 대출별 파이프라인이다. sweep의 진입점은 삭제된 nav-proposals.scheduler.suggest가 아니라 report.scheduler.derive다. nav-changes.post.propose.ts는 라우트 껍데기이고 로직은 usecase에 있다. 그리고 reserve_consumed는 R8 아래에서 언제나 0이다(스키마 주석만).

🔴 그리고 여섯 번째는 나중에 발견했다. 문서 둘이 부분 재투자에 대해 서로 다르게 말하고 있었다. 02-core-concepts는 *"전액 재투자만(부분 불가)"*이라고 했고 05-investment-lifecycle은 반대로 말했다. 코드가 결론을 낸다. YieldLib.reinvest는 요청 초과만 거부하고, 그 최소 금액 가드는 min_reinvest_amount 아래의 부분 재투자를 위해 존재한다. 전액만 재투자할 수 있다면 대상이 없는 가드다. 02는 그 호출을 reinvest(fullYield)로도 적었는데 시그니처는 reinvest(address stablecoin, uint256 yieldAmount)다. ⚠️ 2026-07의 BD5 항목은 쓰인 그대로 둔다. BD5가 결정될 때는 참이었고, 그것을 대체하는 것은 v3-151이다.

06-writedown-navTODO(N-1)을 남겼다. 손실 입력이 상각이 아니라 파트너의 NPL인 자리다. observe.tstotalNplCUMULATIVE_LOSS 지표에 넣으므로 NAV가 잃은 돈이 아니라 늦은 돈으로 가격 매겨진다. 표현은 아직 회신을 기다리는 파트너 API 요청에 달려 있다.

출처: JY, 2026-08-27 결정 라운드. 문서 작업 2026-08-29.

📅 2026-08-27 — 코드가 이미 앞질러 간 문서상 사실 셋: 하나씩 어긋난 풀 명단, 쓰인 지 몇 시간 만에 뒤집힌 결정, 그리고 자기를 대체한 마이그레이션에 귀속된 뷰

📝 문서만 · 코드 0줄

동작은 아무것도 바뀌지 않았다. 셋 다 코드가 맞고 그것을 서술하는 문서가 틀린 자리이고, 그쪽이 더 나쁜 방향이다. 낡은 문서는 답처럼 읽힌다.

🔴 sepolia.md의 세대 표가 양쪽으로 하나씩 어긋나 있었다. QA Flow 260806QA Upcoming 260812를 나열했는데 그 표를 만든 감사보다 앞선 2026-08-12와 2026-08-19에 소프트 삭제됐고, 가장 오래된 살아 있는 풀 FJL IDN Private Credit 1은 빠져 있었다. 어떤 풀이 어느 구현체에서 clone됐는지를 오프체인에 기록하는 것이 없으므로, 이 표는 그 사실의 요약이 아니라 그 사실 자체다. 빠진 풀에 대해서는 기록이 아예 없었던 것이다. 거기 매달린 숫자들도 함께 움직였다. "아홉 중 하나도 없다"는 dev 풀 일곱이고, pool_implementation은 살아 있는 여덟 전부에서 NULL이다.

⚠️ FJL은 세대가 없는 것으로 적었고, 그것은 읽지 못한 것과 다르다. 표시 전용 풀이다. deploy_status = NOT_DEPLOYED이고 배포 tx도, 팩토리도 없으며 보유분은 원장 이벤트로 심었다. clone이 없으므로 읽을 구현체도 없다. 그래서 행이 그렇게 적는다. 이 스택이 배포한 적 없는 주소의 바이트코드를 찾아가라고 다음 독자에게 cast code 명령을 주는 대신이다. 🔴 그런데 그 행은 pool_addresslp_token_address를 그대로 담고 있고 그것은 0179:4의 "pool_address 없음"과 모순된다. 조용히 맞추지 않고 그 자리에 표시했다. JY와 CC 둘 다 그 행을 확인하기 전에 문서 쪽 사실로 추론했기 때문이다.

🔴 같은 파일이 "그 컬럼은 미뤘다"를 유효한 결정으로 기록하고 있었다. 같은 날 뒤집혔다. 마이그레이션 0205_pool_implementation과 커밋 c919f5f가 둘 다 2026-08-21이고 그날이 감사와 결정의 날이다. 그 문단은 뒤집히기 몇 시간 전에 쓰였고 다시 손보지 않아서 현재처럼 읽혔다. 이제 둘로 나눴다. *"화면이 세대를 구분하도록 가르치지 말 것"*은 여전히 유효하고 *"그 컬럼은 미뤘다"*는 하루 갔다. 컬럼이 표를 은퇴시키지도 않는다. 존재하는 모든 풀이 그보다 앞서고 NULL로 읽힌다.

🔴 11-db-schemadashboard_alert_counts를 0113의 것으로 제시했는데 그것은 0175에서 참이기를 그쳤다. 그 마이그레이션이 뷰 전체를 다시 냈고, 집계 여덟 중 다섯이 이제 원장이 대체한 테이블 대신 원장 뷰를 읽는다. "이 알림이 무엇을 세는가"를 문서와 대조한 독자는 원장 이전의 답을 얻었다. 24시간 정체 임계값은 네 군데에 적혀 있다. STALLED_YIELD_HOURS, admin-web의 STALLED_PROCESSING_HOURS, schema.sql 스냅샷, 라이브 마이그레이션이다. 그리고 살아 있는 SQL 사본은 0175의 것이다. 0113이나 0114의 텍스트를 놓고 고치면 대체된 마이그레이션에 착지하고 데이터베이스에는 절대 닿지 않는다.

⚠️ countStalledYieldByPool은 다섯 번째 사본이 아니다. STALLED_YIELD_HOURS를 임포트한다. 두 번째 독자이고, "셋이 일치해야 한다"고 말하는 admin-web의 주석은 사본을 뜻하면서 독자를 세고 있다. 이 정정의 첫 시도가 같은 실수를 했기에 기록한다.

그리고 임계값 가드가 살아 있는 SQL에 눈멀어 있지도 않다. 첫 시도가 그렇게도 주장했다. stalled-threshold.test.tsschema.sql을 읽고 그 파일은 0175 이후이므로 라이브 드리프트는 여전히 실패한다. 진짜 구멍은 애초에 schema.sql이 다시 생성됐는지를 아무것도 확인하지 않는다는 점이고, 그것이 한때 컬럼 일곱을 마이그레이션에만 살게 한 바로 그 구멍이다.

⚠️ 의도적으로 건드리지 않은 것: 부실 선언 권한 표현. DL-1(b)가 "누구나 부실 선언을 발동할 수 있다" 위에 세워져 있는데 컨트랙트에는 onlyRole(DEFAULT_ADMIN_ROLE)이 있다. 그건 낡은 문장이 아니라 코드와 어긋나는 판단 설계이고, 산문을 고치면 열린 질문까지 함께 은퇴시키게 된다. 결정에 맡긴다.

결정 카드 없음. 새 입장이 아니라 기록에 대한 정정이다. 출처: JY, 2026-08-27.

📅 2026-08-26 — 풀 생성이 모든 시도에 400을 내고 있었다. 백엔드가 필수로 만든 필드를 위저드가 한 번도 보내지 않았기 때문이다

🔨 만듦 · apps/admin-web · 프런트엔드만

🔴 POST /pools가 2026-08-21에 accrual_rate_bps를 필수로 만들었고(v3-143) 위저드는 그것을 한 번도 보낸 적이 없다. 그 문자열이 apps/admin-web 어디에도 없었다. 검사는 핸들러의 첫 줄들에서, 쇼케이스 가드보다 334줄 앞에서 돌므로 쇼케이스 풀도 거부됐다.

🔴 빌드 시점에 아무것도 잡지 못한 이유는 그 필드가 어떤 공유 행 타입에도 없기 때문이다. payload가 깨끗하게 컴파일되고 런타임에 실패했다. CreatePoolPayload가 이제 그 키를 옆의 reserve_bps처럼 선택으로 두지 않고 요구한다. 그리고 그 변경이 마지막 남은 호출 지점을 드러냈고, 그것이 그렇게 한 근거다.

백엔드가 실제로 배포됐는지는 추측이 아니라 읽기 전용으로 확인했다. accrual_rate_bps가 같은 08-21 라운드에 LIST_SELECT에 들어갔다. GET /poolswithOptionalAuth이고 dev 응답이 그것과 현재 LIST_SELECT의 모든 컬럼을 담고 있다. 라우트 그룹은 첫 경로 조각으로 나뉘므로 pools.get.listpools.post.create가 한 스택으로 나가고, 필수 필드 커밋이 select 변경의 조상이므로 두 번째를 담은 빌드는 필연적으로 첫 번째도 담는다.

⚠️ "어느 쪽이든 안전하다"는 해석은 틀렸고 기록할 가치가 있다. DB_FIELDS는 화이트리스트다. 옛 Lambda에서는 추가 키가 버려지고 컬럼이 DEFAULT 0을 가져가므로, 운영자가 요율을 입력하고 성공을 보고도 아무것도 발생시키지 않는 풀을 갖게 된다. "무시될 것이다"와 "아무것도 바뀌지 않는다"는 같지 않다.

폼에 기본값을 두지 않는다. 0조차도. 백엔드가 폴백 대신 그 필드를 요구하는 이유가 그것이 적고 있는 대로다. 0은 revert하지 않는다. 0으로 만든 풀은 잘 배포되고 아무것도 발생시키지 않으며, 발생 드라이버가 대체한 그 결함과 똑같아 보인다. 비어 있으면 위저드 단계가 막히므로 폼이 아직 눈앞에 있을 때 운영자에게 묻는다.

그리고 이제 리뷰 단계에 나타난다. 그전에는 없었다. 절대 정정할 수 없는 유일한 필드가 제출 직전에 읽는 페이지에서 빠져 있던 유일한 필드였다. 8.5%를 85%로 입력하면 8500 bps이고 폼 자신의 범위 안이며 배포된다. 비어 있으면 옆 필드가 쓰는 || '0' 대신 를 렌더한다. "입력 안 함"에 대해 0%를 찍는 것이 이 필드의 정확한 실패 양상이다.

문서: 14-decisions v3-143. 마이그레이션 없음(0207이 이미 나갔다). 출처: JY, 2026-08-26.

📅 2026-08-26 — 늦은 분배가 "0일"이라고 말했고, 상환 모달이 에스크로를 거꾸로 적었으며, 만기 온 풀의 남은 쿠폰을 클라이언트가 떨궜다

🔨 만듦 · apps/web · 프런트엔드만

🔴 "Yield distribution overdue" 배지 옆에 Next distribution · 0 days. 풀 페이지가 카운트다운을 0으로 잘라서, 석 달 밀린 지급이 오늘 도착하는 것으로 렌더됐고 지연이 얼마나 이어지든 0에 머물렀다. 그 자르기는 승인된 문구를 기다리며 TODO가 달린 채 붙들려 있던 버그였다. 공유 라벨에 나머지 절반이 있었다. 늦은 분기가 overdue라는 단어 하나로 답해서 92일 밀린 것과 어제 밀린 것이 똑같아 보였고, 행 왼쪽이 이미 말한 것을 되풀이할 뿐이었다. 왼쪽에 상태, 오른쪽에 지난 일수다. overview 탭은 자기 수정 없이 12 days late를 받아 갔다.

🔴 "상환하는 LP는 에스크로로 옮겨져… 그 시점부터 더 이상 수익을 얻지 않습니다"는 풀이 하는 일의 정반대다. 에스크로된 LP는 그 홀더를 위해 계속 발생시킨다(v3-143). 에스크로가 바꾼 것은 이자가 언제 장부에 잡히느냐다. PlatformPoolStorage.sol:102가 말 그대로 그렇게 적고 있고, previewEscrowAccrual은 "투자자가 대기열에 선다고 이자가 멈추지 않았음을 볼 수 있어야 한다"는 이유로 존재한다. 이것은 서명 직전의 마지막 화면이고, 그것을 거꾸로 적는 것은 존재하지 않는 기다림에 대한 반대 논거다. 옛 표현을 정당화하던 주석 둘도 같은 커밋에서 정정했고 이제 그 좌표를 인용한다. 그중 하나는 거짓인 절반을 그 문장이 쓰인 이유로 적고 있었다.

🔴 클라이언트가 CLOSEDMATURED에 대해 next_yield_due를 지우고 있었다. 백엔드가 버린 규칙의 마지막 사본이고, 백엔드는 그 이유도 적어 뒀다. 날짜를 지우면 "그 기간이 보일 수 있는 유일한 자리들에서 사라지고" "12개월 풀의 마지막 쿠폰이 정확히 그 구멍에 빠진다." 이제 서버 쪽에서는 DRAFT만 지운다.

⚠️ 그 목록을 통째로 지우는 것이 남겨 두는 것보다 나빴을 것이다. 클라이언트에는 그 값 뒤에 추정치가 있고(start_date + 주기) 그 lifecycle들에서 서버의 null은 부재가 아니라 이다. 더 갚을 것이 없는 만기 풀은 null로 해석되고 같은 쓰기로 지워진다. 그래서 목록이 옮겨졌다. 서버의 값은 언제나 통과하고, 침묵이 의미를 갖는 lifecycle 셋에서는 추정치를 거부한다. 그 이유를 붙여 이름 붙였다. 인라인 배열로 두면 "만기 풀에는 날짜가 없다"로 읽히고 다시 필터로 바뀌기 때문이다.

포트폴리오의 Next payout 행에는 게이트가 하나 더 있었다. state !== 'FREE'이고, 포지션이 만기되면 앞으로의 수익이 없다는 주석이 붙어 있었다. 만기 뒤 상환 홀더는 정확히 FREE에 착지하는 사람이므로, 그 행이 그것을 다루는 유일한 홀더에게서 숨겨져 있었다. 매퍼와 한 커밋으로 냈다. 매퍼만으로는 화면에서 아무것도 바뀌지 않았을 것이다.

문서: 14-decisions v3-143 · v3-147. 마이그레이션 없음. 출처: JY, 2026-08-25.

📅 2026-08-26 — 만기는 상환 풀이 갚기 시작하는 때인데 투자자 앱의 열두 곳이 그것을 끝이라고 불렀다 (v3-148)

🔨 만듦 · apps/web · 프런트엔드만

투자자 앱에는 다른 질문에 대한 판정 하나가 있었다. LOCKED / EARLY / FREE는 "이 홀더가 나갈 수 있는가"에 답하는데, 여덟 표면이 FREE를 "지금, 전액"으로 읽었다. 풀 형태 넷 중 하나에만 참이다. 그래서 이것은 문구 작업이 아니다. 판정이 이제 나갈 수 있는지와 함께 보유분이 어떻게 빠져나가는지를 반환하고, 제품이 이미 가진 축들에서 유도하며 아무 데도 저장하지 않는다. 그것 없이 문장 열둘만 고쳤다면 다음 풀 형태가 다시 그것들을 깨뜨렸을 것이다.

🔴 주기 배너가 주기를 읽고 만기는 절대 읽지 않았다. 그래서 만기 뒤에 갚는 풀에서 로그인하지 않은 방문자에게 "지금 요청할 수 있습니다. 이번 주기는 9월 17일에 닫힙니다"라고 말했다. requestRedemption이 모든 호출에서 revert하는 풀에 대해서다. 배너를 숨기지는 않는다. 방문자가 그 풀이 어떻게 갚는지를 배우는 유일한 자리이기 때문이다. 그래서 거짓인 절반이 나가고 설명이 남으며, 홀더 패널도 렌더하는 문자열 하나에서 나온다. 그 패널은 행동할 것이 없는 상태를 얻는다. 버튼이 단순히 비활성인 게 아니라 할 일이 없다.

🔴 "락업 없음, 언제든 상환"이 세 곳에 있었다. 락업 0은 게이트 하나가 없다는 것만 말한다. 리스크 공시도 락업이 있을 때 렌더돼서, 만기까지 모든 요청을 거부하는 풀에는 상환 공시가 아예 없었다.

"Term complete", 회색 처리된 "Final yield", "Matured · N days left", "포지션 전체를 한 번에 상환합니다" — 각각을 그것이 참인 풀 형태에는 남기고 아닌 형태에는 대체했다. 히어로 지표는 재는 대상을 바꾼다. "남은 일수"는 갚고 있는 풀이 답을 가진 질문이 아니다. Repayment in progressMATURED 배지 옆에 앉는다. 그 배지만으로는 끝난 것으로 읽힌다.

⚠️ 의도적으로 하지 않은 것 둘. 주기 수가 투자자 API에 없으므로 "4주기 중 3주기 남음"을 원하는 두 곳은 "In progress"라고 말하고 숫자를 지어내지 않는다. 그리고 pool-header-cardend_date를 향해 카운트다운하고 있었다. 배포된 만기가 아니라 위저드의 추정치다. 하루 전 invest sidebar에서 고친 것과 같은 결함이고 같은 maturity_date ?? end_date 폴백에 같은 TODO(BE)가 붙었다.

🔴 리뷰가 이 변경 자체에서 결함 넷을 찾았고 전부 같은 모양이다. lifecycle 플래그를 경로 판정으로 재사용한 것이다. requiresFullRedemptionMATURED뿐 아니라 WIND_DOWN도 덮는데 거기서는 주기 계획이 치워진다. 그래서 "잔량은 다음 주기로 이월됩니다"가 절대 오지 않을 수 있는 주기를 약속했다. 그리고 positionState가 여전히 만기 게이트를 두 번째로 유도했고 첫 번째보다 좁았다. 만기 날짜가 필요했으므로 날짜가 설정된 적 없는 FIXED_MATURITY 풀이 "주기로 정산됨"으로 읽혔다. 그 자신의 주석이 그 경우를 도달 불가로 기록해 뒀다.

⚠️ 매퍼가 검증하지 않는 값에 대한 가드 없는 .toISOString() 하나. redemption.maturityDate는 검사 없는 new Date(row.maturity_date)이므로 나쁜 컬럼이 Invalid Date로 도착하고 .toISOString()이 던진다. 렌더 경로에서, 라벨 하나 때문에 페이지 전체가 비워진다. 독자 쪽에서 가드했다. 매퍼는 별도 라운드다. 거기서 null로 만들면 모든 화면에서 한꺼번에 풀이 "만기됨"에서 "만기 없음"으로 옮겨 가기 때문이다.

문서: 14-decisions v3-148. 마이그레이션 없음. 출처: JY, 2026-08-25.

📅 2026-08-26 — 이체가 적립되는 주기는 정산 중인 주기인데 어드민 화면은 다른 쪽을 이름 대고 있었다 (v3-91)

🔨 만듦 · apps/admin-web · 프런트엔드만

주기 풀은 커서 둘을 갖는다. acceptingEpochId는 요청 마감에서 돌고 currentEpochId는 정산이 돌 때 돈다. 마감마다 갈라지고 정산마다 다시 만나므로 주기마다 정확히 recall_lead_days 동안 어긋난다. 그리고 그 구간이 펀드 매니저가 행동할 수 있는 유일한 창이다.

🔴 모든 화면이 한 행 안에서 둘을 섞었다. 금액은 정산 중인 주기에서 왔고(컨트랙트가 epochFundTopUpcurrentEpochId에 적립한다, v3-91) 주기 번호와 기한, 마감은 접수 중인 주기에서 왔다. 펀드 카드에서는 그것이 8월 30일에 풀이 주기 1로 빚진 돈 위에 Cycle 2 · due Sep 27로 읽혔다. 주기가 하나 높고 28일 늦다. 늦게 도착하면 주기가 모자라게 정산되고 부족분이 이월되지 않는 풀에서다.

🔴 근본 원인은 한 줄이었다. AWAITING_FUNDINGnow >= requestCutoffAt이었고 그 마감은 접수 중인 주기의 것인데, acceptingEpochAt이 그것을 구조적으로 미래에 유지한다. 그래서 그 단계가 사실상 발동한 적이 없고, 그것에 대해 이미 올바르게 쓰인 유도 셋이 죽어 있었다. 정산 노드의 금액, 사이드의 커서 전환, 연체 배지다. accepting !== settlingacceptingEpochAt의 항등식이고 두 값 다 이미 요약에 있었다. 새 체인 읽기가 없다.

settlingCycleLabel을 API 경계에서 한 번 정규화한다.currentEpochId는 첫 상환 요청이 그것을 움직이기 전까지 0인데 epochFundingDate(0)은 이미 주기 1의 날짜로 답한다. 하나 어긋남이 서명 대화상자까지 "Fund Cycle 0"으로 도달했다. 하나가 아니라 필드 둘인 이유는 날 0이 여전히 쓰기 가드가 검사하는 사실이기 때문이다. Postpone settlement가 창이 열렸는지로 게이팅돼 있었는데 그건 시계에 대한 사실이라, 한 번도 정산한 적 없는 풀에서 백엔드가 409로 답하는 제어를 제공했다.

날짜 우선순위, 네 곳. pools.next_funding_date는 접수 중인 주기만 미러링하는데 nextFundingDate ?? settling이 정산 주기를 이름 대는 행에서 그것이 이기게 했다. dev에서 올바르게 읽히는 이유는 모든 미러가 아직 NULL이기 때문이다. Confirm 한 번이면 틀려지므로 화면을 보는 대신 테스트로 못 박았다.

Fund 대화상자가 그 수치가 확정된 것인지를 말한다. 요청은 마감까지 계속 들어오고 주기는 생애 대부분을 그 창 안에서 보내므로, "화면에 있는 것이 빚진 것이다"는 참일 때보다 거짓일 때가 많다. 그리고 정산이 쓰지 않은 것은 그것이 보내진 주기에 남는다. ⚠️ 닫힌 분기에는 날짜가 없다. 정산 주기의 마감은 요약에 없고, 여기서 펀딩일로 그것을 유도하면 D3가 한 곳에 두는 산술의 네 번째 사본이 된다.

⚠️ 사이드의 DemandOpen requests · all cycles로 라벨이 바뀐다. redemption_epoch_summarydemand_lp에는 회차 필터가 없으므로, 이제 한 주기를 이름 대는 제목 아래 풀 전체 장부가 놓인다. 숫자는 그대로다. RPC 필터는 백엔드 몫이다.

문서: 14-decisions v3-91. 마이그레이션 없음. 출처: JY, 2026-08-24.

📅 2026-08-25 — 만기 뒤 쿠폰이 상환 회차를 따라가고, 컨트랙트의 약속 계층이 첫 라이터를 얻는다 (v3-147)

🔨 만들었고 배포 대기 — sweep이 IAM 부여를 얻으므로 CDK 배포 전까지 체인에 아무것도 공표되지 않는다

합의. 만기 뒤 여러 회차에 걸쳐 갚는 풀에서 수익 지급과 원금 상환은 같은 날 떨어진다. 트랜잭션은 둘이다. 체인에 그것을 위한 함수가 둘 있고 둘을 함께 하는 세 번째가 없기 때문이다. 다만 날짜는 하나다.

🔴 둘 중 하나만 고정돼 있으면 두 사다리가 한 날짜에 머물 수 없다. 회차의 날짜는 미리 정해지고 펀딩이 늦어도 움직이지 않는다. 수익 날짜는 성과에서 유도됐다. 마지막으로 지급한 기간의 끝 + 주기 하나다. 10일 대신 20일에 한 번 지급하면 이후 모든 쿠폰이 함께 옮겨 가고 회차는 제자리에 남는다. 합의는 첫 지연에서 깨지고 깨진 채로 남았다.

이제: 만기 전에는 아무것도 바뀌지 않는다. 붙을 회차 사다리가 없다. 만기 뒤에는 수익 기간 n이 회차 n의 지급일에 도래한다. 유도가 아니라 저장된 값이므로 늦은 지급이 다음 것을 옮기는 대신 늦은 것으로 보인다.

마이그레이션 없음. redemption_epochs.funding_date가 이미 주기마다 받은 날짜를 담고 있고 그 주석이 다른 것을 거기 쓰는 것을 금지한다. 만기 뒤 쿠폰 날짜가 바로 그 날짜다. ⚠️ 그 NULL이 무게를 진다. 체인이 그 날짜를 받은 적이 없고 유도하고 있다는 뜻이다. 첫 달에는 사흘 이르고 넷째 달에는 여드레 이르다. 그래서 쓰이지 않은 주기는 읽지 않고 뺀다.

🔴 순서: 만기 상한이 먼저, 계획이 두 번째다. 기간이 돌고 있을 때 도래한 기간은 늦은 것이고, 그것을 계획에 넘기면 회차 날짜로 옮겨 가 조용히 연체가 아니게 된다. 계획은 상한이 일정을 끝내던 지점에서 넘겨받는다.

🔴 setYieldDueDate에는 호출자가 0이었다. v3-143 ③이 컨트랙트에 선형 예측 옆의 저장된 약속 계층을 줬는데 오프체인의 무엇도 거기에 쓴 적이 없다. 그래서 풀이 제품이 약속하지 않은 날짜를 공표했고 settleYield가 그 계층을 스스로 28일씩 전진시켰다. 이제 매일 도는 수익 due sweep이 그것을 쓰고, 그것이 sweep을 서명 함수로 만든다. ORACLE_ROLE에 자기 실행 role을 더한 것이다. 공유 role에 서명 함수를 두면 옆의 모든 스케줄러에 kms:Sign을 쥐여 준다. 어느 기간을 쓸지는 체인에서 읽고(yieldPeriodsSettled + 1) 절대 유도하지 않는다. 그 인덱스는 회차가 아니라 앵커부터 센 수익 기간이고, 쿠폰이 처음 누락되는 순간 오프셋이 움직인다.

구조적으로 치명적이지 않다. yieldDueDate는 체인에서 아무것도 게이팅하지 않으므로 동기화가 실패해도 낡은 공지가 남을 뿐 돈은 건드리지 않는다. 🔴 다만 읽기 실패는 절대 "날짜 없음"으로 넘기지 않는다. 그것은 "일정이 끝났다"로 해석되고 쓰이는데, 그러면 모든 상환 풀의 next_yield_due가 한꺼번에 지워진다.

금액 둘과 그것을 보여 주는 화면 둘. GET /pools/{id}/repayment-cycles가 회차마다 원금 축과 수익 축으로 답한다. 각각 자기 상태와 보여 줄 수치 하나, 그것이 추정인지를 담는다. 합계는 없다. 한쪽이 미결인 동안에는 거짓이고 모든 회차가 그 상태를 지난다. 어드민 상환 카드는 두 행짜리 열을 얻고 FM의 회차 블록은 펀딩 동작 둘을 순서 없이 나란히 둔다.

🔴 redemption_epochs.total_demand_lp은 쓰인 적이 없었으므로(정의 하나, 쓰기 0회) 원금 수치는 체인에서 온다. 날짜는 여전히 그러지 않는다. 거기서는 유도된 날짜와 받은 날짜를 구분할 수 없다. 그리고 금액 읽기는 받은 날짜가 있는 회차에 대해서만 한다. 🔴 epochFundTopUp은 정산 전에는 funded_usd로, 정산 후에는 surplus_usd로 보고한다. 정산이 그것을 먼저 차감하므로 남은 것을 기여로 읽으면 다 갚은 회차를 미펀딩이라고 부르게 된다.

🔴 같은 규칙의 구멍이 기간의 반대편에도 있었다. derivePeriod가 여전히 주기를 걸었고 period_end가 다음 앵커가 된다. 그래서 월 단위 주기에 대한 28일 계획에서는 앵커가 다음 회차를 지나 떨어졌고 그 회차의 쿠폰이 영영 도래하지 않았다. 이제 규칙 하나를 공유한다. 함께 발견한 것: 격자의 폴백 시작이 모집이 열린 날이라 풀의 첫 쿠폰이 자기 모집 기간을 덮고 있었다.

투자자 화면은 홀더 자신의 금액을 쓴다(JY, 2026-08-26). 그리고 두 행은 같은 종류의 진술이 아니다. 🔴 수익은 홀더별 사실일 수 있다(yield_distribution_investors가 컨트랙트가 홀더마다 적립해 준 값을 정산 전후의 claimableYield에서 기록한다). 원금에는 누구에게도 회차별 수치가 없다. claimRedemption 한 번이 홀더가 마지막으로 청구한 뒤의 모든 회차를 정산하므로, RedemptionClaimed가 회차 id를 담지 않는 것은 누락이 아니다. 정산 단위가 회차가 아니라 청구이고, 그것을 오프체인에서 쪼개는 것은 돈에 대한 2단 사다리를 다시 유도하는 일이다.

그래서 원금 행은 상태를 말하고 그게 더 쓸모 있다. 만기 후 상환은 requestRedemption을 거치므로 한 번도 요청하지 않은 홀더는 기약 없이 아무것도 상환받지 못하는데 아무도 그것을 말해 주지 않는다. 그 행이 상환 동작을 담는다. 정확히 한 회차, 날짜를 가진 것 중 가장 이른 미정산 회차에서다.

문서: 14-decisions v3-147. 마이그레이션 없음. 출처: JY, 2026-08-24.

📅 2026-08-25 — 셀 것이 없는 락업, 그리고 폼이 여전히 그 밑에서 빼낼 수 있게 두던 날짜 (v3-146)

🔨 컨트랙트 배포됨, 오프체인 만듦 — FACTORY_ADDRESS_84532가 바뀌기 전까지 살아 있지 않다

v3-144의 컨트랙트 절반이 나갔다. subscriptionEndDateRedemptionConfig에 살고, isLockupActive가 그것부터 세며 investor 인자를 뺐고, PoolConfigLib.validateTerms가 조건 셋을 한 집합으로 판정한다. initialize에서 한 번, 그리고 모든 setter에서다. 🔴 그것은 자르지 않고 거부한다. 앞선 개정판은 해제 시점을 만기로 잘랐는데, 그러면 풀이 배포된 뒤 공표한 것보다 적은 락업을 강제하게 된다.

🔴 폼이 여전히 옮길 수 있게 두던 날짜. subscription_end_date가 살아 있는 풀에서 수정 가능했고 근거는 그것이 "어떤 컨트랙트에도 닿지 않는다"였다. 이번 라운드 전까지는 참이었다. pool-wide 풀에서는 체인이 자기 사본을 갖고 있고 수정을 되돌려 쓰는 것이 없으며 풀에 입금이 생기면 컨트랙트가 거부한다. 그래서 수정은 우리 쪽 절반만 영구히 옮겼고, 방향은 되돌리는 쪽이다. 날짜를 지우면 lockupAnchorMs가 null로 답하고 백엔드가 "락업 없음"으로 읽어 컨트랙트가 거부하는 상환을 받아들인다. 이제 수정 폼과 PATCH /pools/{id}에서 공유 규칙 하나로 잠긴다. 그리고 체인이 그것을 읽는 곳에서만이므로 락업 없는 풀과 모든 투자자별 풀은 그 필드를 유지한다.

🔴 개방형 풀은 락업을 갖지 않는다(JY). 모집 창이 선택이므로 pool-wide 락업이 셀 것이 없다. 형태 전체가 "예정된 끝 없음"인 풀에게 마감을 요구하는 대신 그 조합을 없앤다. 그러면 개방형 풀에는 출구 게이트가 아예 없다. 패널티는 무엇에 대해 조기인지가 있어야 한다. 그리고 그것이 dev의 모든 개방형 풀이 이미 도는 방식이다. 최소 보유 기간은 포지션별 라운드를 기다린다.

두 거부 다 앵커의 속성으로 적었다. 그래서 만기가 언젠가 포지션별이 되면 v3-144의 reopen 금지와 함께 스스로 풀린다. lockupWithoutAnchorReason은 은퇴한다. 발동할 수 없는 분기에 앉아 있었고 그것이 지키던 상태는 이제 만들 수 없다.

⚠️ 가는 길에 발견: maturity_model이 PATCH의 필드 허용 목록에 없었다. 모르는 키는 버려지므로 수정 위저드의 maturity model 선택이 DRAFT에서 아무것도 저장하지 않았고 오류도 없었다. 이제 진짜 B 부류 필드이고, 그 enum과 maturity_days와의 짝짓기가 validatePoolConfig로 옮겨 생성과 수정이 같게 판정한다.

문서: 14-decisions v3-146. 마이그레이션 없음. 출처: JY, 2026-08-25. 컨트랙트는 CH(Base Sepolia 2026-08-25 라운드, apps/contract/sepolia.md).

📅 2026-08-24 — 기간이 누군가 Deploy를 누를 때 시작하고 있었다 (v3-145)

🔨 만들었고 배포 대기 — 오프체인만이고 컨트랙트 변경 없음

pools.worker.deploymaturityDate = nowSeconds + maturityDays를 계산했다. 그래서 초안의 만기가 배포되지 않고 앉아 있던 만큼 밀렸고 다른 모든 날짜는 제자리에 있었으며, 투자자에게 닿은 절반은 기간이 풀이 모집을 마치기 전에 시작했다는 것이다. 모집 마지막 날에 들어온 사람은 이미 그 창 전체 동안 돌고 있던 기간에 대해 재어졌고 더 짧게 보유하고도 온전한 기간의 쿠폰을 받았다. 07-redemption이 그것을 알려진 결함으로 적어 뒀고 결정된 해법은 컨트랙트 변경이 필요해 만들어진 적이 없다.

이제 subscription_end_date + maturity_days다. maturityDate는 백엔드가 계산해 setMaturityDate로 보내는 절대 타임스탬프이므로 이것은 표현식 하나이고 ABI도, 컨트랙트 팀이 할 일도 없다. 🔴 그리고 만기를 포지션별로 만들지 않고 늦은 투자자 결함을 고친다. 만기가 홀더별이 된 게 아니라 일찍 시작하기를 그친 것이다.

가드 셋이 은퇴하고 하나가 새로 생긴다. W9 상한(모집 마감 ≤ 만기 − 수익 기간 1회분)과 maxOfferingDays·minEffectiveDays, "늦은 투자자는 N번만 받습니다" 경고가 전부 모집이 기간의 앞부분에서 잘려 나갔기 때문에 있었다. 이제 잘려 나가는 것이 없다. 늦은 투자자 락업 가드(D-7)도 함께 간다. 기간의 양 끝이 앵커를 공유하므로 그 조건이 위저드가 늘 검사해 온 것으로 붕괴한다. ⚠️ v3-144는 D-7이 절대 은퇴할 수 없다고 기록했다. 락업만 혼자 옮길 때는 맞았고 만기가 함께 옮긴 뒤로는 틀렸다.

🔴 새로 생긴 것: FIXED_TERM 풀에는 모집 마감이 필요하다. 빈 마감은 "기간이 끝날 때까지 모집"이라는 뜻이었는데 기간이 마감으로 정의되면 순환이 된다. 발행 게이트와 POST /pools/{id}/lifecycle, 배포 워커에서 거부한다. 워커가 중복이 아닌 이유는 재시도가 발행 검증을 건너뛰고 setMaturityDate는 값이 없으면 실패하는 게 아니라 그냥 안 보내지므로, 체인이 만기가 오지 않는다고 읽는 풀이 남기 때문이다.

⚠️ 새 배포에만 해당한다. 이미 체인에 올라간 풀은 받은 만기를 유지하고 그 end_date는 여전히 옛 시작일 기준 추정치를 담고 있으며 Configuration 탭이 그것을 읽는다. DeployDateReview가 이제 보고하던 어긋남 대신 그 낡음을 이름 댄다.

📅 2026-08-21 — 락업이 모집 마감으로 옮겨 가고, 풀이 clone이라 두 앵커가 함께 살아 있다 (v3-144)

🔨 오프체인 만듦, 컨트랙트는 CH에게 인계

만기는 pool-wide이고 락업은 홀더별이었으므로 홀더별 경계가 pool-wide 경계를 앞지를 수 있었다. 모집 20일, 락업 15일, 만기 30일은 모든 가드를 통과하고 마지막 날 투자자를 만기 5일 뒤까지 잠긴 채로 남긴다. 풀이 모두에게 열리는 순간에 혼자서다. 이제 락업은 아무도 그 전에 투자되지 않는 풀의 모집 마감부터 센다.

🔴 이것은 마이그레이션이 아니고 전환일도 없다. 풀은 생성 시 구현체에 고정된 Clones 프록시이므로 존재하는 모든 풀이 투자자별 규칙을 영원히 유지하고 투자자 앱이 둘 다에 답해야 한다. lockupAnchorMs(@aset/types)가 판정하는 유일한 곳이고 POST /redemption-requests와 공유하므로 버튼과 게이트가 어긋날 수 없다.

모르는 경우는 pool-wide로 떨어진다. 안전한 방향이 하나뿐이기 때문이다. close + lockup ≥ investedAt + lockup이 언제나 성립하므로 pool-wide로 추측하면 해제일이 늦게 보이고(보수적이고 아무것도 실패할 수 없다) 투자자별로 추측하면 컨트랙트가 거부하는 상환을 제안하게 된다. 그래서 목록은 투자자별인 구현체를 담고, 구현체가 기록되지 않은 풀은 그중 하나다(0205보다 앞선다).

pool-wide 풀에서 lockup_days > 0이면 reopen이 거부된다. 옛 마감을 유지하면 새 투자자에게 이미 지나간 락업이 붙고, 옮기면 락업을 벗어난 홀더가 다시 잠긴다. 거부 사유는 reopen이 아니라 앵커를 이름 댄다. 그래야 만기가 언젠가 투자자별이 되면 녹아 없어진다.

🔴 가는 길에 발견한 결함 둘. create-pool.ts가 여전히 poolImplementation()을 호출했는데 이번 라운드 재배포에서 poolImplementationFixed()로 이름이 바뀌었다. 옛 이름은 revert하고 이 읽기는 치명적이지 않으며 canReopenOffering(null)은 없는 세대를 너무 오래된 것으로 읽는다. 그래서 이름 변경 뒤 배포된 모든 풀이 재개방할 수 없다는 말을 듣고 있었다. 그리고 invest-sidebargetRedemptionState를 거치지 않고 Date.now()로 자기 해제일을 계산해서 이 중 아무것도 따라가지 못했을 것이다.

위저드의 만기 초과 가드는 이 이동으로 은퇴하지 않는다. 노트의 앞선 개정판은 은퇴한다고 적었다. 조건은 어느 쪽이든 lockup > 만기 − 모집이다. 바뀌는 것은 그것이 늦은 홀더 하나를 서술하기를 그치고 전원을 서술하기 시작한다는 점이다.

📅 2026-08-21 — 버튼은 4일부터 거기 있었고 체인은 매번 그것을 거부했다 (v3-142)

✅ 반영됨 — Base Sepolia에 새 구현체와 팩토리, 그리고 W9가 끝에서 끝까지 배포됨

POST /pools/{id}/closeaction: reopen을 주면 setLifecycleStatus(ACTIVE)를 보내는데, LifecyclePolicy.isValidTransitionCLOSED → ACTIVE한 번도 받아들인 적이 없다. v3-141의 라운드 전에는 return false로 떨어졌고, 그 뒤에는 새 CLOSED 분기가 to == MATURED만 반환했다. 배포된 풀에 대한 모든 재개방이 revert했고 핸들러는 502를 냈다.

🔴 표면 넷이 아무도 확인하지 않은 기능을 주장하고 있었다. 어드민 풀 페이지가 Reopen 버튼을 렌더했고, 마감 확인 문구는 *"만기가 오기 전에는 다시 열 수 있습니다"*라고 말했으며, 10-status-machinesCLOSED → ACTIVE 전이 행에 **✅**를 달고 있었고, pools.post.close는 자기 주석에서 마감을 *"종착 사실이 아니라 시장 상황에 대한 판단"*이라고 불렀다. 엔드포인트가 존재하므로 그 약속이 검증된 것처럼 읽혔다. CLAUDE.md 규칙 2-b이고, 확인해야 할 것이 다른 저장소 디렉터리의 함수 하나인 바로 그 자리에서다.

CLOSED에 이제 출구가 둘이다. 기간이 끝나면 MATURED, 모집을 다시 열면 ACTIVE다(3edf75b). ACTIVE ↔ CLOSED는 의도된 순환이다. 없애야 했던 것은 CLOSED ↔ MATURED이고 v3-141의 라운드가 죽은 MATURED → CLOSED 간선을 제거해 닫았다. MATURED는 여전히 어드민 setter가 끝나는 곳이다. LifecyclePolicy는 internal이라 인라인된다. ABI도 selector도 topic0도 바뀌지 않고 오프체인도 움직이지 않았다.

배포가 한 번 더 들었다. 결정이 b619d9a가 쓰인 뒤에 나와서 08-20 팩토리는 전이 셋 중 둘을 담았다. poolImplementationimmutable이고 풀은 Clones이므로 살아 있는 구현체에 간선을 더할 수 없다. 새 구현체 0x405E89Ec…, 새 팩토리 0x206fC348…, FACTORY_ADDRESS_84532, API 재배포다. 즉시 하는 것이 나았던 이유는 08-20 팩토리로 만들어진 풀이 아직 없어서 간선 없이 굳은 것이 없었기 때문이다. ⚠️ 배포가 있었는지가 아니라 env 을 확인할 것. 그날 W9의 컬럼 때문에 API 스택도 다시 배포됐고, 그것이 먼저 나갔다면 여전히 옛 팩토리를 가리킨다.

🔴 재개방은 미래의 모집 마감을 유지하고 대화상자가 이제 어느 날짜인지 말한다. subscription_end_date과거일 때만 지운다. 아니면 3번 패스가 한 시간 안에 풀을 다시 닫고 운영자를 아무 설명 없이 되돌린다. 미래 날짜는 의도적으로 유지한다. 그 풀은 손으로 일찍 닫혔고 앞에 계획된 마감이 있다. 옛 확인 문구는 그중 아무것도 말하지 않았고, 그래서 설계대로 정확히 작동한 재개방이 먹히지 않은 것처럼 읽혔다.

⚠️ Clones 컷오프는 v3-141의 것보다 세게 문다. 그쪽은 subscription_end_date를 가진 풀에만 닿았다. 어떤 풀이든 손으로 닫을 수 있으므로 이쪽은 어디서나 닿는다. 그리고 어떤 풀이 어느 구현체에서 왔는지를 오프체인에 기록하는 것이 없으므로 어떤 화면도 세대를 구분하지 못한다. 체인에서 감사했다. dev에 배포된 풀 아홉 중 재개방할 수 있는 것은 없고 일곱은 08-18 라운드보다도 앞서며 둘은 테스트넷 USDC를 $72,000과 $48,800씩 담고 있는데, 그것이 "모든 dev 풀은 $50 미만의 QA 풀"이라는 옛 설명을 은퇴시킨다. 해결은 운영 쪽이고(dev 풀을 현재 팩토리로 다시 만든다, JY) 풀별 세대 컬럼은 미뤘고 prod 전에는 필요해진다.

풀이 이제 자기가 어느 컨트랙트 세대인지 기록한다(0205). 아무것도 그러지 않았고, 답을 얻는 유일한 방법은 각 프록시 바이트코드에서 363d3d373d3d3d363d73 뒤의 20바이트를 읽는 것이었다. 화면이 렌더하면서 할 수 있는 일이 아니다. pools.worker.deploy가 이제 factory.poolImplementation()에서 pool_implementation을 쓰고 그 옆에 pool_factory도 쓴다. 어드민 재개방 제어는 풀의 구현체가 그것을 할 수 없으면 버튼 자리에 사유를 보여 주고, pools.post.close는 체인이 revert해 502가 되게 두지 않고 409로 답한다. 🔴 규칙은 할 수 있는 구현체의 허용 목록이 아니라 할 수 없는 구현체의 닫힌 역사 목록이다. 허용 목록은 재배포마다 덧붙여야 하고 누군가 처음 잊는 순간 그 기능이 조용히 회수된다. 기록할 만한 뒤집기: 이것은 아침에 미뤘다고 적혔다. dev 풀을 현재 팩토리로 다시 만들 것이라는 논리였다. dev 풀은 그대로 두기로 했으므로 세대가 공존하고 그것들을 구분할 수 있는 유일한 것이 이 컬럼이다. 오래된 풀을 재개방 가능하게 만들지는 않는다. 무엇도 못 한다.

발행된 풀에서 시작일과 만기를 더 이상 비울 수 없다(JY). 날짜 입력을 지우면 ''가 보내졌고 PostgreSQL이 DATE 컬럼에 대해 그것을 거부해 핸들러가 500을 냈다. 이미 불가능하던 쓰기가 아무것도 이름 대지 않는 방식으로 실패한 것이다. 이제 둘 다 Configuration 탭에서 이름과 함께 거부되고 pools.patch.update에서 400이 난다. 작동하게 만드는 대신 계속 불가능하게 둔다. end_date를 지우는 것은 "만기 없음"이 아니고(resolveMaturityAtstart_date + maturity_days로 떨어지므로 기간의 주인이 바뀌고, maturity_days가 없으면 풀이 MATURED로 쓸려 갈 수 없다) start_date를 지우면 활성화 sweep이 도는 키와 수익 일정이 세는 앵커가 사라진다. subscription_end_date는 계속 비울 수 있다. NULL이 설계된 값이기 때문이다.

W9가 이제 끝에서 끝까지 배포됐다. 0204가 schema_migrations에 기록됐고, GET /poolssubscription_end_date를 반환하며, admin-web이 나갔고, 컨트랙트 두 갈래가 온체인에 있다. 이번 라운드에 함께: 모집 기간 필드가 아무도 입력하기 전에 오류를 보여 주기를 그치고, 타임라인이 스크롤을 따라다니기를 그치며, 왼쪽 날짜 라벨 둘이 서로 겹쳐 찍히기를 그친다(짧은 모집은 예외가 아니라 평범한 경우다). 그리고 발행과 재시도 배포 경로가 이제 날짜를 다시 읽고 낡은 모집 마감을 거부한다. 만기는 배포 순간에 유도되고 모집 마감은 절대 날짜이므로, 충분히 오래 붙들린 초안은 마감 sweep이 입금 하나 들어오기 전에 닫아 버릴 풀을 배포한다.

📅 2026-08-21 — 에스크로된 LP는 벌고, 아니라고 말하던 문서가 낡은 쪽이었으며, 발생 분모에 청산 항이 적혀 있었다 (v3-143)

✅ 반영됨 — 컨트랙트는 2026-08-20 빌드, 2026-08-21 온체인. 마이그레이션 0206~0208

v3-131 (3)이 코드에 있다. FixedYieldEngineYieldAccrualPolicy가 지분당 누적기를 대체한다. 수익이 totalLpSupply − poolHeldLp에 대해 시간으로 발생하고, 에스크로된 홀더도 계속 번다. 그 몫은 회차 사다리(epochGy)에서 요청별로 발생하고 에스크로가 끝날 때 채무와 채권이 한 순간에 인식된다. previewEscrowAccrual(requestId)가 열려 있는 동안의 지연이다. ⚠️ 새 풀에만 해당한다(poolImplementationFixedimmutable이고 풀은 Clones다). 구현체 0x7C21153E…와 팩토리 0xE8453DAc…2026-08-21 Base Sepolia에서 확인했고 배포된 바이트코드가 이 브랜치의 소스를 바이트 단위로 재현한다. 다만 poolCounter()가 0이므로 발생이 굴러간 적도, 에스크로가 온체인에서 가격 매겨진 적도 없다(동작은 forge test 766/766에 기대고 있다). 그리고 존재하는 모든 풀은 더 오래된 구현체에 고정돼 있다.

🔴 상태 칸이 산문과 반대로 말했고 산문이 맞았다. 다섯 곳이 여전히 항목 (3)을 미구현으로 담고 있었다. 07-redemption은 무엇이 바뀌어야 하는가 표에서 "미정, 미구현"이라고 했고 절 서문에서도 "여전히 미정이고 미구현"이라고 했다. 14-decisions🔴 MVP로, 16-timeline○ 계획됨 아래 "마지막 MVP 항목"으로, 10-status-machines는 "아래 흐름이 현재다"라는 요약을 "수익은 요청 시점에 멈춘다"로 닫았다. 미정이 그중 최악이었다. 결정도 됐 만들어졌다. 그동안 07-redemption06-writedown-nav, 26-glossary, 그리고 이 문서가 반영된 규칙을 올바르게 서술하고 있었다. ⚠️ 낡은 칸을 보고 쓰인 핸드오프가 반대 결론에 이르렀고 올바른 투자자 문구를 제거했을 것이다. 움직인 것은 칸이다.

🔴 그리고 발생 분모가 청산 항으로 찍혀 있었다. 분모 셋 표가 수익 발생을 totalLpSupply − settledUnclaimedLp로 적었다. 코드는 totalLpSupply − poolHeldLp를 쓴다(FixedYieldEngine.accrualBasis). settledUnclaimedLpR10, 즉 청산의 것이고 발생 기준은 그것을 쓴 적이 없다. 따로 논증된 규칙 둘이 한 항으로 접힌 것이고, 그 표 위의 줄이 경고하는 바로 그 실패다. 23-money-path05-investment-lifecycle에서도 정정했다. 05의 onLpTransfer 설명이 잠금이 *"발생 기준의 어느 쪽도 움직이지 않는다"*고 적었는데 그건 settledUnclaimedLp 산식을 말로 풀어 쓴 것이다. 그리고 v3-131 자신의 본문에서도 고쳤다. 같은 치환에 거기서 따라 나온 주장 둘이 붙어 있었다.

⚠️ 분모 셋이고 이제 셋이 다 다르다. 평시 NAV는 아무것도 제외하지 않고, 수익 발생은 풀이 보유한 LP 전부를 제외하며, 청산은 정산 미청구 몫만 제외한다. 옛 설명은 그중 둘이 같은 집합을 제외한다고 적었다. 쓰일 때는 참이었고 이번 라운드 뒤로는 거짓이다.

⚠️ accrualBasisLp가 0으로 읽힐 수 있고 그것은 옛 결함이 돌아온 게 아니다. 만기 후 회차 풀에서는 창이 열리는 순간 모든 지분이 에스크로되므로 0이 평범한 경우다. 옛 규칙에서는 그때 그 창의 이자가 아무에게도 속하지 않고 unclaimedYield에 쌓였다. 이제 0은 아직 아무것도 인식되지 않았다이고 그 창은 여전히 그 안에 있는 홀더들에게 빚진 것이다. test_Repro_Deal_FourCycleWindow_InterestIsOwedToTheHolders가 못 박고, testFuzz_escrowNeverShrinksTheTotalBill이 파트너가 빚지는 총액을 에스크로를 가로질러 평평하게 유지하며, test_EscrowEarnsTheSameAsStayingIn이 에스크로하는 것과 그대로 있는 것을 4 wei 이내로 맞춘다.

오프체인: accumulatedYieldPerShareyieldDebt 은퇴(0206), 컨트랙트에 setter가 없으므로 생성 전용으로 추가한 pools.accrual_rate_bps(0207), exit window 앵커(0208)다. POST /pools가 요율을 요구하고 GET /portfolio-positionsescrow_accrual을 반환하며 수익 due 행이 outstanding_net_liability를 담는다.

📅 2026-08-20 — 컬럼 하나가 질문 둘에 답하고 있었고, 두 답이 같은 숫자여서 들키지 않았다 (v3-141)

🔨 만들었고 배포되지 않음 — 0204는 dev에 손으로 적용됐고, 한 갈래는 아무도 하지 않은 컨트랙트 변경이 필요하다

pools.end_datepools.scheduler.lifecycle모집이 끝났다로(ACTIVE → CLOSED) resolveMaturityAt기간이 끝났다로 읽었다. 두 해석이 같은 날을 가리켰다. 생성 위저드가 end_date = start_date + maturity_days로 계산했기 때문이다. 결과는 아무도 볼 수 없는 버그였다. 풀이 만기가 오는 날까지 입금을 받았고, 마지막 날 오후에 도착한 돈이 하루 투자되고 원금 상환 대기열에 합류했다. 어떤 화면도 틀리지 않았고 어떤 필드도 변호할 수 없는 값을 갖지 않았다.

🔴 뻔한 수리는 만기를 옮긴다. resolveMaturityAtend_datestart_date + maturity_days보다 먼저 읽으므로 발행됐지만 배포되지 않은 풀에서 그것을 줄이면 기간이 줄고, resolveNextYieldDue가 일정을 만기로 자르므로 의무가 어디에서도 실패하지 않고 날짜와 함께 사라진다. 그래서 새 컬럼 subscription_end_date(0204)를 두고 end_date는 뜻하던 것을 그대로 뜻하게 남긴다.

자동 마감 패스는 한 번도 돈 적이 없다. 그것은 만기 패스 뒤에 ACTIVE 풀을 다시 읽는데 둘이 같은 날짜를 비교했으므로 기간제 풀은 언제나 MATURED로 떠났고 마감 패스가 보기 전에 사라졌다. CLOSED에는 손으로만 갈 수 있었다. 그러니 새 컬럼으로 키를 잡는 것은 조정이 아니라 경로를 켜는 일이고, 그것이 그 아래 전제를 드러냈다. 🔴 만기 패스가 ACTIVE만 골랐으므로 일찍 닫힌 풀은 만기가 올 수 없었다. 무패널티 상환과 알림, 모든 배지가 달력이 아니라 MATURED를 키로 삼으므로, 그런 풀은 기간을 지나서까지 CLOSED에 앉아 상환이 영영 열리지 않았을 것이다. 필터는 이제 ACTIVE, CLOSED이고, subscription_end_date를 담은 CLOSED 풀로 좁혔다. 즉 마감 패스를 통해 거기 도달한 풀이다. 🔴 손으로 닫은 풀은 의도적으로 뺐다. 손으로 닫는 것은 되돌릴 수 있고(reopenACTIVE로 돌려준다) MATURED → ACTIVE는 어떤 전이 표에도 없으므로, 그것들을 쓸어 보내면 운영자가 오늘 가진 선택지를 조용히 없애게 된다. 즉시, 그리고 주소가 없는 풀에서는 온체인에 막는 것도 없이. 패스 순서는 그대로다. 시간상 모집 마감이 먼저 오기 때문이다.

그 뒤 절반에는 체인이 필요했고 b619d9a에서 얻었다. 그전까지 LifecyclePolicy.isValidTransitionCLOSED를 종착으로 다뤘고 transitionPools는 온체인부터 쓰므로 그 갈래는 옳고 무기력했다. 그 수정이 동시에 MATURED → CLOSED를 지운다. 체인은 그 간선을 허용했고 어떤 오프체인 경로도 쓰지 않았으므로, 남겼다면 CLOSED ↔ MATURED가 순환이 됐을 것이다. ⚠️ 풀은 Clones다. 새 구현체와 팩토리가 살아난 뒤에 만들어진 풀만 새 DAG를 얻고, PlatformPoolFactory.poolImplementationimmutable이므로 팩토리도 교체된다. 앞선 풀들은 CLOSED를 종착으로 유지하고 거기 도달할 수도 없다(subscription_end_date가 없다).

상한은 수익 지급 한 번이고 엔드포인트가 아니라 위저드에 산다. subscription_end_date <= 만기 − 수익 기간 1회분(월 30일 / 분기 90일)이 단계를 막고, 세 기간 너비의 더 느슨한 띠가 경고하며 마지막으로 들어온 투자자가 몇 번 받게 되는지를 이름 댄다. 🔴 회차 주기가 아니다. 수익은 달력 축에서 돌고 만기 후 상환은 28/84일 축에서 돈다. 그래서 문구가 "cycle"이라고 말하면 안 된다(테스트가 그것을 단언한다). 🔴 FIXED_MATURITY + 회차 풀에만 묻는다. 다른 모든 곳에서는 홀더가 요청으로 나갈 수 있거나 잴 만기가 없으므로, 상한을 두면 오늘 잘 되는 풀 생성이 막힌다. 다른 모든 형태는 컬럼을 NULL로 두고 sweep이 그것을 건너뛴다.

생성 위저드의 5단계가 Schedule & redemption이 됐다. 시작일이 6단계에서 나왔다. 거기서는 발행 버튼 옆에 앉아 있었고, 풀이 언제 열리는지 수익 주기가 언제부터 세기 시작하는지를 정하는 그 날짜가 그것에 의존하는 모든 것 뒤에 답해지고 있었다. 타임라인 바가 모집 / 수익만 / 상환을 축척대로 그리고 가장 짧은 실효 기간을 아래에 괄호로 적는다. 결함이 숫자로는 보이지 않았고 가운데가 빈 막대로는 놓칠 수 없기 때문이다. 락업은 의도적으로 그리지 않는다. 홀더마다 다른 날 시작한다. 🔴 4단계와 5단계를 바꾸면 안 된다. 상한이 4단계의 주기에서 계산된다.

🔴 풀 읽기 select 둘도 그 컬럼을 배워야 했고, 그 절반이 숨는 쪽이다. LIST_SELECTDETAIL_SELECT는 명시적 허용 목록이므로 빠진 컬럼이 null로 도착하고 모든 화면이 깨지는 대신 자신 있는 오답을 렌더한다. 풀 수정 폼이 이 필드를 그 값으로 채우고 다시 보내므로, 빈 값으로 채웠다면 다음 저장에서 저장된 날짜 위에 null을 썼을 것이다. ⚠️ 이 Lambda들을 올리기 전에 0204를 적용할 것. Supabase는 존재하지 않는 컬럼을 지목하는 select를 거부한다.

라벨로서의 "End date"가 사라졌다. 풀 수정 폼과 Configuration 탭, 풀 개요에서 Maturity로 바뀌고 그 옆에 Subscription close가 붙는다. 라벨이 결함의 대부분이었다. 아직 돈을 받고 있는 풀에서 "End date"를 읽은 운영자는 청약을 멈추고 싶은 날로 그것을 설정했고 대신 기간을 옮겼다.

🟡 상한은 끝이 정해진 임시방편이다. 만기가 풀 전체의 날짜 하나이기 때문에만 존재한다. 만기가 포지션별이 되면(D2) 늦은 진입이 그 홀더에게 아무 비용도 물리지 않고 보호할 것이 남지 않는다. features/pool-form/offering.ts가 맨 위에 그렇게 적고 이식 대신 삭제해 달라고 한다.

기존 풀은 백필하지 않는다(JY, 2026-08-20). end_date를 복사해 넣으면 모든 모집 마감이 만기일이 되는데 그것이 지금 풀고 있는 뒤섞임이고, 그 풀들이 한 번도 지나 본 적 없는 sweep 경로에 불을 켜게 된다. 배포된 풀에 대한 회귀 확인은 명시적으로 면제했다.

문서: 14-decisions v3-141 · 10-status-machines → pool lifecycle · 11-db-schema → pools. 마이그레이션 0204. 2026-08-20에 원래 번호 0194로 dev에 손으로 적용했고, ch/product가 0194~0203을 갖고 있는 것이 드러나 번호를 다시 매겼다. schema.sql도 같은 변경에서 갱신했다. 출처: JY(작업 지시 W9), 2026-08-20.

📅 2026-08-18 — 유일한 감시자를 끄고 그 자리에 아무도 세우지 않은 설정 (v3-140)

🔨 만들었고 배포되지 않음 — 마이그레이션 0192는 주석만이고 적용되지 않았다

pools.epoch_cycle_modeSEMI_AUTO("주기마다 지급일을 확정하라고 운영자에게 알린다")와 AUTO("알리지 않는다")를 제공했다. v3-135가 이미 그 리마인더를 선호가 아니라 상태의 결과로 재정의했고(유일한 질문은 이 주기의 날짜가 설정됐는가다) 그것이 이 컬럼을 아무것에도 답하지 않게 만들었다. 그것을 없애는 것이 단정한 절반이다.

🔴 단정하지 않은 절반은 AUTO가 실제로 한 일이다. 펀딩일을 정하는 일을 누구에게도 무엇에게도 넘기지 않았다. 묻기를 침묵시켰다. AUTO인 풀은 날짜가 설정되지 않은 채로 각 주기에 도달하고 체인은 어쨌든 답한다. PoolCommonLib.epochFundingDateAt이 받은 적 없는 주기에 대해 직전 + epochDurationDays를 반환하고 정확히 한 걸음만 뒤를 보며, 정산이 그 값을 스토리지에 굳힌다(RedemptionLib.sol:894). revert도 실패도 없이 틀린 날짜가 한 주기씩 기어간다. "이메일 줄이기"라고 라벨된 스위치가 풀이 어느 날짜에 지급하는지를 바꾸고 있었다.

그리고 요청형 조합이 면제가 아니라 더 나쁜 절반이다. v3-135가 기록한 이음매는 만기 후 문제로 틀 잡혔고 그 틀은 너무 좁았다. 상환 계획에는 적어도 배포가 회차 1을 쓰고 나머지를 시도한다(v3-133). 요청형 회차 풀은 정확히 아무것도 미리 쓰이지 않고 운영자가 주기가 올 때마다 확정하는 모델이다. 그래서 거기서 AUTO아무도 이 날짜를 정하지 않았다를 풀의 예외가 아니라 기본 상태로 만든다.

v3-135가 제안한 두 해법 중 어느 것도 나가지 않았고 어느 쪽도 충분하지 않았을 것이다. 위저드가 AUTO를 보내지 않게 해도 0104의 NOT NULL 기본값이 컬럼에 남고, 계획 분기를 모드 검사 위로 올려도 모든 요청형 풀이 여전히 그것에 게이팅된다. 검사가 게이팅에서 아예 나온다. sweep은 이제 분기 둘을 돌고 모드 검사가 없으며, 한 질문을 두 종류의 증거로 묻는다. 계획 풀은 redemption_epochs.funding_date에 대한 차집합으로 답하고 시계가 필요 없다. 요청형 풀은 주기가 올 때만 행이 생기므로 "N+1 주기에 날짜가 없다"와 "N+1 주기가 아직 없다"를 구분할 수 없고, 남는 것은 windowOpen 앞의 선행 창이다.

생성은 이제 컬럼을 아예 보내지 않고 0104 기본값이 NOT NULL을 채우게 한다. 리터럴을 쓰면 아무도 하지 않은 선택을 코드가 다시 주장하게 되고 어차피 기존 행은 전부 SEMI_AUTO다. pool-editUI 없이 수정 가능 권한 목록과 PATCH payload로 그 필드를 왕복시키고 있었고 나머지와 함께 갔다. 런타임 독자는 언제나 그 sweep 하나뿐이었다. 나머지 스케줄러 둘은 그것으로 게이팅하지 않는다는 주석만 달고 있었다.

컬럼은 남고 읽기만 나간다. "이제 신경 쓰지 않는다"를 표현하려고 컬럼을 지우면 옛 해석 아래 만들어진 풀들의 역사를 다시 쓰게 되므로, 0192는 주석일 뿐이고 그 외에는 없다. DDL도 없고 건드리는 행도 없으며 주석이 정직한 상태를 적는다. 존재하고, 채워져 있고, 읽히지 않는다. 🔴 다시 읽지 말라고도 적는다. "이 풀에 대한 알림은 그만"은 알림의 억제이고 알림 환경설정에 속하지 일정 컬럼이 아니다. 동작 변화를 물려받는 것도 없다. dev에서 측정하니 풀 8개가 전부 SEMI_AUTO이고 계획 풀은 없으므로 어떤 풀도 AUTO였던 적이 없다.

⚠️ schema.sql11-db-schema.md 쌍이 서로 다른 세션의 서로 다른 커밋 둘에 착지했다. CLAUDE.md 체크리스트는 둘을 함께 하라고 하고 둘 다 했다. 스냅샷의 DEPRECATED (0192) 주석은 코드와 함께 나갔고 이 문서의 컬럼 행은 문서 라운드에 왔다. 다만 커밋 하나를 diff하는 독자는 절반만 보고 나머지가 빠졌다고 결론 내린다. 고치지 않고 기록한다. 작업을 나눈 것은 의도적이었고 이음매를 숨기려고 역사를 다시 쓰는 비용이 이 설명보다 크다.

문서: 14-decisions v3-140 · 22-notifications → epoch_funding_date_due · 11-db-schema → pools. 마이그레이션 0192(주석만, dev 적용 대기). 출처: JY(제품), 2026-08-18.

📅 2026년 8월 17일 — 아무도 안 쓴 회차는 온체인에서 비어 있는 게 아니라 틀린 날짜이고, 정산할 때마다 굳는다 (v3-133 – v3-139)

🔨 만들었고 배포 안 됨 — 마이그레이션 0189 / 0190이 아직 dev에 적용 안 됐고, 컨트랙트는 하나도 안 건드렸다

v3-132는 만기후 회차상환 계획을 표현할 수 있는 위저드를 냈고 날짜는 배포 후 카드에 넘겼다. 그 카드를 만들다가 날짜가 기다리면 안 되는 이유가 나왔다. 체인에는 규칙이 없고 파생식만 있는데, 그 식은 정확히 한 걸음만 뒤를 본다. PoolCommonLib.epochFundingDateAt은 회차에 저장된 값이 있으면 그걸 주고 없으면 previous + epochDurationDays를 준다. 그래서 아무도 안 쓴 회차가 빈 값이 아니라 날짜로 답한다. 15일 캘린더 계획에서 위저드의 Mar 15 · Apr 15 · May 15 · Jun 15는 체인에선 Mar 15 · Apr 12 · May 10 · Jun 7이 된다. 둘째 회차에서 사흘, 넷째에서 여드레 어긋나고 어디서도 리버트가 안 난다. 게다가 복구 가능한 상태로 남지도 않는다. 정산할 때마다 다음 회차의 파생값을 스토리지에 써 버리므로(RedemptionLib.sol:894) 정산 한 번이 틀린 날짜 하나를 영구히 확정한다.

그래서 배포가 N개를 전부 쓴다 (v3-133). 쓸 수 있는 유일한 순간이기도 하다. 그 시점에 만기는 실제 타임스탬프라서 계획을 만기에 맞춰 다시 세울 수 있는 첫 순간이고, 풀이 불완전한 스케줄을 달고 라이브가 되기 전 마지막 순간이다. 1회차는 앵커로 이미 온체인에 있고 나머지는 거기서부터 걸어 나가므로 목록 전체가 한 스케줄에 속한다. 🔴 상한은 트랜잭션 개수가 아니라 시계다. 개수로 잡으면 아무도 안 재본 tx당 벽시계 시간을 추측하는 셈이고, 그 추측은 체인이 느린 날 깨진다. 하필 그날이 이게 중요한 날이다. 🔴 중간에 끊긴 실행은 실패한 배포가 아니다. 풀은 온체인에 있고 설정도 맞다. 라이브 풀에 DEPLOY_FAILED를 남기면 거짓 기록이고, 빠진 회차는 각자의 윈도가 열리기 전까지 나중에 쓸 수 있다. 다만 조용히 끝나서는 안 된다.

라이터가 둘인데 순서가 반대이고, 합치면 한쪽이 조용히 깨진다 (v3-134). 배포는 오름차순으로 쓴다. 중간에 끊겼을 때 가까운 회차가 아니라 먼 회차가 안 쓰인 채 남게 하려는 것이다. 확인 카드는 내림차순으로 쓴다. setEpochFundingDate는 회차의 윈도가 열린 뒤엔 거부되는데 그 윈도는 epochFundingDateAt에서 나오고, 앞 회차가 안 쓰여 있으면 그 함수가 0을 준다. 윈도가 0이면 "아직 안 열림"으로 읽히므로 K회차를 쓰는 행위가 K+1회차의 가드를 켜는 셈이다. 여기서 오름차순으로 쓰면 아무도 고르지 않은 날짜 때문에 회차 하나가 영영 잠길 수 있다. 모듈은 순서를 스스로 정하지 않고 호출자에게 받으며, 테스트 스위트도 일부러 나눠 뒀다.

알림은 선호가 아니라 상태의 결과가 됐다 (v3-135). v3-132는 "모든 날짜가 앞에서 확정된다"는 이유로 이 모양에 epoch_cycle_mode = AUTO를 강제했는데, 쓰기 실행이 시계로 끊기는 순간 그 전제가 무너진다. 이제 게이트는 redemption_epochs.funding_date IS NULL이고, epoch 요약이 쓰는 것과 같은 함수를 통과하므로 둘이 어긋날 수 없다. 알림이 제안하는 날짜도 previous + cadence가 아니라 저장된 규칙을 앵커에서부터 걸어 나가 만든다. 전자는 roll-15 계획에서 12일을 말했다. 확인 카드가 배포되기 전까지 이 알림이 유일한 복구 경로다. 배포는 재시도하지 않는다. 쓰기가 도는 시점엔 deploy_status가 이미 DEPLOYED이고 재전달은 멱등 가드가 돌려보낸다.

규칙 컬럼 금지가 한 칸 넓게 그어져 있었고, 다시 긋는다 (v3-136). v3-132는 런타임에 epoch_date_basis / epoch_roll_day를 아무도 읽으면 안 된다고 했는데, 위저드 미리보기와 확인 diff와 알림은 전부 읽어야 한다. 선은 읽기가 아니라 누가 행동하느냐에 있다. 저장된 규칙이 만든 날짜는 사람에게 보여줄 수는 있어도, 체인에 쓰거나 스케줄을 결정하는 데 쓰면 안 된다.

🔴 같은 버그가 한 라운드에 세 군데서 잡혔고, 그게 이 라운드의 진짜 성격이다. 셋 다 규칙에서 날짜를 다시 유도해 자신 있게 틀린 값을 냈고 실패 신호가 없었다. 모든 날짜가 앞에서 정해진다고 주장한 위저드 문구, 배포의 라이터, 그리고 roll-15 계획에서 "05-30을 확인하라" 고 제안한 알림이다. 원인은 구분 하나다. generateFundingDates는 첫 날짜에서부터 걸어 나가고, repaymentPlanDates는 첫 날짜를 스스로 찾으며 CALENDAR에선 넘겨받은 값을 무시해서 rollDay: 0으로 월말에 착지한다. 이제 앵커를 쓰는 호출자 둘 다 planDatesFromAnchor를 거치고, 계획 프리미티브에는 경고를 달았다.

이 모든 게 의존하는 이벤트를 인덱서가 버리고 있었다. EpochFundingDateSet은 accepting 사이클을 지목하지 않으면 통째로 버려졌는데, 배포가 쓰는 회차는 전부 그 경우다. 그대로 뒀으면 배포는 성공하고 0190은 비어 있고 알림과 카드가 둘 다 완성된 계획을 미작성으로 보고했을 것이다.

데이터, 그리고 계획과 사실 사이의 선. 0189는 계획을 pools에 두고, 0190redemption_epochs.funding_date를 추가한다. 이 컬럼은 EpochFundingDateSet에서만 쓰고 다른 무엇도 쓰지 않는다. NULL이 계속 아무도 이걸 안 정했다 는 뜻이어야 하기 때문이다. 카운트는 2회차부터다. 1회차는 setEpochSchedule에서 오고 그건 EpochScheduleSet을 내보내므로, 1부터 세면 모든 계획을 영원히 한 회차씩 모자라게 보고한다. GET /pools/{id}/repayment-cycles는 그 null을 드러내려고 존재하고 일부러 체인을 안 읽는다. 체인 읽기는 모든 회차에 답을 주므로 그 구분을 지우기 때문이다. 원장 읽기 실패는 []가 아니라 500이다. 빈 목록은 "날짜 있는 회차가 하나도 없다"로 읽히고, 그러면 카드가 전부 쓰겠다고 제안한다. 트랜잭션 하나씩.

POST /pools/{id}/epoch-schedule은 선택 epoch_id를 받는다 (v3-139). 배포가 못 끝낸 걸 카드가 마저 하도록 하되 기본값은 여전히 accepting 사이클이고 그보다 뒤처진 값엔 409를 준다. 🔴 미래 회차에 대해서는 풀 행을 미러링하지 않는다. next_funding_date와 그 출처 컬럼들은 회차 하나를 설명하는데, 4회차 쓰기로 이걸 채우면 풀이 4회차를 다음이라고 알리고 윈도가 열리기도 전에 확정으로 표시한다. 그러면 mirrored: false가 "실패했다"와 "미러링할 게 없다" 사이에서 모호해져서 mirror_scope로 둘을 갈랐다. 이게 없으면 호출자가 재시도해서 이미 동의한 체인에 두 번째 트랜잭션을 보낸다. 그리고 아무도 안 보던 순서를 이제 본다. 양쪽 이웃 모두에 대해, 파생된 이웃까지 포함해서 검사하고, 0으로 읽히는 이웃은 1970년으로 취급하지 않고 건너뛴다. 이 패턴은 바로 아래 setEpochSettleAfter에 이미 있었고 그건 funding date와 비교한다. ⚠️ 가드는 엔드포인트에 있고 GovernanceLib에는 없다. ORACLE 키가 setEpochFundingDate를 직접 부르면 여전히 우회한다. 그래서 배포 라운드가 필요 없었고 이미 라이브인 풀도 커버한다. 탈취된 키가 아니라 운영자 실수에 대한 가드다.

🔴 이 전부보다 오래된 P0. KMS 키를 나눈 이후로 새로 만든 풀은 전부 서명을 못 하는 상태로 태어났다 (v3-138). 예전엔 admin과 oracle이 한 키라서 배포자에게 롤을 주면 서명자에게도 얻어걸렸다. 키를 나누면서 둘이 달라졌는데 create-pool.ts는 계속 admin 서명자를 지목하고 있었다. 그 뒤로 아무도 풀을 안 만들어서, 나간 채로 기다리고 있었다. 그런 풀은 oracle 경로 아홉 개와 PAUSER 게이트 함수 다섯 개 전부에서 리버트한다. ⚠️ close · wind-down · impairment는 애초에 안 깨졌다. setLifecycleStatusDEFAULT_ADMIN_ROLE이고, 실패한 건 그 옆의 clearOnChainPause 다리다. 이 기록의 이전 판은 다르게 적혀 있었는데, 맞는 결론이 틀린 전제 위에 서 있으면 다음 독자를 오도한다. 회수보다 부여가 먼저이고, 호출 인덱스를 비교해 고정했다.

🔴 그리고 라이브 문구 결함 하나. 출처가 결정 카드다. v3-100은 v3-93의 과다 펀딩 항목을 "남은 건 다음 epoch으로 넘어간다"로 닫았다. 안 넘어간다. epochFundTopUp은 회차로 키가 잡혀 있고 정산은 epochFundTopUp[currentEpochId]만 읽으며(RedemptionLib.sol:955), reserveBalance +=저장소 전체에서 정확히 한 번 나온다(PoolLedgerLib.sol:73). 그러니 top-up을 어디로든 옮기는 코드가 없다(v3-137). 잃어버리는 건 아니다. totalEpochTopUp에 남아 wind-down 분자를 통해 홀더에게 간다. 다만 자기 회차에 묶인다. 그런데 over_funding_detected는 아직도 펀드매니저에게 "다음 epoch으로 이월되니 조치할 필요 없다" 고 메일을 보낸다. 비싼 쪽은 뒷문장이다. 다음 회차는 다시 전액을 채워야 하므로 안내가 정확히 거꾸로다. catalog-integrity.test.ts/carries over/i단언하고 있어서, v3-96의 조작된 "automatically" 가 고정됐던 방식 그대로 고정돼 있다. 카드와 이 문서는 고쳤고, 문구 수정은 apps/infra에 아직 남아 있다. 그걸 밝히는 게 핵심이다. 거짓 메일 위에 참인 문서를 얹으면 결함이 고쳐지는 게 아니라 가려진다.

정리하다가 같이 고친 것. schema.sql이 컬럼 일곱 개(0024 · 0071 · 0088 · 0111 · 0161) 뒤처져 있었다. 마이그레이션 191개를 재생해 대조하다 찾았고 잔여는 0이다. 원인이 둘인데 둘 다 부주의는 아니다. 체크리스트가 11-db-schema.md만 요구하고 스냅샷은 안 요구해서, 규칙을 완벽히 지킨 커밋도 스냅샷을 낡게 둘 수 있었다. 그리고 드리프트를 알아챈 자리에서는 고치는 대신 적어 뒀다("그대로 둠, 이 변경 범위 아님"). 이제 CLAUDE.mdschema.sql을 먼저 놓고, 파일 헤더가 이유를 적는다. 이건 문서가 아니라 도구다. 문서로 취급하면 범위 경계가 생기고, 도구로 취급하면 생길 수 없다. 잘못된 도구는 그냥 고장 난 것이기 때문이다.

문서: 14-decisions v3-133 – v3-139 · 07-redemption → 날짜가 체인에 닿는 경로 · 11-db-schema · 15-api-reference → Pools · 22-notifications · 09-rbac · 24-field-governance §5. 마이그레이션 0189 · 0190, 둘 다 dev 적용 대기. 출처: JY(제품), 2026-08-17.

📅 2026년 8월 16일 — 만기후 회차상환 위저드, 그리고 만기가 앵커를 지나쳐 버린 배포를 이제 거부한다 (v3-132)

🔨 만들었고 배포 안 됨 — API는 수동 배포라서 푸시가 초록이어도 라이브라는 뜻이 아니다

v3-131이 종이 위에서 이 조합을 열었다. 여기 있는 건 그걸 표현할 수 있는 위저드, 그리고 설계가 걸려 있는데 놓을 자리가 없던 숫자 하나다.

위저드는 순서를 바꿔 묻는다. 옛 순서는 기계의 질문을 먼저 했기 때문이다. on-request 블록은 회차 길이로 시작하는데, 여기선 그게 세 번째로 묻고 첫 번째로 답이 나오는 항목이다. 지급이 몇 번인지와 파트너가 무엇에 맞춰 송금하는지가 둘 다 그걸 결정하기 때문이다. 그래서 만기후 블록은 기간 → 날짜 기준 → 윈도와 리드 → 첫 지급 → 미리보기로 흐르고, 각 단계는 위에서 안 정해진 것만 묻는다. 캘린더 기준을 고르면 회차 길이 질문 자체가 사라진다. 캘린더가 날짜를 정하고 epoch_duration_days는 월간이냐 분기냐에서 따라 나온다. 그래도 값은 나간다. 체인이 요구하기 때문이다. 그리고 그 값을 참조하는 기간 오류 메시지는 이제 화면에 없는 필드를 가리키는 대신 숫자를 인용한다.

컬럼 셋(0189), 그중 하나가 요점이다. redemption_term_epochs는 계획의 길이를 회차 수로 담는다. epoch_date_basisepoch_roll_day는 생성 시점의 답이고, 배포 후 카드가 같은 목록을 두 번 묻지 않고 재구성할 수 있도록 남긴다. 🔴 뒤의 둘은 런타임에서 아무도 안 읽고 앞으로도 읽으면 안 된다. 날짜가 산출물이고, 저장된 규칙에서 날짜를 유도하는 스케줄러야말로 이 설계가 없애려는 조용한 폴백이다. roll day는 28에서 멈춘다. 2월이 그렇기 때문이다.

배포는 만기가 지나쳐 버린 앵커를 거부한다. 만기는 배포 순간부터 세는데 funding date는 절대 시각이라, 일주일 묵힌 초안은 만기가 일주일 밀리고 앵커는 안 움직인다. 이 풀 모양에서 그건 조용히 치명적이다. 만기 전 요청은 리버트하므로 1회차는 윈도 내내 모두를 거절하며 보내게 되고, 어느 세터로도 나중에 못 고친다. 이제 워커가 배포를 실패시키고 deploy_error에 고친 날짜를 담는다. FIXED_MATURITY로 한정한다. on-request 풀은 만기 전 요청을 받는 게 당연하기 때문이다.

이 모양에서는 알림을 끈다. epoch_cycle_mode = AUTO다. SEMI_AUTO 알림은 자기가 부르는 날짜를 "이전 + 주기"로 유도하므로 온체인에 있는 날짜와 다른 날을 말하게 된다. 라이브 DB에는 AUTO인 배포된 풀이 없어서(2026-08-15 확인) 무언가가 변화를 물려받지 않는다. (🔴 다음 날 v3-135가 뒤집음. 위의 8월 17일 항목 참조.)

이번 라운드에 없고, 이번 라운드가 함의하지도 않는 것: 배포 후 확인 카드와 회차 번호를 받는 엔드포인트. 위저드의 날짜 목록은 미리보기이고 모든 행에 그렇게 적는다. (둘 다 8월 17일에 착지했고 쓰기는 배포 안으로 들어갔다. v3-133.)

화면에서 리뷰했고, 그게 셋을 움직였다. 오류와 중립적인 힌트가 같은 필드 아래에 나란히 있지 않게 됐다. 힌트는 "기간이 온전한 지급 횟수로 나뉜다"고 말하는데 그 바로 위 오류는 안 나뉜다고 말하고 있었다. 지금 값이 무엇을 만드는지 설명 못 하는 힌트는 할 말이 없는 것이다. 옛 회차 바가 나르던 비례 감각은 그걸 결정하는 두 필드를 입력하는 자리에 한 줄로 돌아왔다("28일마다 7일씩 요청 접수"). 날짜 표가 정확한 윈도는 주지만 한 회차의 모양은 안 주기 때문이다. 그리고 만기부터 N회차까지의 타임라인 W4는 열어 두지 않고 버렸다. 표가 이미 그릴 내용을 다 적고 있고, 배포 전 추정치를 타임라인으로 그리면 스케줄의 권위를 빌려주게 된다.

투자자 앱이 만기 게이트보다 주기를 먼저 읽던 걸 멈췄다. 세 화면이 "이 풀에 회차가 있나?"를 먼저 물어서, 만기 전엔 모든 요청을 거절하는 풀이 브라우즈 카드에 Epoch · 28d로 붙고 포트폴리오에선 "회차로 정산"으로 분류됐다. 유동성 모델에 그걸 위한 AWAITING_MATURITY가 생겼다. 락업(홀더 각자의 날짜에 끝난다)과도 다르고 회차(실제로 뭔가 정산된다)와도 다르다. FIXED_MATURITY로 한정하고 양방향 테스트로 고정했다. 배포된 epoch 풀 둘은 on-request이고 실제로 만기 전에 정산하기 때문이다. 공유 3-상태 모델은 안 건드렸다. 그건 redemption_type을 모르고, 알게 만들면 FIXED_MATURITY 파생 전부가 한꺼번에 움직인다. 시드 계정으로는 가질 수 없는 이 상태의 픽스처는 /styleguide/portfolio에 있다.

문서: 14-decisions v3-132 · 11-db-schema → pools. 마이그레이션 0189(dev 적용 대기). 출처: JY(제품), 2026-08-15/16.

📅 2026년 8월 15일 — 만기 후 여러 회차에 걸쳐 상환하는 풀은 새 설정이 필요 없는 것으로 밝혀졌고, 가드 하나를 빼고 컨트랙트 하나를 고친다 (v3-131)

🔧 결정만, 구현 없음 — 문서는 계획을 기록하고 코드는 안 바뀌었다

새 상품 모양이다. 만기까지는 상환을 거절하고, 만기 후 원금을 여러 회차에 걸쳐 갚는다. 그동안 쿠폰은 남은 원금에 계속 붙는다. 설정으로 쓰면 redemption_type = FIXED_MATURITY + epoch_duration_days > 0이고, 새 구간 × 모드 행렬의 조합 ②이며 컨트랙트가 이미 허용한다. RedemptionLibmaturityDate 전 이탈을 거절하고 epoch 엔진은 주기대로 정산하며, 둘은 서로를 모른다. 이 조합을 거절하는 건 v3-88 D7 하나뿐인데, 만기 온 풀은 "한 번에 지급한다"는 이유로 FIXED_MATURITY의 주기를 0으로 만드는 admin 위저드 가드다. 그 문장은 그게 쓰이던 시절의 모든 풀에 대해 참이었다.

세 번째 redemption_type 값은 전례를 보고 기각했다. redemptionConfiginitialize에서만 설정되고 PlatformPool은 업그레이드가 안 되므로, 풀은 생성 시점 구현체에 고정된다. 마지막 세 번째 값이 그걸 보여 준다. LIQUIDITY_WINDOWS는 온체인엔 나갔는데 배포 워커가 윈도 배열을 한 번도 안 채워서, 그렇게 만들어진 풀 넷은 영원히 상환이 안 된다. 라벨을 갈아 끼우지 않고 폐기했다. 컬럼을 다시 쓰면 DB가 컨트랙트에 없는 능력을 주장하게 되기 때문이다(마이그레이션 0106).

딜마다 다른 값은 아무것도 저장 안 한다. 회차 금액도 마찬가지다. 스케줄 길이는 풀 단위이고, 회차별 금액은 어디에도 설정하지 않는다. 각 회차는 발행사가 그 회차에 넣은 만큼을 pro-rata로 정산하므로, 들쭉날쭉한 분납이 설정 없이 이미 된다. 스케줄에 진짜 없는 건 종료다. 앵커와 주기는 있는데 끝이 없다(redemption_window_epochs, 스펙만 있고 MVP 포함 미정. 회차로 저장하고 개월로 입력하며 NULL = 무한이라 기존 epoch 풀엔 영향 없음).

MVP는 다섯 중 둘이다. (1) 위저드가 조합을 받고 스케줄 블록 이름을 "만기후 상환"으로 바꾼다. FE/BE만이고 스키마·컨트랙트 변경 없음. (3) 수익 발생이 요청 시점에서 정산 시점으로 옮겨 간다. 이건 컨트랙트 변경이다. 나머지는 이렇다. (2) 회차 카운터는 미정, (4) 투자자별 만기는 범위 밖, (5) 끝내 요청 안 하는 홀더는 운영 문제.

(3)은 v3-91 규칙 1을 뒤집는데, 그때 안 따진 근거 위에서 뒤집는다. 규칙 1은 에스크로된 LP를 수익 분모에서 빼므로 요청자는 요청부터 청구까지 아무것도 못 번다. 문제는 요청자가 조금 손해 본다는 게 아니다. 발행사의 쿠폰은 줄지 않으므로, 몰수된 몫이 남아 있는 홀더에게 간다. 쿠폰이 만기 전과 똑같이 계속된다고 적힌 계약에 대고는 플랫폼이 약정을 안 지키는 것이고, 여러 회차 스케줄에서는 매 회차 반복되며, 포지션 전체를 한 번에 요청하면 스케줄 전체를 몰수당한다. 이제 발생은 executeEpoch에서 끝난다. 청구 시점이 아닌 건 그러면 청구를 미룬 투자자에게 돈을 주게 되기 때문이고, 요청 시점이 아닌 건 위 이유다. 안 채워진 잔량은 계속 발생한다. 그 원금은 여전히 운용 중이기 때문이다. 요청과 청구 사이엔 배제 구간이 셋 있는데, executeEpoch에서 정산하면 셋 다 닫힌다. executeEpoch가 LP를 안 태워서 생기는 구간까지 포함해서다. v3-91이 기댔던 방어 둘은 그대로다. epoch 가격은 forward이고, 컷오프 이후 취소는 이미 불가능하다.

정리하다가 낡은 문서 주장 둘을 고쳤다. 07-redemption은 락업과 만기 경계가 둘 다 포지션 단위라고 적고 있었고, 04-pool-models는 투자자 만기를 deposit_time + maturity_days로 유도하고 있었다. 만기는 마이그레이션 0161 이후로 풀 단위다. 컨트랙트의 maturityDate를 미러링하는 절대값 pools.maturity_date이고, 컬럼 주석이 invested_at에서 다시 유도하지 말라고 그대로 적어 놨다. 컨트랙트에는 lockupDays(기간)와 maturityDate(날짜)가 있고 maturityDays는 없으므로 홀더별로 앵커를 잡을 것 자체가 없다. 두 문서 모두 이제 이 비대칭을 적고 결정됐지만 안 만든 수정으로 링크한다. 임시 통제는 2주쯤의 모집 윈도다.

같이 기각했고 각각 기록한 것: 쿠폰 기준 설정(선택지가 하나뿐), 영업일 관례와 휴일 캘린더(은행을 건드리는 건 발행사의 리콜뿐이고 recall_lead_days가 그걸 덮는다), 가중평균 만기 앵커(조기 이탈이 없으면 패널티가 없고 그러면 차익도 없다), 이 조합에서의 redemption_gating_bps(두 번째 상한은 발행사가 이미 맞춰 넣고 있는 스케줄을 깨뜨리기만 한다). 끝내 요청 안 하는 홀더는 만들지 않고 순서를 잡았다. 알림, 그다음 약관의 종료 조항, 그다음 그 조항이 요구하는 만큼만 코드. wind-down 경로는 절대 아니다. 그건 권한이 편해 보이고 의미는 정반대다.

redemption_type04-pool-models에 처음으로 제대로 들어갔다. 줄곧 온체인·생성 전용 차원이었는데 차원 목록에서 빠져 있었다.

뒤에 코드와 대조했고, 숫자 둘과 추정 하나가 움직였다. 결정 카드 수를 123 → 127로 고쳤다(옛 숫자는 하나가 아니라 셋 뒤처져 있었다). 이 문서 자체의 합계는 151 → 150으로 고쳤다. 그 수치는 원시 <details> 블록을 세고 있었는데 그중 하나는 항목이 아니라 중첩된 하위 토글이고, 같은 off-by-one은 새로 만든 게 아니라 물려받은 것이다. 04의 Investor Terms 도입부도 자기 표보다 하나 뒤처져 있어서 이제 여덟이다. 항목 (1)은 가드 제거로 적혀 있었는데 실제로는 pool-create/payload.ts의 맞물린 가드 셋이고, 앵커 스케줄 블록 없이 주기만 열면 생성 엔드포인트가 거절하는 풀이 나온다. 07카드 둘 다 이제 그렇게 적고, 07에는 setEpochSchedule이 강제하는 recall_lead_days + request_window_days < epoch_duration_days 범위가 들어갔다. 내용상의 주장은 버텼다. enum 값, maturityDate == 0 거부, 정산 자금 공식, 소각 지점, 무권한 폴백, v3-88 D5/D7 인용이 전부 컨트랙트와 맞았다.

발행사와 열어 둔 것: 쿠폰 기준, "월 9%"가 뭘 뜻하는지(연환산·빈도·일수 계산), 원금과 이자가 같은 날짜를 쓰는지, 날짜를 놓쳤을 때 처리. 출시 전: 회차별 펀딩 절차, 스케줄 도중의 손실 처리, 계약상 고정금리에 대한 공개 문구, 그리고 재투자를 계속 열어 둘지(상환 중인 풀로 자본이 다시 들어오게 한다. 이 상품은 꺼야 한다).

문서: 14-decisions v3-131 · 07-redemption · 04-pool-models · 16-timeline. Notion "풀 상환 모델 — 구간 × 모드 (기획서)" + "만기 기준을 풀 단위 → 투자자별로 전환". 출처: JY(제품), 2026-08-15.

📅 2026년 8월 13일 — 랜딩 사이트의 브레이크포인트 체계가 앱에 닿았는데, 절반은 베끼면 안 되는 절반이었다 (v3-129)

✅ 큰 화면은 더 큰 내용이 아니라 더 많은 내용을 받는다

공유 타입 스케일의 디스플레이 사이즈 다섯이 이제 유동적이고 줄어들기만 한다. 375→1280px 뷰포트에서 stat 18→20, lead 20→24, dstat 24→32, hnum 28→40, hero 32→48이고, 각 clamp의 최댓값은 토큰이 원래 갖고 있던 고정값이다. 전보다 커지는 건 하나도 없다. label/data/read는 12/14/16에 그대로 고정이다. 레이아웃 쪽은 토큰 하나만 새로 생겼다. --container-monitor(1680px)가 2xl 칸을 채운다. web의 컬럼은 거기까지 넓어지고, admin의 main은 없던 상한을 얻는다. web의 브라우즈 그리드도 카드 한 장이 늘지만 뷰포트가 아니라 컨테이너를 기준으로 잡았다(아래 참조).

랜딩을 먼저 읽은 게 계획을 바꿨다. 거기엔 체계가 하나가 아니라 둘이다. 메인 페이지(widgets/landing/*, 파일 8개)는 요소마다 다섯 단계 램프를 손으로 적는다. ch/product에서 넘어온 페이지 넷(파일 20개)은 global.css에 한 번 선언한 clamp() 토큰을 쓴다. 돌아다니던 표는 앞엣것을 기록한 것이다. web에 84개, admin에 118개인 컴포넌트 파일 수를 놓고 보면 요소마다 적는 방식은 감당이 안 된다. 랜딩 자신이 첫 번째 형태에서 두 번째로 흘러간 것이 그 근거다.

앱들은 반응형이 아니었던 게 아니라 한 축에서만 반응형이었다. 셸 전환(web은 md, admin은 lg), 반응형 그리드 35개, 테이블 오버플로, 4단계 거터 램프는 이미 있었다. 없던 건 타입과 밀도다. 여덟 개짜리 스케일이 모든 폭에서 고정 px였고, 48px hero가 375px 폰에서 그대로 앉아 있었다.

모니터 칸은 두 앱을 반대 방향으로 움직인다. 이게 거꾸로 읽히는 부분이다. web은 1280px에서 잘려 2560px 화면의 절반을 빈 거터로 썼다. admin은 상한이 아예 없어서 같은 화면에서 일곱 칸짜리 Pools 행을 약 2496px로 늘였고, 맨 왼쪽 풀 이름과 맨 오른쪽 체인을 짝지으려면 눈이 여행을 해야 했다. 같은 토큰인데 하나는 넓히고 하나는 좁힌다.

타입 체계 셋이 경쟁하던 문제는 세어 보면 실제보다 작다. 큰 타입 호출 지점 111개가 스케일로 옮겨 갔다(기본 칸과 21–40px 임의값, 최대 편차 2px). text-xs/text-sm/text-[11px] 약 1,023곳은 일부러 뒀다. 12–14px은 스케일링하면 안 된다. text-4xl 여섯 곳은 타입이 아니라 장식용 글리프 크기라서 남았다.

⚠️ 브라우즈 그리드가 lg 위의 뷰포트 브레이크포인트를 잃었고, 그게 이 변경보다 오래된 버그를 고쳤다. AppShelllg+에서 라우트 컨텐츠 왼쪽에 240px 사이드바를 놓으므로 카드는 뷰포트 − 240 안에 산다. 게다가 그 사이드바는 런타임에 접힌다. 뷰포트를 기준으로 잡은 컬럼 단계는 전부 같은 방향으로 틀렸다. xl:grid-cols-3은 1280에서 켜지는데 그때 컬럼은 912px이라 288px 카드를 냈고, 새로 넣은 2xl:grid-cols-4274px을 냈다. 둘 다 직전 컬럼 수가 이미 주던 368px보다 좁다. 그러니까 "칸을 늘린다"고 적힌 단계마다 조용히 카드를 작게 만들고 있었다. 지금은 lg:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]로 읽는다. 실제로 들어가는 만큼 300px 이상 트랙을 깐다. auto-fit이 아니라 auto-fill인 이유는, 전자는 짧은 목록이 못 채운 트랙을 접어 버려 카드 두 장을 약 760px로 늘이기 때문이다. 300px은 공짜가 아니라 실제 맞바꿈이다. 1280과 1315 사이에서 컬럼 하나를 잃고(카드 288 → 444) 1640부터 네 번째가 온다. portfolio의 "Discover more" 그리드는 .slice(0, 3)을 렌더하므로 평범한 컬럼으로 남는다.

⚠️ tailwind-merge가 키트 컴포넌트 안에서 커스텀 사이즈 토큰을 조용히 지운다. 그것도 admin에서만. tailwind-merge는 CSS 속성이 아니라 클래스 이름 으로 분류한다. text-lead는 아는 font-size 패턴 중 어디에도 안 맞아서 텍스트 색상 으로 분류되고, 옆에 있는 text-neutral-700이 CSS엔 존재하지도 않는 충돌에서 이긴다. twMerge('text-lead font-bold text-neutral-700')'font-bold text-neutral-700'을 돌려준다. 이게 대체한 text-2xl은 내장 스케일이라 살아남았으므로, 이번 전환이 파손을 만들어 냈다. admin CardTitle 일곱 개가 상속 크기로 떨어져 24px이 16px로 렌더됐다. 같은 묶음의 DialogTitle 둘은 이 작업 이전부터 text-lead를 달고 있어서 줄곧 키트 기본 18px로 조용히 렌더되고 있었다. 그러니 같은 수정이 그 둘은 깨뜨린 게 아니라 고쳤다.

저장소는 세 곳 중 두 곳을 이미 막고 있다. packages/ds-editorial/src/cn.tsapps/web/app/shared/lib/utils.ts는 둘 다 extendTailwindMergecn을 만들며 토큰 여덟 개를 font-size에 등록한다. 거기 주석이 정확히 이 실패를 적어 놨다. apps/admin-web엔 자기 cn이 없다. Card/Dialog/Button이 @shard-lab/finance-dashboard-kit에서 오는데, 그 패키지는 맨 twMerge를 번들해 패키지 안에서 머지하므로 admin 쪽 확장은 참조될 일이 없다. 호출 지점에서 text-(length:--text-lead) leading-tight로 고쳤다. 맨 tailwind-merge도 사이즈로 분류하는 형태다. font-size만 담으므로 line-height는 따로 적는다.

그 외에 노출된 곳은 없다. web의 PoolStatusAlert.tsx:216-217은 똑같아 보이지만 확장된 cn이라 괜찮다. 공유 patterns.tsx도 같은 이유로 괜찮다(실측: SectionEyebrow가 두 앱에서 12px로 렌더). <Link> 같은 pass-through 컴포넌트는 머지를 아예 안 한다. 원래 있던 admin 사례 둘은 고치지 않고 기록만 한다. pool-controls.tsx:639nav-change-dialog.tsx:173DialogDescription을 16px 대신 14px로 렌더한다.

⚠️ 어떤 게이트도 이걸 못 본다. tsc -b, 프로덕션 빌드 둘, 문구 가드가 내내 초록이었다. 클래스는 유효하고 컴파일되고 방출된 CSS에도 들어 있다. 런타임에 제거된다. 토큰을 달고 있는 요소를 DOM에서 훑는 것도 놓친다. 그 클래스는 크기를 정해 주려던 요소에서 사라져 있기 때문이다. 프로브 요소에 getComputedStyle을 재 본 것이 이걸 드러냈다.

⚠️ 가져갈 함정 둘. 유동 토큰은 맨 vw가 아니라 rem + vw여야 한다. 아니면 브라우저 텍스트 확대가 안 먹는다. 그리고 Tailwind는 기본 유틸리티 뒤에 변형을 방출하므로 p-4 sm:p-6 lg:p-8 pb-20sm 위에서 pb-20을 조용히 잃는다. 축별로 램프를 만들 것.

⚠️ line-height가 사이즈를 따라왔다. 토큰 스케일은 1.05–1.1로 도는데 그게 대체한 칸들은 1.2–1.4였다. 한 줄짜리 숫자엔 맞고 줄바꿈되는 제목엔 답답하다. 명시적 leading-*이 없던 전환 제목 30개에 leading-tight을 넣었다.

미룬 것: admin/FM의 폰 지원. lg: 드로어는 만든 대로 두고, admin은 당분간 노트북 이상이다.

검증: 두 앱 tsc -b 통과 · 프로덕션 빌드 둘 초록 · 문구 가드 통과(177 + 240 파일) · 컴파일된 CSS에서 clamp, page-shell의 규칙 다섯, 번들 둘의 2xl 상한, 2xl:grid-cols-4 확인.

참조: v3-129 · 25-design-system § 브레이크포인트와 페이지 컬럼

📅 2026년 8월 13일 — Aset이 파트너 롤을 자기에게 주는 걸 그만뒀고, 그에 기대던 엔드포인트 둘도 같이 나갔다 (v3-53)

✅ 그 shim은 배포 경로의 한 줄이었지 설계가 아니었다

create-pool.ts는 배포하는 모든 풀에서 플랫폼 서명자에게 YIELD_DEPOSITOR_ROLE을 줬다. 그 롤은 파트너의 money-IN 함수 둘, depositYieldfundRedemption을 지키고, initialize가 이미 _fundWallet에 그 롤을 준다. 즉 파트너는 언제나 서명할 수 있었다. 두 번째 사본은 파트너에게 필요한 무엇도 열어 주지 않았다. 그건 Aset이 파트너 대신 서명하게 했고, 09-rbacv3-53 이후로 "메인넷 전에 반드시 제거. 이건 수탁이다"라고 표시해 온 dev shim이다. 이제 제거했고, 문서가 핫 자동화 키에 배정한 유일한 롤인 ORACLE_ROLE은 그 옆에서 그대로다.

서버 키를 쓰던 라우트 둘이 같이 나왔다. 그 부여가 둘이 동작하던 유일한 이유였기 때문이다. POST /redemption-requests/{id}/fund는 삭제했고, POST /pools/{id}/epoch-fundingfund_tx_hash 분기만 남겼다. 잃는 능력은 없다. record-fundingepoch-funding + fund_tx_hash가 파트너 서명 등가물이고, 신뢰하는 대신 영수증을 검증하며, admin-web은 이미 서버 경로를 거절하고 있었다. fund_wallet이 있는 풀이면 전부다(fmSignBlocked, fund-payout-button.tsx). fund_wallet은 실제 온체인 풀 전부에 필수이므로(v3-26) 그건 상환이 존재할 수 있는 모든 풀이었다. 이제 죽은 래퍼 둘(fundRedemptionOnChain, depositYieldOnChain. 후자는 deposit_tx_hash가 필수가 되면서 이미 마지막 호출자를 잃었다)은 lib/shared/contract/에서 사라졌다.

새 부여 장치는 안 넣었고 필요도 없다. 롤은 지갑을 알아서 따라간다. initializefund_wallet에 주고, executeFundWalletChange가 옛 보유자에서 회수하고 새 보유자에게 준다(PlatformPool.sol:1413-1414). 등록 시점에 FM별로 주는 안은 검토하고 기각했다. 롤은 불변 Clones 위에서 풀 단위라, FM마다 풀마다 트랜잭션 하나씩 필요하고 각각을 DEFAULT_ADMIN_ROLE이 서명해야 한다. v3-37이 콜드 3-of-5 Safe에 배정한 키 위에 일상적인 관리 이벤트를 올리는 셈이다.

가드를 걸었다. 실패 방식이 조용하기 때문이다. 부여 목록을 buildOperationalRoleCalls로 빼고 create-pool-config-calls.test.ts에서 단언한다. YIELD_DEPOSITOR_ROLE을 다시 넣으면 테스트 둘이 깨진다. 이건 업그레이드 불가 프록시에 풀마다 한 번 배포되므로 회귀가 나도 백엔드 재배포로는 못 고친다. 게다가 "백엔드가 상환 펀딩을 못 한다"는 딱 그 줄을 다시 넣고 싶어지는 종류의 제보다.

⚠️ 오늘 이전에 배포된 풀은 온체인에 여분 부여를 그대로 갖고 있다. 새 풀은 깨끗하고, 기존 풀은 풀마다 revokeRole(YIELD_DEPOSITOR_ROLE, platform)이 필요하다.

📅 2026년 8월 13일 — 잠자던 레버를 뺐고, 그 버킷을 이고 있던 공식 다섯 개가 짧아졌다 (v3-128)

✅ 아무도 안 당기는 기능도 그걸 이고 있는 모든 공식만큼 비용을 낸다

파트너 잔액 hold-back을 통째로 뺐다. 컨트랙트 스토리지, setFundingRestricted, _releaseHeldFunds와 그 clamp, FundReleases* 이벤트 둘, 원장 kind 둘과 그 폴드 규칙, money_pool_state.held_fund_releases 컬럼, 인덱서 라이터, business/holdback-release.ts, holdback_release_deferred 알림까지다. deposit()은 이제 갈래가 하나다. 파트너 잔액은 언제나 fund_wallet으로 간다.

이건 v3-112를 뒤집는다. 그때는 "제품 경로를 안 만든다"와 "장치를 지운다"를 저울질해 삭제가 비가역이고 비싸다고 판단했고, 유지 비용을 0으로 매겼다. 유지 비용은 레버가 아니라 버킷이었다. heldFundReleases는 wind-down NAV 분자, epoch 가용 유동성 합계, instant 정산 게이트, epoch 충당 차감 순서, 청구 드로다운에 항으로 들어 있었다. 돈 공식 다섯 개를 컨트랙트와 폴드와 오프체인 미러들과 프론트엔드 둘과 이 문서가 이고 다녔다. 존재한 적 있는 모든 풀에서 0이었던 값 때문이다. 돈 경로 리뷰 두 번이 연달아 그 항이 잠들어 있다는 걸 확인하는 데 시간을 쓰고 옆으로 치웠다.

지우기 전에 확인했지 가정하지 않았다. 배포된 풀 일곱 개 전부에서 fundingRestricted가 false이고 heldFundReleases가 0이었다(온체인 조회). 원장에 HELD_FROM_PARTNER / HOLDBACK_RELEASED 행이 0건, 0이 아닌 버킷을 가진 프로젝션 없음, setFundingRestrictedOnChain은 어떤 람다·스케줄러·admin 화면에도 호출자가 없음. 어느 풀의 어떤 숫자도 안 바뀐다.

열려 있던 질문은 코드와 함께 죽는다. v3-112는 그걸 컨트랙트 담당자에게 넘겼었다. _releaseHeldFunds가 의무 셋을 빼는데 뒷받침 불변식은 다섯을 세므로, 릴리스가 홀더 수익 청구를 받치던 잔액을 들고 나갈 수 있었다. 레버가 잠들어 있어서 잠재 상태였고, 이제 의미가 없어졌다.

⚠️ 컨트랙트 재배포가 필요하고, clone은 안 따라온다. 풀은 EIP-1167 clone이라 생성 시점 구현체에 고정되므로, 옛 구현체로 만든 풀은 레버를 갖고 있고 그 이벤트를 여전히 낼 수 있다. 폴드에는 이제 그 규칙이 없어서 잘못 세는 대신 던진다. 그 실패는 의도한 것이다. 그런 풀은 우회가 아니라 되돌림이 필요하다.

enum 라벨은 일부러 남긴다. money_event_kind에서 HELD_FROM_PARTNER / HOLDBACK_RELEASED를 빼려면 타입을 다시 만들고 컬럼을 다시 캐스팅해야 하는데, 뷰 넷이 money_events.kind를 읽고 그중 하나는 며칠 전 v3-126에서 다시 쓴 것이다. 라벨 둘을 지우려고 뷰 정의 넷을 마이그레이션에 복사하는 건 실질 위험과 무위험을 맞바꾸는 짓이다. CHECK로 쓰기를 막고, events.test.ts가 파일에 이미 있던 RETIRED 목록에 둘을 선언한다.

검증: 컨트랙트 테스트 453개 · infra 569개 · money 패키지 157개 · 빌드 가드 4종 · 리플레이 6건 통과 / 불일치 0 · ABI 재생성(115줄 감소)과 --check 통과.

참조: v3-128 · 마이그레이션 0183 · 23-money-path §6

📅 2026년 8월 12일 — KYC 경계가 관할권을 지어내고 신원 상태를 뭉갰는데, 온체인 게이트가 둘 다 곧이곧대로 받았다 (v3-127)

⏳ 자격증명은 그 경계에서 가장 약한 번역만큼만 믿을 수 있다

KYC 흐름 전체를 리뷰하다가 SBT 경계에서 결함 둘이 나왔고 모양이 같다. 하나는 아무도 확인 안 한 신원 사실을 썼고, 다른 하나는 구분을 잃어버린 값을 읽었다. 둘 다 오류를 안 냈다.

mintKYCSBTcountryCode 기본값이 'KOR'이었고, 호출자는 users의 미러가 비어 있지 않을 때만 그걸 넘겼다. 그래서 국가가 한 번도 안 정해진 홀더가 한국 관할권을 주장하는 자격증명을 받았다. 그 문자열은 온체인 투자자 게이트 둘(jurisdictionAllowed[jurisdictionHashOf(investor)]_isUsCountry)의 입력이고, 오프체인 게이트는 모르는 국가에 대해 닫는 쪽으로 실패한다. 양쪽이 위험한 방향으로 어긋났고, deposit()은 오프체인 게이트를 안 거치고도 부를 수 있다. 미러가 null인 채로 도달하는 라이브 경로가 둘이었다. applicant 조회가 실패한 GREEN 리뷰, 그리고 온체인 자가 복구로 승인된 행이다. 이제 국가는 기본값 없이 필수이고(빼면 컴파일 오류), 입금 게이트가 쓰는 같은 헬퍼로 해석하고, SumSub에서 한 번 백필하며, 국가가 안 정해지면 민팅이 닫히는 쪽으로 실패한다. 재인증 소각보다 앞이므로 거절이 홀더를 빈손으로 남기지 않는다.

POST /auth/verifyisValidKYC를 읽었는데 그건 kycStateOf == VALID라서, EXPIREDREVOKED가 미인증과 똑같은 false로 도착해 다운그레이드 갈래를 탔다. 회수된 홀더의 kyc_status = REJECTEDNOT_STARTED로 덮였고, isFinalRejectedREJECTED를 본다. 즉 로그인이 AML 차단을 지우고 KYC를 다시 열었다. 만료된 홀더는 GUEST/NOT_STARTED로 떨어졌는데, POST /yield-claims는 그걸 "KYC 없음"으로 읽는다. canRedeem이 명시적으로 허용하는 출금을 오프체인 게이트가 거절한 것이다(v3-28). 이제 resolveSbtSync가 네 상태 전부와 null을 받고 의견이 있는 컬럼만 쓴다. checkValidKYC는 삭제했다.

검증하다가 다섯이 더 나왔다. "진행 중인 민팅 하나"는 POST /kyc/mint-sbtforce 갈래에서만 검사했고 읽고 나서 쓰는 형태라, 평범한 경로가 두 번째 민팅을 큐에 넣어 첫 번째가 방금 민팅한 토큰을 태울 수 있었다. 이제 enqueueSbtMint 안의 원자적 선점이고 모든 호출자가 공유한다. isFinalRejected는 종류를 안 가리고 가장 최근 kyc_logs 행을 읽어서, RED/FINAL 뒤에 온 applicantDeactivatedreject_type 없는 행을 위에 올리면 종국 차단이 안 먹었다. .single()은 읽기 실패와 삭제된 사용자를 구분 못 하게 했고 둘 다 영구였다. 갱신이 옛 sbt_expires_at을 유지해서, 재민팅이 실패하면 reverify 스윕이 이미 태운 자격증명을 인용했다. 그리고 KYC 폴링의 종료 상태 목록에 NOT_SUBMITTED가 빠져 있어서, KYC를 시작한 적 없는 로그인 방문자 전원이 10초마다 영원히 폴링했다. 호출마다 쿼리 셋과 RPC 하나다. 이제 술어가 기다리는 상태를 열거한다.

🔴 기존 데이터. 국가 미러가 비어 있을 때 민팅된 SBT는 온체인에 KOR을 달고 있고 force 재민팅이 필요하다. 온체인 속성은 어떤 마이그레이션으로도 못 고친다.

같이 나간 것: /kyc/access-token/kyc/reuse가 공유하는 검증 게이트 하나(reuse는 승인된 홀더를 전부 거절하고 있어서, 만료된 홀더가 SDK로는 갱신되는데 자기가 들고 온 파트너 applicant를 임포트해서는 안 됐다), applicant를 가져오는 모든 경로에서 통일된 applicant 미러, 그리고 0183(두 경로 모두 kyc_logs를 최신순으로 읽는데 admin 목록이 인덱스 없이 테이블 전체를 정렬하고 있었다).

📅 2026년 8월 12일 — 드롭된 테이블의 기본값이 살아남아서, 상환 뷰가 "펀딩 대기"를 완료로 부르고 한 번도 안 잡힌 요청을 전부 버렸다 (v3-126)

⏳ 이벤트에서 유도한 상태는 어느 이벤트를 읽느냐만큼만 정확하다

admin Redemptions 화면이 "아직 절반만 그려진다"고 제보됐다. 아니었다. 화면은 완성돼 있었고 그 아래 데이터가 두 가지로 틀렸는데, 둘 다 money_redemption_list가 드롭된 redemption_requests에는 참이고 money_redemption_workflow에는 거짓인 가정을 물려받았기 때문이다.

status 갈래가 WHEN fund.id IS NOT NULL THEN 'PENDING_RESERVE'를 읽었는데, 여기서 fundREDEMPTION_FUNDED 이벤트다. 그건 fundRedemption()이 내보내고, instant 경로는 요청이 온체인에서 이미 PENDING_RESERVE일 때만 그걸 받는다(RedemptionLib.sol:566). 그러니까 기다림이 끝난 순간을 기다림 자체로 라벨링하고, 진짜 기다림은 REQUESTED로 라벨링했다. 정말로 기다리는 상태에는 원장 이벤트가 아예 없다. RedemptionPendingReservemoney_event_kind가 아니다. 그래서 유일한 증인은 funding_shortfall인데, 뷰가 이미 조인해 놓고 안 읽던 컬럼이다. 결과적으로 PENDING_RESERVE에 걸린 모든 것이 원하던 집합의 여집합을 봤다. 펀딩 히어로, 의무 타임라인, 풀별 분해, Fund-all 목록, 그리고 /dashboard/stats의 플랫폼 "펀딩 대기" 합계다. PROCESSINGCASE에 아예 없어서 deriveFundingStatus의 살아 있는 갈래 하나가 죽은 코드가 됐다.

별개로 funding_status는 이전 과정에서 DEFAULT 'FUNDED'를 잃고 지금은 nullable이며 명시적 admin 라이터가 셋뿐이라, 평범한 새 요청은 NULL을 갖는다. 그래서 목록 엔드포인트의 funding_status <> 'HELD'가 NULL로 평가되고 WHERE가 그걸 버린다. epoch OpenRollover 큐가 일반 요청에 대해 아무것도 안 돌려주는 동안, 그 옆의 수요 집계는 별도 SQL RPC라서 LP를 계속 보고했다. 행이 하나도 없는 합계다. 호출자마다 술어를 두지 않고 뷰의 is_held 불리언으로 고쳤다.

검증하다가 같은 계열이 둘 더 나왔다. 요청별 FM 알림이 부족액으로 payout_amount를 보냈는데, 정의상 아직 지급 안 된 요청이라 null이다. Number(null) = 0이라서 "[Action needed] Reserve shortfall … needs 0 USDC top-up"이 나가고 있었다. 그리고 readPoolHolderslp_balance > 0으로 걸렀는데, LP엔 맞고 수익엔 틀리다. 쌓인 accrued_yield는 전액 이탈 후에도 남고 claimYield엔 LP 전제조건이 없다. 그래서 영수증의 미청구 계기가 낮게 읽혔고, 수익 대사기는 그 홀더들을 체인과 대조조차 안 했다.

같이 나간 것: GET /redemption-requests/instant-summaryPOST /pools/{id}/notify-fund. admin 화면 넷이 disabled + 사유를 달고 기다리던 엔드포인트 둘이다.

🔴 배포 순서. 람다가 is_held로 필터하고 PostgREST는 뷰에 없는 컬럼에 400을 주므로, 0180이 먼저 가지 않으면 빈 큐가 오류가 된다.

문서: v3-126. 마이그레이션 0180_redemption_list_status_and_hold_flag.sql. 출처: JY, 2026-08-12.

📅 2026년 8월 12일 — 포트폴리오가 스키마가 금지하는 수익을 약속하는 걸 그만두고, 돈이 언제 돌아오는지 말한다 (v3-125)

⏳ 원금은 액면에서 상한이 걸리므로 포지션 델타는 상각이고 수익은 yield strip이다

/portfolio를 합의된 정보 구조(요약 → 유동성 → 포지션 → 최근 활동 → 발견)로 다시 만들었는데, 그러다 화면이 데이터베이스가 금지하는 결과를 중심으로 설계돼 있다는 게 드러났다. pools.nav_per_token에는 CHECK (<= 1.000000)이 걸려 있고 nav_history.new_nav도 마찬가지다. NAV는 원금만 값 매기고 수익은 따로 청구하기 때문이다. 입금은 1.0에 민팅되므로 tokens × NAV가 취득원가를 넘을 수 없는데, 히어로와 Value 컬럼과 행 상세가 전부 투자수익으로 읽히는 라벨 아래 초록색 +$0.00 (+0.0%)를 칠하고 있었다. v3-125는 이 수치를 원금 변동으로 이름 바꾸고, 변동 없음을 말로 적고("액면 그대로, 변동 없음"), 실제로 도달하는 한 경우를 위해 양수 갈래를 남긴다. 전입이나 세컨더리 보유로 액면 아래에서 들어온 경우인데, 이제 행이 불가능해 보이는 숫자 대신 이유를 적는다.

FE가 믿고 있던 필드 둘이 존재하지 않는다. nextSettlementAtexpectedFillPctPoolRow엔 있는데 어느 마이그레이션에도 없고 두 pools select 어디에도 없다(grep next_settlement_at apps/infra → 0건). 그래서 둘 다 영구히 null이다. 상환 다이얼로그가 앞엣것을 읽어서 모든 epoch 풀에 대해 "N일마다"로 폴백했다. 확정된 funding date가 있는 풀까지 그랬다. 진짜 앵커는 next_funding_date이고 LIST_SELECT가 그걸 나른다. 포트폴리오는 그걸 예상 으로 배지 붙인다. 확정 으로 만들어 줄 출처 컬럼 둘이 상세 전용이기 때문이다. 지나는 김에 고친 것들: 회차별 상한이 v3-73에 어긋나게 원시 bps로 공개되고 있었고, NAV 표시는 이제 어디서나 소수 둘째 자리다. position-card.tsxcomputePoolRiskTier가 이미 그러던 것과 맞췄다.

별개로 크래시 한 종류를 닫았다. Intl.DateTimeFormat.formatToParts()는 유효하지 않은 날짜에 RangeError를 던지고 날짜 포매터는 렌더에서 바로 호출되므로, 어느 페이로드든 파싱 안 되는 타임스탬프 하나가 루트 에러 바운더리까지 가서 앱 전체를 "Something Went Wrong"으로 갈아 치웠다. 셸도 페이지도 없고 어느 필드인지 말해 주는 것도 없었다. 이제 나쁜 값 하나는 대시 하나로 끝난다. 유동성 타임라인도 유한하지 않은 타임스탬프로 마커를 내보내는 걸 멈췄다. 정산과 만기 갈래엔 가드가 있었고 락업엔 없었다.

일부러 없는 것. 지갑 유휴 잔액 없음(잔액 훅이 없다), 포트폴리오 가치 추이 차트 없음(풀별 nav-history는 있는데 집계 엔드포인트가 없고, 클라이언트에서 더하면 보유 기간을 무시하게 된다), 수익의 이자·수수료·상각 분해 없음(claimable_yield는 이미 순액), 지갑별 보유 없음(money_positions는 사용자로 키가 잡힌다). 대신 LP 상태 분해를 넣었다. 가용 / 상환 중 / 잠김 / 전액 상환 가능이다.

구성 색은 고른 게 아니라 검증기를 돌렸다. 단일 색상의 순서형 램프(brand-700/500/400/300)다. DS에 색각 이상 분리를 통과하는 범주형 세트가 없고, 상태 색상은 자산군에 좋은 소식과 나쁜 소식으로 읽히기 때문이다. 이전의 600/500/400은 인접 명도 검사를 통과 못 했고, 세그먼트가 구분이 안 되던 이유가 정확히 그것이다. 네 단계가 상한이므로 다섯 번째 그룹은 크기 기준으로 "기타"에 접힌다. 동작을 주장하는 문구는 v3-96에 따라 shared/copy/portfolio.ts로 갔고, /investor-activity 행 → 원장 매퍼는 shared/lib으로 옮겨서 Activity 페이지와 포트폴리오의 최근 활동 섹션이 payout 대 LP 금액 규칙에서 어긋날 수 없게 했다.

검증: tsc -b 통과, 프로덕션 빌드 통과, 문구 가드 통과, 테스트 26개 통과. BE 변경 없음, 마이그레이션 없음, 컨트랙트 변경 없음. 포지션 상태 넷이 한 계정에 공존할 수 없어서 /styleguide/portfolio가 실제 위젯을 픽스처에 대고 렌더한다. 아직 열려 있는 것: 존별 행 수 불균형은 미관 문제이고 미해결이며, 새 위젯들의 모바일 레이아웃은 좁은 폭에서 리뷰 안 했다.

문서: v3-125. 출처: JY(제품), 2026-08-12.

📅 2026년 8월 12일 — "한 달"에 뜻을 하나만 주고, epoch 위저드가 회차의 3분의 2를 감추는 걸 그만둔다 (v3-124)

⏳ 시간 기준 셋에 이름을 한 번 붙여서 28 대 30 논쟁이 반복되지 않게 한다

"1개월 락업은 28일인가 30일인가"가 여러 번 다시 다뤄졌고, 매번 둘 중 하나가 틀렸다는 가정에서 시작했다. 어느 쪽도 안 틀렸다. v3-124는 대신 장치 셋에 이름을 붙인다. epoch 회차는 고정 28 / 84일이다. 컨트랙트가 epochDurationDays × 1 days로 전진하고 달력 계산을 안 하기 때문이다. 수익 분배는 달력 월이고 1년에 12번이며 월말 클램프가 걸린다. 락업·만기·기간 표시는 한 달을 30일로 본다. 실행 규칙은 이렇다. 28은 정확히 한 곳에만 있고, "월"이라 불리는 값은 전부 30이다. 대조표는 26-glossary → month에 있고, 닮은꼴인 분모 충돌 옆이다.

상수는 하나도 안 움직였다. 주기는 28 / 84 그대로, 수익은 달력 그대로, firstYieldIntervalDays 하한은 30 / 90 그대로, epoch_schedule_typeMONTHLY / QUARTERLY 값을 유지한다. admin 주기 선택기가 이제 Every 28 days로 읽히고, 그게 "월"을 한 뜻으로 풀어 준다. 락업과 만기에는 개월/일 단위 토글이 생겼고 경계에서 한 번만 변환한다. percent → bps와 같은 방식이다.

회귀 둘은 나중에 다시 발견되도록 두지 않고 가드로 적어 뒀다. firstYieldIntervalDays를 28로 내리면 수익이 발생하기 이틀 전에 락업이 끝나므로 YIELD_BASED 조기 이탈이 0원을 몰수한다. v3-84의 구멍이다. 주기를 30 / 90으로 옮기면 요일 안정성을 잃고(30 mod 7 = 2), MAX_EPOCH_DURATION_DAYS에 정확히 걸치며, setEpochSchedule이 생성 전용이라 자산군이 갈린다.

그리고 epoch 타임라인이 28일 중 17일만 그리고 있었다. funding date에서 멈춰서, 마치 회차가 윈도 + 리드이고 다음 회차가 지급일에 시작하는 것처럼 읽혔다. 아니다. 다음 윈도는 온전한 한 주기 뒤에 열리므로, 월간 프리셋은 28일마다 7일 요청을 받고, 지급 뒤에는 아무도 안 들어가고 아무것도 안 보여 주던 11일짜리 닫힌 구간(주기 − 윈도 − 리드)이 따라온다. 이제 위저드가 한 회차 전체를 걸치고 그 남는 구간에 이름을 붙인다. 07-redemption의 ASCII 도해도 같은 식으로 고쳤다. 거기선 리콜 리드를 "the gap"이라 부르고 남는 구간은 아예 빠져 있었다. 파생값 버그 하나가 딸려 나왔다. "가장 이른 앵커" 힌트가 now + span을 말했는데 그 윈도는 그 순간에 열리고 곧바로 이미 지났다고 거절당하므로, 폼이 거부하는 날짜를 부르고 있었다. 이제 실제로 받아들여지는 첫 날짜이고, 인라인 수정도 같은 값을 쓴다.

검증: admin-web tsc -b 통과, 프로덕션 빌드 통과, 문구 가드 통과(228 파일), 테스트 150개 통과(신규 18개. 남는 구간, 다음 윈도 유도, 개월 변환기의 왕복을 덮는다). 새 타임라인 유틸리티가 조용히 투명하게 렌더되는 대신 빌드된 CSS에 실제로 방출되는지도 확인했다. BE 변경 없음, 마이그레이션 없음, 컨트랙트 변경 없음. WEEKLY는 문서화된 yield_frequency 집합에서 일부러 뺐다. 백엔드는 인식하지만 제품에서 그걸 설정할 수 있는 게 없다.

문서: 26-glossary · 07-redemption · 04-pool-models · v3-124. 출처: JY(제품), 2026-08-12.

📅 2026년 8월 12일 — Yield 리뷰가 값 매긴 세트 전체를 서명할 수 있게 됐고, 홀더가 지급받았다고 주장하는 걸 그만둔다 (v3-123)

⏳ 돈 경로 둘이 러너 하나를 쓰고, 카운터는 정산을 부풀리지 않는다

Yield 리뷰는 도래한 풀마다 값을 다 매겨 놓고 영구히 비활성인 버튼에서 끝났다. 툴팁은 운영자 한 명이 세트 전체를 서명할 수 없다고 했다. 8월 11일부로 그건 더 이상 이유가 아니다. A-2가 목록을 돌며 항목마다 서명하는 러너를 냈고, 모달은 원래 FM 전용이라 펀드 지갑 하나가 자기 풀 전부를 서명할 수 있다. v3-123use-fund-all-flow를 포크하지 않고 YIELD_DEPOSIT kind를 얹는다. 루프, 항목별 상태 기계, ref 기반 이중 지급 가드, 항목별 체인 전환, 서명자 사전 검사는 도메인과 무관하고 상환 펀딩에서 이미 증명됐다. 다른 건 풀 함수와, 수익 기록에 서버 호출이 필요하다는 사실뿐이다.

수익 경로가 일부러 상환 경로와 다른 지점이 셋이다. 0원은 완료가 아니라 실패로 처리한다. instant 펀딩은 0을 일부러 done으로 표시하지만(리저브는 이미 움직였고 다시 보내면 아무도 안 받을 돈을 지급한다), 0인 수익 행은 FM이 값을 안 매긴 행이고 done은 재시도를 억제하는 상태다. 실행을 열 때 계획을 스냅샷으로 잡는다. 분배를 기록하면 도래 큐가 무효화되기 때문이다. 실시간으로 읽으면 성공할 때마다 스테퍼에서 자기 행을 지우게 되고 전체 실행이 빈 다이얼로그로 끝난다. 그리고 정산이 실패한 예치는 done으로 두고 메시지가 운영을 가리킨다. 예치는 비가역이고 failed는 두 번째 예치를 부르기 때문이다.

마지막 경우가 진행 카운터를 "분배됨"이 아니라 "예치됨" 으로 적는 이유다. done에는 정산이 실패한 항목이 들어 있으므로, "4건 중 2건 분배됨"이 홀더가 지급받지 않았다고 적힌 행 바로 위에 앉게 된다. 정산을 낮춰 말하는 건 복구할 수 있고, 부풀려 말하는 건 그 정체 메시지가 막으려는 실패 자체다.

여기서 같이 고친 것: 13-operations가 정체된 분배 알림을 "미구현, 문구 대기"라고 적고 있었다. 8월 4일에 나갔다(yield.scheduler.stalled.ts, 일 단위 버킷 멱등성, 복구가 ORACLE_ROLE 게이트라서 opsTeam만). 인앱과 이메일 문구 둘 다 있다.

⚠️ 돈 경로인데 검증 없이 나갔다. tsc -b 통과(admin-web + web), admin-web 빌드 통과, 문구 가드 통과(227 파일. v3-96에 걸리는 "records list"와 산문 em-dash를 잡아냈다), 렌더 상태 넷에서 콘솔 오류 없음, Redemptions 다이얼로그 변화 없음(Fund all $16,400 / 1 of 3 funded). 서명 경로는 한 번도 안 돌려 봤다. 도래한 풀의 펀드 지갑을 쥔 FM 지갑, dev 스테이블코인, 도래한 기간이 필요하다. 그걸 마음대로 만들 수 없어서 /preview/yield가 있고, 위의 카운터 결함을 잡아낸 게 그것이다. BE 변경 없음, 마이그레이션 없음, 컨트랙트 변경 없음. FE만이라 FE 파이프라인이 실제로 배포한다. 첫 실사용은 큐 전체가 아니라 도래 기간 한둘로 할 것.

문서: 13-operations · v3-123. 출처: JY(제품), 2026-08-12.

📅 2026년 8월 7일 — Yield가 읽는 표가 아니라 처리하는 큐가 된다 (v3-120, v3-121)

⏳ 도래 큐가 일주일 먼저 시작하고, 실제로 늦은 행이 어느 것인지 말한다

due-rows.tsnext_yield_due < now로 골라서, 기간이 작업 큐에 처음 나타나는 날이 이미 연체된 날이었다. 펀드 지갑에 돈이 있어야 하고 온체인 서명이 필요한 작업인데도 그랬다. v3-120이 이걸 now + 7d로 넓히면서, 알림 파이프라인(3일 지나 시작해 7일마다 재발송)과 겹치지 않고 일부러 그 에 앉게 했다.

위험은 윈도가 아니라 큐 전체를 합산하던 수치 둘이었다. 노출 카드와 "N개 풀 연체" 개수는 큐가 연체만 담고 있는 동안에만 맞았고, 큐를 넓히면 아직 안 갚아도 되는 돈이 연체라고 적힌 숫자에 접히면서 아무것도 실패하지 않았을 것이다. 이제 모든 도래 행이 is_overdue 를 나르고, 그게 유일하게 맞는 필터다. overdue_days는 0에서 클램프되므로 다가오는 기간과 오늘 도래한 기간이 둘 다 0을 보고한다. 클램프는 남긴다(CSV가 이미 D+${overdue_days}로 나가고 있었다). 대신 그 컬럼을 getDDay로 렌더해서, 아직 안 늦은 것까지 "D+0"으로 내보내지 않는다.

🧾 새 읽기 둘: 어느 펀드를 쫓을지, 그리고 실제로 누가 받았는지

v3-121GET /yield-distributions/fund-summary(펀드당 한 행. 미지급액, 밀린 풀 수, 다음 도래, FM 통보 상태, 나쁜 순)와 GET /yield-distributions/{id}/investors(투자자별 분배와 풀의 미청구 잔액)를 넣는다. 둘 다 시스템이 이미 쓰고 있던 기록을 읽는 것이다. run-distribution.ts는 나온 이후로 홀더별 분배를 계속 저장했는데 아무도 다시 안 읽었다. 롤업을 브라우저에서 그룹핑하지 않고 서버에서 하는 이유는, 화면이 노출 카드와 펀드별 합계를 나란히 렌더하기 때문이다. 같은 돈을 독립적으로 두 번 합하면 갈라진다.

🔴 FM 통보 상태에는 컬럼이 없고, 컬럼처럼 보이는 필드 둘은 둘 다 틀렸다. yield_distributions.fm_notified_at은 분배가 실행될 때 찍히므로, 그 컬럼이 설명하려는 미지급 기간에 대해서는 정확히 null이다. POST /pools/{id}/escalate-yield는 DB 행을 아예 안 쓴다. 기록은 에스컬레이션이 남기는 notification_events 행이다. 그런데 그것만 읽었으면 거짓이 나갔을 것이다. dispatch.ts가 수신자를 정하기 전에 이벤트를 넣으므로, FM이 없는 펀드를 에스컬레이션하면 아무도 못 받은 메시지에 "통보됨" 흔적이 남는다. notifications를 조인하면 UNREACHABLENOTIFIED가 갈리고, 그 컬럼이 존재하는 이유가 그것이다.

죽은 컬럼 셋을 죽은 것으로 확인했다. yield_distribution_investors.investor_name / .investor_address는 라이터가 없고(이름은 users 조인에서 온다), .claimed_at도 없다. 그래서 영수증의 청구 계기가 풀 단위 누적 이고 portfolio_positions.accrued_yield 위에 서 있으며 그렇게 라벨돼 있다. 회차별 청구 상태는 어디에도 기록이 없고, 지어내지 않는다.

🎭 화면이 역할로 갈린다. 둘 중 하나만 돈을 낼 수 있기 때문이다

admin과 operator는 펀드 지갑을 쥐지 않으므로 depositYield를 서명할 수 없다. 그런데 헤더의 Distribute 버튼, 행 액션, 배치 리뷰가 전부 그들에게 제공되고 있었고 각각 막힌 제출에서 끝났다. 이제 FM은 Due now 카드(연체 먼저, 펀드 지갑 잔액을 온체인으로 읽음)와 Upcoming 타임라인을 보고, admin은 연체 노출 카드와 펀드별 표를 보며 모든 admin 액션은 Escalate to FM이다. 예외 하나는 그대로다. settleYield로 정체된 PROCESSING 행을 복구하는 것이고 그건 Aset의 ORACLE_ROLE이다. 용어는 v3-96을 따른다. "Record"는 없어졌고 액션은 Distribute 하나이며 "서명"은 화면에 안 드러낸다.

문서: 13-operations · 15-api-reference · v3-120 · v3-121. 검증: tsc -b 통과(admin-web), 문구 가드 통과(212 파일), 알림 프로듀서 가드 통과, infra 테스트 445개 초록. 스키마 변경 없음, 마이그레이션 없음, 컨트랙트 변경 없음. 두 엔드포인트는 읽기이고 is_overdue는 read model에서 계산한다. ⚠️ 엔드포인트 둘과 리드 윈도는 백엔드라서 pnpm --filter @aset/infra deploy:dev:api가 필요하다. FE 파이프라인이 초록이어도 이건 안 나간다. 출처: JY(제품), 2026-08-07.

📅 2026년 8월 6일 — FM이 epoch 회차를 펀딩할 수 있게 됐고, KYC 게이트에 대한 주장 둘이 틀린 것으로 밝혀졌다 (v3-119)

💸 컨트랙트는 이미 회차를 펀딩하고 있었고, request id가 필요했던 건 API뿐이었다

epoch 풀에서 Pool.fundRedemption은 넘겨받은 요청을 아예 안 건드린다. epoch 갈래는 epochFundTopUp[currentEpochId]에 적립하고 반환하며, requestId는 내보내는 이벤트에서만 쓴다(RedemptionLib.sol:444-459). 거기로 가는 유일한 경로가 POST /redemption-requests/{id}/fund였으니 회차 단위 송금에는 정직한 대상이 없었고, admin Epochs 탭은 FM의 유일한 액션을 비활성 으로 내보내며 요청 행을 가리키는 안내를 달았다. POST /pools/{id}/epoch-funding (v3-119)이 그걸 닫는다. 수탁 경로는 전과 같은 둘이다. amount(플랫폼 키. fundRedemptiononlyRole(YIELD_DEPOSITOR_ROLE)이라 펀드 지갑이 그 키인 경우에만 쓸 수 있다) 또는 fund_tx_hash(FM 서명. 영수증을 믿는 대신 성공했고 이 풀을 건드렸는지 검증한다). requestId0으로 나간다. 오프체인에서 RedemptionFunded를 소비하는 게 없고, 남의 id를 빌리면 로그가 요청이 펀딩됐다고 주장하게 되는데 그런 요청은 없다.

redemption_requests에는 아무것도 안 쓴다. 적립은 회차에 속하므로, 큐에 있는 N개 행에 펀딩 tx 하나를 찍으면 같은 걸 N번 잘못 귀속시키고 투자자 각자의 요청 해시를 파트너 송금 해시로 덮어쓴다. 기록은 새 POOL_EPOCH_FUNDED 활동 이벤트와 체인이고, 응답이 회차를 다시 읽어 줘서 호출자가 "펀딩했지만 아직 $X 부족"과 "펀딩했고 충분"을 구분할 수 있다. 그 읽기가 실패하면 숫자를 지어내는 대신 snapshot_unavailable: true를 준다. 부분 펀딩은 계속 지원한다(모자라게 들어오면 pro-rata로 채우고 이월된다). 그래서 컨트롤 기본값은 부족액이고 낮춰 고칠 수 있다.

🔴 앵커 있는 풀의 배포 실패는 가스 추정에 있었고 앵커와는 무관했다. 위저드에서 배포한 epoch 풀이 setEpochScheduleInvalidSchedule()로 리버트하며 죽었고, 통설은 앵커가 온전히 한 주기 뒤에 있어야 한다는 컨트랙트 규칙이었다. 그런 규칙은 없다. 네 번째 가드는 절대 타임스탬프와 초 단위 기간을 비교하므로(17일 ≈ 1.47e6 대 앵커 ≈ 1.79e9) 실제 날짜에는 발동할 수 없고, 위저드는 이미 더 엄격했다. 실제로 발동한 건 recall + window >= epochDurationDays17 >= 0 으로 읽힌 것이다. s.epochDurationDays에는 라이터가 정확히 하나(GovernanceLib.sol:113)인데 clone의 initialize는 거기 안 든다. create-pool.ts는 그 세터를 먼저 큐에 넣었고 주석에도 그렇게 적었지만, 순서는 실행 만 지배했다. 모든 설정 호출이 설정 안 된 clone을 상대로 병렬 가스 추정을 받았고, 추정은 실제 호출과 똑같이 리버트한다. 그래서 배포가 사전 점검에서 죽었고 트랜잭션은 안 나갔으며 영수증 흔적은 엉뚱한 호출을 가리켰다. 첫 수정은 사전 점검을 단계로 나눴는데(호출이 stage를 갖고 단계는 순차로, 한 단계 안의 호출은 계속 파이프라인), 아래의 in-flight 한도 발견 때문에 머지 시점에 폐기됐다. ops 키가 EIP-7702 계정이라 대기 중 트랜잭션이 하나로 제한되므로, 단계로 나눈 배치도 완전 병렬과 똑같이 제출에서 실패한다. 이제 createPoolOnChain완전 직렬 이다. 추정, 전송, 대기, 다음. 각 호출이 자기 전송이 실제로 실행될 상태에 대고 측정되므로 원인 둘을 장치 하나로 고친다. ConfigCall.stage / planConfigStages는 중복이라 뺐고, buildEpochConfigCalls는 남겼다. 이제 배열 순서가 시뮬레이션까지 묶기 때문이다. 추정 실패는 함수 이름과 "tx 안 보냄"을 달아 다시 던지고, 배포 후 재확인이 epochDurationDays도 단언한다.

일부러 그대로 둔 것: deploy_status = DEPLOY_FAILED 옆의 lifecycle_status = ACTIVE는 드리프트가 아니라 의도된 짝이다. lifecycle은 발행 의도이고 deploy_status는 체인이 따라왔는지이며, Retry Deploy가 원래 대상에 착지하려면 앞엣것이 보존돼야 한다(pools.post.lifecycle.ts:62-78). 이미 봉쇄돼 있다. pool_address는 성공했을 때만 쓰고, 투자자는 API와 클라이언트 양쪽에서 걸러지며, admin 칩은 lifecycle 칩보다 앞에 Deploy failed 를 보여 준다.

🔴 KYC 이탈 게이트에 대해 믿고 있던 것 둘이 틀렸다. epoch 청구는 RedemptionBlockedByKyc로 리버트하지 않는다. KYCRequired를 낸다(RedemptionLib.sol:1049). RedemptionBlockedByKyc는 instant 정산 깔때기(:544)와 무권한 폴백(:416)의 것이다. 틀린 이름에 대고 매핑을 추가하면 컴파일되고 배포되고 조용히 영영 안 발동한다. 그리고 NONE은 게이트에서 아예 문서화된 적이 없었다. canRedeemVALID || EXPIRED이므로(PlatformKYCSoulbound.sol:371-373) NONEREVOKED와 똑같이 거절되는데, v3-19는 EXPIRED 통과 / REVOKED 차단 짝만 적었다. 03-kyc-identity가 이제 네 상태 전부와 경로별 리버트 표를 담는다.

이번 회차에 투자자 쪽에서 같이: 상환 모달이 "정산 전 언제든" 취소를 약속하고 있었는데 컨트랙트는 두 겹으로 거절한다(윈도 게이트 + ClaimBeforeCancel). 실제 규칙으로 바꿨고, 요청이 LP를 에스크로해 발생을 멈추지만 이미 번 수익은 계속 청구 가능하다는 A10 문장을 함께 넣었다. IMPAIRED 페이지 배너는 "상황을 모니터링 중"이라는 말 대신 손실 인식과 락업 면제를 적는다.

문서: 15-api-reference · 03-kyc-identity · v3-119. 검증: web / admin-web / infra 전부 tsc 통과, 문구 가드 통과(170 / 200 / 342 파일), 알림 프로듀서 가드 통과, 테스트 531개 초록(web 22 · admin 97 · infra 412). 새 활동 이벤트 타입 POOL_EPOCH_FUNDED. 스키마 변경 없고 컨트랙트 변경 없음. 출처: JY(제품), 2026-08-06.

📅 2026년 8월 6일 — 04-pool-models가 훑어볼 수 있게 됐다. 모든 차원의 색인과, 접히는 깊은 부분 (v3-118)

🗂 레퍼런스 벽을 색인 + 펼침으로

04-pool-models는 이제 문서화된 설정 29개를 전부 나열한 표 하나로 시작한다. 각각 무엇을 통제하는지 한 줄, 가변성, 상태, 그리고 자기 정의로 가는 링크다(v3-118). 어떤 설정이 있는지 아는 데 카테고리 표 일곱 개와 하위 절 예순 개를 스크롤할 필요가 없어졌다. 그 아래로 깊은 장치 블록 여섯 개와 긴 YAML 예제 셋이 <details> 뒤로 접힌다.

잘라낸 건 없다. v3-114가 이미 04의 고고학을 주인들에게 옮겼고, 이번은 표현만 바꾼 것이다. <details>는 펼침일 뿐이라 모든 줄이 페이지와 DOM에 남고 Ctrl-F와 사이트 검색이 여전히 닿는다. diff는 83줄 추가에 11줄 삭제이고, 그 열하나는 전부 아래 설명하는 수정 둘의 것이다.

접은 것: 트랑슈 그룹핑 장치 · 입금 흐름의 번호 붙은 8단계(바로 위 도해를 다시 말한다) · wind-down ASCII 타임라인(mermaid를 다시 말한다) · 60+30일 근거 · 위험 등급 근거와 단계 표 · 큰 YAML 설정 셋. 예제 3은 열어 뒀다. 설정 두 줄은 접을 값어치가 없다.

앵커가 제약이었다. 네이티브 <details>:target에 안정적으로 안 열리므로, 모든 제목과 <a id> 별칭 둘은 접힘 밖에 두고 본문만 감쌌다. 04에는 들어오는 앵커가 26개인데(#emergency-wind-down 하나에만 13개) 빌드된 HTML에서 전부 존재를 확인했고, 새 색인 링크 31개도 확인했다.

🔴 낡은 것 둘이 드러나서 고쳤다. 카테고리 불릿 일곱 개가 개수를 달고 있었는데(Asset Configuration (7), Operations / Advanced (1)) 그중 다섯이 자기 표와 안 맞았다. 다시 타이핑하지 않고 없앴다. 색인이 같은 걸 표현하고 표에서 만들어지므로 표와 어긋날 수가 없다. 그리고 wind-down mermaid 라벨이 아직 NAV = distributable/totalSupply로 읽혔다. 서른 줄 위 같은 절의 산문에 있는 맞는 공식과, 2026-08-04에 배포된 컨트랙트와 모순이다(v3-116). 이제 distributable / claimingSupply로 읽는다.

흐름 절 셋은 다른 페이지가 소유한 내용이 있는지 다시 봤는데, 모든 체크포인트가 이미 밖으로 링크하고 있어서 손대지 않았다.

문서: 04-pool-models · 14-decisions v3-118. 검증: 빌드 초록, check-doc-anchors 1304개 검사 / 2개 깨짐(둘 다 허용 목록) / 예상 밖 0. 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-06.

📅 2026년 8월 6일 — 거버넌스 타임락이 아무도 실행 안 한 채 만료될 수 있었고, 무권한 wind-down은 DB에 닿은 적이 없었다 (v3-117)

⏳ impairment도 wind-down도 스스로 도는 게 없다. 알림도 미러도 NAV에만 있었다

NAV 인하는 저절로 적용된다. nav-changes.scheduler.apply-pendingapplyPendingNavOnChain을 부르는 스케줄 람다다. impairment와 wind-down에는 대응물이 없고, 닮았다는 것 자체가 이걸 못 보게 만들었다. executeImpairmentOnChain / executeWindDownOnChain은 호출자가 각각 하나씩인데 자기 POST 핸들러의 action === 'execute' 갈래이고, 두 핸들러 모두 HTTP paths:로 등록되며 schedule:은 없다. 그래서 7일이나 30일 타임락이 다 지나도록 풀이 제안 전 상태에 머물 수 있었고, 결정이 실행된 적 없다고 말해 주는 게 아무것도 없었다. 새 opsTeam 이벤트 둘, impairment_execute_due(D-1)와 winddown_execute_due(D-3)가 이제 시간별 스윕 pools.scheduler.governance-execute-due에서 나온다. 재촉만 하고 실행은 안 한다. impairment는 비가역이고 wind-down은 종착이라 둘 다 cron의 것이 아니다. 리드 윈도에 상한을 안 둔 건 의도다. 리드 기간만큼만 넓히면 impairment에는 cron 한 틱이라 실행 한 번 실패하면 알림이 사라지는데, 만료를 지나쳐 버린 제안이야말로 잡을 값어치가 있는 경우다. 한 번만 울리는 건 멱등 키({event}:{poolId}:{proposedAt})에서 나오고, 그 키가 취소 후 재제안에서 다시 무장한다.

executeWindDown()이 무권한인 건 의도이고 버그가 아니다. "고치지" 말 것. executeImpairment()onlyRole(DEFAULT_ADMIN_ROLE)인 데 반해 여기엔 롤 수식어가 없고, 그 비대칭은 문서가 정리하기 전에 보안 발견으로 제출돼 있었다. "T+90일 타임락 통과 → 누구나 Pool.executeWindDown()을 부를 수 있음" 이었다. 이미 승인되고 타임락이 걸린 행동을 누구나 트리거할 수 있게 하는 표준 패턴이다. 제안과 취소는 admin 게이트이고 실행자는 파라미터를 바꿀 수 없다. 수식어를 붙이면 문서화된 liveness 설계가 깨진다.

🔴 진짜 결함은 그 아래 있었다. 무권한 실행은 그 실행이 미러링될 때만 안전한데, 인덱서는 ImpairmentExecutedWindDownExecutedLifecycleStatusChanged도 안 보고 있었다. lifecycle_status = WIND_DOWN의 유일한 라이터가 BE 실행 갈래였다. 그러니 외부인이 온체인에서 풀을 청산시켜도 DB는 계속 ACTIVE로 읽히고, UI는 컨트랙트가 리버트하는 입금을 계속 제공한다. v3-92 계열이고 이번엔 실제 트리거가 있다. 이제 셋 다 감시한다. *_executed 라이터 둘은 핸들러의 똑같은 멱등 키를 재사용하므로, API를 거치면 no-op으로 중복 제거되고 우회한 경우에만 실제로 발송된다. is_paused는 일부러 안 건드린다(제3자 실행은 unpause()를 안 보내므로 미러는 풀이 pause라고 계속 말해야 한다). pool_updates 피드 항목은 인정된 잔여물이다. createSystemPoolUpdate에 멱등 키가 없어서 여기서 부르면 API 경로에서 항목이 중복된다.

서식 수정 둘이 딸려 왔고 둘 다 사용자에게 보인다. 7일 대 30일 기간이 네 군데에 흩어져 있었고 Solidity 상수와 이어 주는 게 없었다. 그래서 winddown_proposed_ops가 30일 윈도에 대고 펀드매니저에게 "7일 타임락이 적용된다"고 말하게 됐다. 언제까지 이의를 제기해야 하는지 말하는 게 목적인 그 알림 하나에서 23일을 줄여 말한 것이다. 이제 두 핸들러가 business/governance-timelock.ts를 임포트하고, 새 *_execute_due 짝은 기간을 부르는 대신 계산된 만료를 렌더한다. 별개로 nav_change_proposed가 원시 NUMERIC 컬럼을 '{oldNav} → {newNav}'에 그대로 넣고 있어서 투자자가 0.9166666666666666 → 0.85를 읽었다. 이제 notifications/format-nav.ts가 NAV 이벤트 여섯 개 전부의 단일 NAV 포매터다.

아직 열려 있는 것: ImpairmentProposed / WindDownProposed는 감시 안 하므로, 온체인에 직접 올린 제안은 제안 알림에도 실행 도래 스윕(DB 컬럼을 읽는다)에도 안 보인다. 검증: 문구 · 프로듀서(키 66개) · 단위 가드 통과, tsc 통과, 테스트 412개. 문서: 22-notifications · v3-117. 스키마·컨트랙트 변경 없음. 출처: JY, 2026-08-06.

📅 2026년 8월 6일 — 서버 트랜잭션은 한 번에 하나. ops 키가 EIP-7702 계정인데 아무도 몰랐다

⛓️ `--slow`가 기본값이 된다. 스크립트에서도 백엔드에서도

forge script 배포가 -32000: in-flight transaction limit reached for delegated accounts로 실패했다. 원인은 RPC가 아니다. 배포 키 0xdcd8662E…7764EIP-7702 위임 이 걸려 있고(코드가 0xef0100 + 0x63c0c19a…e32b. 스마트 계정 업그레이드가 설치했다), 노드는 위임된 계정의 대기 트랜잭션을 하나 로 제한한다. DeployPoolImplementation은 여섯 개를 보낸다. 링크된 라이브러리 다섯에 구현체 하나다. 그래서 두 번째가 거절됐다. 아무것도 안 착지했으므로(nonce 그대로, 주소 여섯 개 전부 코드 없음) 깨끗한 재시도였다.

그 키가 ADMIN_PRIVATE_KEY이기도 하다. 같은 주소가 모든 서버 측 트랜잭션에 서명하므로 이 한도는 배포 불편이 아니라 프로덕션 문제이고, 게다가 안 보였다. isRetryableNonceError가 이 메시지에 안 걸려서 겹친 쓰기가 기다리는 대신 곧바로 실패했다.

  • 스크립트: script/run.sh가 이제 단일 진입점이다(pnpm deploy:pool-impl, deploy:factory, create:pool 등). env 파일을 소싱하고 언제나 --slow를 넘기므로, 다음 배포를 돌리는 사람이 플래그를 잊을 수가 없다.
  • 백엔드: writeContractAndWait가 in-flight 거절을 제출 시점 오류로 보고 기다린다(6회, 약 12초 백오프. nonce 재시도와 예산을 따로 잡아서 한쪽이 몰려도 다른 쪽을 굶기지 않는다). 핸들러 안에서는 이미 영수증마다 await하고 있었고, 이건 공유 뮤텍스가 없는 동시 람다 호출 사이 의 쓰기를 직렬화한다.
  • 깨져 있던 풀 배포: createPoolOnChain은 생성 후 세터들을 일부러 파이프라인으로 돌렸다. nonce를 미리 순차로 계산해 한꺼번에 보내고 영수증을 함께 기다렸다. 한도 아래에서 그건 통째로 실패하고, 반쯤 설정된 clone에 가스를 낸 뒤다. 이제 한 번에 하나씩 추정하고 보내고 기다린다. 그러면 파이프라인이 가리고 있던 두 번째 결함도 고쳐진다. setEpochSchedulerecallLeadDays + requestWindowDays < epochDurationDays를 검증하는데(GovernanceLib.sol:210), duration이 아직 0인 clone에 대고 미리 추정하면 앵커 있는 epoch 풀마다 InvalidSchedule로 리버트했다(v3-107). 이제 가스는 각 tx가 실제로 실행될 상태에 대고 측정된다.

⚠️ 재시도는 안전망이지 해결이 아니다. ops 키에서 위임을 빼면 한도 자체가 없어지고, ops/배포 키가 스마트 계정일 이유가 없다. 그전까지는 둘이 겹칠 때마다 모든 서버 쓰기가 최대 12초쯤을 기다림에 쓴다.

admin 키 주변의 다른 Promise.all은 전부 감사했고 읽기뿐이라 한도가 안 건드린다. 코드: infra/lib/shared/contract/{client,create-pool}.ts · contract/script/run.sh · contract/package.json. 커버: lib/shared/contract/__tests__/submission-retry.test.ts. 스키마·컨트랙트 변경 없음.

📅 2026년 8월 6일 — 재투자가 한 줄짜리 가격 수정을 받고, 포지션이 드디어 취득원가를 기록한다

💸 같은 축의 빈틈 둘을 닫는다. 홀더가 얼마를 내는가, 그리고 그걸 우리가 무엇으로 기억하는가

1. YieldLib.reinvesteffectiveNav를 읽는다(v3-111 코딩 완료). 어제 결정했고 이제 컨트랙트에 들어갔다. lpAmounts.navPerToken 대신 PoolCommonLib.effectiveNav(s)로 나누므로, 대기 중인 24시간 인하 안에서 재투자하면 입금과 마찬가지로 공표된 NAV에 민팅된다. 회귀 테스트 test_ReinvestPricesAtAnnouncedNavDuringDecrease가 고정한다. 공표 0.80에서 옛 식은 1000 LP, 새 식은 1250 LP다. 전체 스위트는 초록이다(PlatformPool 런타임 20,835 B, 여유 그대로. 변경은 풀이 아니라 delegatecall되는 라이브러리에 있다).

⚠️ 배포 안 됐고 소급도 안 된다. 2026-08-04 구현체 0x27D9948F…f968에는 없고, 풀은 생성 시점 구현체에 고정된 Clones 프록시다. 그러니 오늘 존재하는 모든 풀은 상각 윈도 중 재투자를 여전히 덜 쳐 준다. 새 구현체와 팩토리를 배포하고 새 풀을 만들어야 누구든 이 수정을 관찰할 수 있다.

오프체인 절반이 같이 나갔다. yield.post.reinvest.ts가 LP 교차 검증에서 공표 NAV를 인용한다(has_pending_nav_update가 켜져 있으면 대기 중인 nav_history 행을 읽는다). 안 그러면 1% 허용치보다 넓은 상각 안에서 재투자할 때마다 체인 대 추정 괴리 검사가 발동해, 낡은 적용 NAV를 불일치로 보고한다. 평가는 일부러 안 건드린다. effective_value는 여전히 적용 NAV로 표시하므로, 포지션 하나만 풀의 나머지보다 24시간 먼저 상각되지 않는다.

2. 포지션이 취득원가를 갖는다(마이그레이션 0125, 2026-08-06 dev 적용). 포지션별 손익을 계산할 수 없었다. 얼마를 냈는지 기록하는 게 없었기 때문이다. effective_valuetokens × nav_per_token이고 포지션이 바뀔 때마다 다시 쓰이므로 언제나 현재 시가다. entry_price(0013)는 전입이 포지션을 만들 때만 쓰였고 입금 경로에서는 안 쓰였다. 그리고 05-investment-lifecycle이 처음부터 이름을 대던 portfolio_positions.nav_at_investment는 스키마에 존재한 적이 없다.

entry_price는 이제 LP 토큰당 가중평균 원가 를 뜻하고 모든 capital-in 경로가 유지한다. process_deposit_atomic, reinvest_yield_atomic, 그리고 인덱서의 입금 / LP 전입 라이터다. 입금과 재투자는 p_nav_per_token이 아니라 금액 / 민팅된 토큰에서 가격을 유도한다. 그게 위 수정과 맞닿는 지점인데, 인하가 타임락에 걸려 있는 동안 적용 NAV는 체결 가격이 아니기 때문이다. 전입은 풀 NAV로 표시한다. 온체인에서 관찰 가능한 대가가 없다. 로트가 아니라 가중평균인 이유는 LP 토큰이 대체 가능하고 포지션이 (사용자, 풀)당 한 행이기 때문이다. 재투자된 수익은 나중에 유령 이익으로 다시 나타나지 않고 원가를 올린다.

규칙 하나는 그대로 적어 둘 값어치가 있다. 모르는 원가는 모르는 채로 둔다. 기록된 원가가 없는 포지션에 추가로 넣으면 새 로트 가격을 채택하는 대신 NULL로 남으므로, UI가 지어낸 손익 대신 손익 없음을 보여 준다. 백필도 같은 선을 따르고 가드 둘 뒤에 있다. 기록된 이탈이 없을 것(deposits에는 행별 LP 수량이 없어서 부분 상환이 있으면 재구성이 틀린다), 그리고 원가 ≤ 토큰(NAV가 1.0에서 상한이므로 1.0을 넘는 재구성 원가는 상환 행을 안 남기는 경로로 토큰이 나갔다는 증거다. LP 전출이다). dev에서 31개 중 20개를 채웠고 전부 정확히 1.000000이었으며, 두 번째 가드가 첫 번째였으면 놓쳤을 행 둘을 잡았다. 9 LP에 입금 $10, 49.95에 $50이다. 둘 다 NULL로 두고 따로 볼 값어치가 있다. schema.sql로 띄운 일회용 Postgres에 대고 끝까지 검증했다. 1.00 → 공표 0.80에서 추가 후 0.977778 → 재투자 후 0.968421, 부분 이탈에는 안 변하고, 원가 모르는 추가에서 NULL 유지, 백필 두 갈래가 각각 예상한 쪽으로 갔다. 순수 헬퍼는 lib/shared/business/cost-basis.ts에 있고 단위 커버가 붙는다.

API 변경 없고(포트폴리오 엔드포인트는 *를 select한다) 새 컬럼 없고 프론트 작업 없다. 이건 숫자를 계산 가능하게 만든 것이지 아직 보여 주는 게 아니다.

3. 같은 회차에서 나온 발견 셋을 고쳤다(마이그레이션 0126, 2026-08-06 dev 적용).

  • 입금 핸들러에 똑같은 오발이 있었고, 재투자와 달리 이건 라이브였다. deposits.post.create.ts가 적용 NAV에 대고 교차 검증하는데, deposit()은 2026-08-04 구현체 이후로 effectiveNav를 읽는다. 그래서 1%보다 넓은 상각 안의 모든 입금이 이미 불일치로 로깅되고 있었고 실은 낡은 가격일 뿐이었다. 이제 두 핸들러가 공유 readPricingNav(lib/shared/nav/announced-nav.ts)를 부른다. PoolCommonLib.effectiveNav의 오프체인 미러이고 가격 산정 전용 으로 문서화돼 있다. 평가는 둘 다 적용 NAV에 남는다.
  • process_deposit_atomic이 이제 source = 'DEPOSIT'을 넣는다. 인덱서 라이터는 늘 그랬는데, 거의 모든 입금이 실제로 타는 경로는 안 그랬다. 그래서 평범한 경우의 출처가 "unknown"으로 읽혔다. 삽입에만 넣고 백필했다. dev 포지션 31개 전부 이제 값을 갖는다.
  • 아무것도 못 부르는 함수 둘을 뺐다. revert_reinvest_atomic(0005)은 0086/H2가 쓰기 전 온체인 검증으로 닫은 창을 보수하던 것이다. 8인자 reinvest_yield_atomic은 0086 이전 시그니처인데 오버로드로 살아 있었고, 유일한 호출자는 11개 파라미터를 전부 넘긴다. 이건 남겨 두지 않고 지울 값어치가 있었다. 위의 취득원가를 유지하지 않는 사본이라 8인자 호출은 그걸 조용히 건너뛰었을 것이다. 둘 다 0005 / 0051에서 복구 가능하다.

문서: 11-db-schema → portfolio_positions · 05-investment-lifecycle · 06-writedown-nav → R2·R3 · 23-money-path · 14-decisions v3-111. 코드: contract/src/libraries/YieldLib.sol · infra/lambda/{yield.post.reinvest,deposits.post.create}.ts · infra/db/migrations/{0125_position_cost_basis,0126_position_source_and_dead_rpcs}.sql · infra/lib/shared/{business/cost-basis.ts,nav/announced-nav.ts,indexer/writers/{deposit,lp-transfer}.ts,indexer/resolve.ts}. 출처: CH 핸드오프, 2026-08-06.

📅 2026년 8월 6일 — 프론트엔드 둘이 리프레시 쿠키 하나를 쓴다. admin-web이 투자자로 로그인해 영원히 403에 갇힐 수 있었다

🔑 리프레시 쿠키가 프론트엔드별로 갈리고, admin 행이 없으면 401이다

ADMIN 한 명이 POST /funds → 403 "Access denied"가 계속 난다고 제보했다. CloudWatch에 원인이 그대로 있었다. RBAC lookup failed { code: 'PGRST116', details: 'The result contains 0 rows' }. 계정이 있고 ADMIN인데도 withRole이 토큰의 user_id에 해당하는 admin_users 행을 못 찾은 것이다. 그날 오후 GET /funds도 같은 식으로 12번 실패했는데 아무도 몰랐다. 실패한 useQuery는 빈 목록으로 렌더되기 때문이다(admin-web에는 MutationCache.onError는 있고 QueryCache 쪽은 없다). 그래서 뮤테이션만 토스트를 띄웠고 버그가 "POST 전용"으로 읽혔다.

근본 원인: web과 admin-web이 리프레시 쿠키 하나를 공유한다. 두 앱이 같은 api 호스트를 가리키고(VITE_API_URL이 동일) 쿠키는 Domain 없는 host-only라, refresh_token은 나중 로그인이 덮어쓰는 슬롯 하나였다. 투자자 로그인이 admin 쿠키를 대체하고 나면, admin-web의 다음 /auth/refresh(401이 나면 언제든 돈다)가 투자자 액세스 토큰을 받아 저장하고 그 뒤로 모든 요청에 썼다. 그러면 모든 admin 엔드포인트가 403을 주는데, 클라이언트에서 403은 재인증 트리거가 아니라서 수동 로그아웃 전까지 세션이 박혀 있었다. 그동안에도 패널은 계속 role: ADMIN을 보여 줬다. 그건 토큰이 아니라 localStorage에 캐시된 로그인 응답에서 오기 때문이다. 같은 흔적이 전체에서 보였다. 나흘 동안 /yield-distributions에서 RBAC 실패 700건 이상(30초마다 재시도하는 폴링 탭), 그리고 admin-web만 부르는 /config/chains도 있었다. 호출자가 투자자 앱이 admin 라우트를 친 게 아니라 admin-web이 비-admin 신원으로 돌고 있었다는 증거다.

수정(백엔드만).

  • 프론트엔드마다 쿠키 하나(lib/shared/auth/cookies.ts): refresh_token_adminrefresh_token_investor. 분리 이전의 refresh_token은 배포를 넘겨 살아 있는 세션을 위해 읽기는 하되 다시 쓰지 않고 로그아웃에서 지운다.
  • 대상 판정 + 롤 일치(신규 lib/shared/auth/audience.ts): /auth/refresh가 주입된 ADMIN_WEB_ORIGINS에 대고 Origin으로 쿠키를 고르고, 호출한 앱에 속하지 않는 롤의 토큰은 401로 거절한다. 공유된 레거시 쿠키를 안전하게 만드는 가드가 이것이다. 대상을 잘못 판정해도 401까지가 최대이고, 앱에 반대편 신원을 넘겨주는 일은 없다. 제보된 세션을 푸는 것도 이것이다. 죽은 리프레시가 이제 조용히 성공하는 대신 401을 내고 진짜 로그인으로 간다.
  • admin 행이 없으면 withRole이 403이 아니라 401을 준다(middleware.ts). 행이 없다는 건 admin 신원이 아예 없다는 뜻이고(투자자 토큰이거나 삭제된 admin), 권한이 아니라 인증 문제다. withOptionalAuth는 같은 조건에서 이미 401을 주고 있었다. 롤 불일치 는 403을 유지하고, 펀드 스코프 403도 전부 그대로다. 로그 줄에 userIdtokenRole이 붙는다. 그게 없어서 이번 건이 로그 고고학이 됐다.

투자자 엔드포인트는 영향 없다. /deposits, /redemption-requests, /portfolio-positions, /yield-claims와 풀 읽기는 withAuth / withOptionalAuth를 쓰고, withRole 게이트 경로는 apps/web에 안 나온다. 회귀 커버는 lib/shared/auth/__tests__/refresh-audience.test.ts. 운영 참고: 오염된 쿠키를 쥔 admin은 다음 리프레시에서 한 번 로그아웃되고 다시 로그인하면 된다.

그리고 이걸 가리고 있던 프론트 빈틈. 두 앱 모두. 뮤테이션에는 처음부터 전역 오류 표면이 있었는데(MutationCache.onError) 쿼리에는 없어서, 거절된 읽기가 0행으로 해석되고 화면은 "여기 아무것도 없다"고 말했다. 이제 두 앱이 QueryCache.onError 쌍둥이를 붙인다. 쿼리 키별로 중복 제거하고(다음 성공에서 해제) 폴러가 틱마다 토스트를 쌓지 못하게 하며, meta: { silent: true } 옵트아웃도 같다. 토스트는 알림이지 답이 아니므로 목록도 같이 말한다. 공유 QueryErrorState("X를 불러오지 못했습니다. 빈 목록이 아닙니다.")가 admin 목록 화면 일곱 개에서 표를 대체한다. funds와 pools에는 실패 신호가 아예 없었고, audit-log / deposits / KYC / redemptions / yield는 여전히 "No records found"라고 적힌 표 위에 작은 빨간 줄이 있었다. 각주 달린 같은 거짓말이다. 투자자 쪽에서는 portfolio가 제품 최악이었다. 포지션 조회가 실패하면 포지션을 쥔 사람에게 "아직 포지션이 없습니다"를 렌더했다. 이제 요청이 실패했다고 말하고 재시도를 준다. (web의 풀 카탈로그는 이미 제대로 하고 있었고 그대로다.) 타입만이 아니라 실제로 돌아가는 앱에서 Playwright로 확인했다. /funds/portfolio-positions에 500을 강제하면 오류 상태와 재시도와 토스트가 나오고, 인증 실패 경로는 여전히 "세션 만료"를 낸다.

문서: 15-api-reference. 코드(infra): lib/shared/auth/{audience,cookies,middleware}.ts · lambda/auth.post.{refresh,logout,verify,admin-login,admin-oauth,admin-2fa-verify}.ts · auth.post.admin-logout-all.ts · lib/config/environment.ts · lib/stacks/api-stack.ts. 코드(프론트): admin-web/app/shared/{lib/query-client.ts,ui/query-error-state.tsx}와 목록 라우트 일곱 개 · web/app/shared/lib/query-client.ts · web/app/routes/portfolio.tsx. 스키마·컨트랙트 변경 없음. 출처: QA 리포트, 2026-08-05.

📅 2026년 8월 6일 — 07-redemption이 고고학을 덜어 내고, "미구현" 안내 셋이 실은 고쳐진 것으로 밝혀졌다 (v3-116)

🚪 상환 페이지는 장치를 그대로 두고, 나가는 건 자기 역사다

07-redemption608 → 429줄 이 됐다(v3-116). 이 페이지는 상환의 주인 이라, 잘라낸 모양이 0405와 달랐다. 여기엔 다른 페이지 내용을 되풀이한 게 없었다. 이고 있던 건 자기 고고학이다. 배포 주소, 커밋 해시, 함수 셀렉터, "이 페이지의 이전 초안은 …라고 적었다", "테스트 27개 통과". 거기에 사실 열한 개가 각각 3~9번 반복됐고, 안내 40개 중 대부분은 경고색을 입은 상태 배지였다. 장치는 전부 남는다. 모델 선택기, 이탈 게이트, 3-상태 락업과 패널티 네 종, 흐름 시퀀스 둘, Model B의 윈도 산술, carry-first 충당, 래더 세대, 취소, 3층 파트너 펀딩, 이상치 홀드 표. 안내는 11개로 줄었고 그중 8개는 번호 붙은 흐름 단계이며, warning / danger는 이제 투자자 자금 위험만 뜻한다.

clone 단서는 일곱 번이 아니라 한 번 적는다. "풀은 생성 시점에 구현체가 고정된 Clones이므로 새 풀에만 해당"이 배포된 컨트랙트 항목마다 붙어 있었다. 이제 페이지 제목 아래 한 번 앉고 페이지 전체에 적용된다. 빼지 않고 남긴 이유는, epoch FE가 상대할 풀이 없는 이유가 여전히 그것이기 때문이다.

🔴 전폭 danger / warning 안내 셋이 Model B가 설계만 되고 켜진 적 없다고 말했다. 셋 다 2026-08-03에 6c0a08f로 닫혔고 페이지가 못 따라간 것이다. 확인 액션은 이제 체인에 닿는다(pools.post.epoch-schedule.tssetEpochFundingDateOnChain, POST /pools/{id}/epoch-schedule). 출처 컬럼 둘은 존재하고(마이그레이션 0111) DETAIL_SELECT가 스케줄 나머지와 함께 돌려주므로 확정 배지와 카운트다운에 입력이 있다. 그리고 setEpochSchedule은 생성에 배선됐다. 조건이 필수이고 배포 후 체인에서 되읽어 단언하며, 컨트랙트가 게으른 엔진으로 흘러가는 대신 ScheduleNotConfigured로 리버트하므로 앵커 없는 새 풀은 배포될 수 없다. 스케줄 이벤트 셋에 대한 인덱서 라이터와 ABI 조각도 들어가 있다. 그러면 v3-107에 기록된 읽기 경로 발견도 닫힌다.

🔴 이 페이지의 wind-down 분자에 컨트랙트가 안 하는 뺄셈이 있었다. 07은 distributable = 리저브 + 회수된 자금 − 정산됐지만 미청구인 채무라고 적었는데, PlatformPool.sol:1370reserveBalance + heldFundReleases + totalEpochTopUp이다. − redemptionCommitted 항은 v3-100에서 나갔다가 0abe168에서 제거됐다. 세 버킷이 이미 그걸 뺀 값이라 또 빼면 이중 계산이다. 남은 비대칭은 분모 에 있고(R10), 페이지가 이제 그렇게 적는다.

그리고 페이지 안의 모순 하나: NAV 인하 가격 예외가 한 절에서는 "✅ Shipped"이고 세 화면 뒤 흐름 단계에서는 "🔴 미구현"이었다. Shipped가 맞고, 흐름 단계는 이제 단일 서술로 링크한다.

조작으로 의심한 둘은 확인 결과 진짜였다. GET /redemption-requests/{id}/exit-gate는 살아 있는 ADMIN 전용 엔드포인트이고, POST …/fm-accept제거됐다 는 페이지의 주장도 맞다(마이그레이션 0050). 04와 05 회차와 달리 이 페이지는 아무것도 지어내지 않았다.

같은 배포 상태를 앞선 스윕이 이름만 대고 못 닿았던 페이지 넷에 전파했다. 08a(previewEpochClaim / ClaimBeforeCancel 안내가 본문은 배포됐다고 하는데 제목은 여전히 "아직 배포 안 됨"이었고, setEpochSchedule은 "호출자가 없어서 앵커된 풀이 없다"로 읽혔다), 09a-custody, 26-glossary(R10의 "재배포 필요" / "대기 중인 재배포 기준"). 넷 다 이제 2026-08-04 배포(커밋 0abe168, 새 팩토리), 새 풀만 으로 읽는다. 그 배포 이전에 만든 풀은 옛 구현체 clone이라 옛 동작을 유지한다. 08-smart-contracts는 손댈 게 없었다. 컨트랙트 표와 executeWindDown 의사코드가 이미 맞았다. 진짜 "미구현" 표시는 그대로 뒀다. DPD 자동 상각, 문구 대기 중인 정체 요청 알림, 설정 불변성의 일반 재배포 안내, 그리고 모든 레거시 풀 단서다.

문서: 07-redemption · 08a · 09a-custody · 26-glossary · 14-decisions v3-116. 검증: 빌드 초록, check-doc-anchors 깨짐 2개(둘 다 허용 목록) / 예상 밖 0. 07로 들어오는 앵커는 전부 여전히 해석된다. 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-06.

📅 2026년 8월 6일 — 05-investment-lifecycle이 다시 안내서가 되고, 지어낸 컬럼 하나가 나온다 (v3-115)

🧭 투자자의 여정, 장치는 되풀이하지 않고 링크로

05-investment-lifecycle이 문서에서 사실 하나 안 빼고 298 → 209줄 이 됐다(v3-115). 04와 달리 05의 단계 안내 29개는 애초에 문제가 아니었다. 안내서는 원래 그렇게 생겼다. 이고 있던 건 다른 페이지가 소유한 장치의 두 번째 사본 이다. LP 수량 공식과 리저브 분할(23-money-path §3), 수수료 산술과 목적지 둘(04 → How Yield Works), 3-상태 락업과 패널티 네 종(07), 대사기의 마이그레이션 번호와 스케줄러 배선(11)이다. 공식이 두 벌이면 그렇게 갈라진다. 이제 각 단계는 투자자에게 무슨 일이 생기는지 말하고 장치는 링크한다. 여정 서술과 Reinvest V1 Policy, Yield Settlement on LP Transfer는 05가 소유하므로 남는다.

주인이 없던 항목 하나는 자르기 전에 옮겼다. 2인자 시그니처가 나오기 전에 배포된 풀이 노출하는 레거시 1인자 reinvest(uint256)이다. 이제 08areinvest 행에, cancelRedemption에 붙은 같은 종류의 단서 옆에 앉는다.

🔴 portfolio_positions.nav_at_investment는 존재하지 않는다. Phase 1에 그걸 위한 단계가 통째로 있었다. "정확한 손익 추적을 위해 투자 시점 NAV를 기록한다". grep은 저장소 전체에서 0건 이고 컬럼은 스키마에 없다. 입금이 실제로 쓰는 건 effective_value = tokens × nav_per_token이고, 다음 NAV 변동이 덮어쓰는 현재 평가다. 그러니 입금 경로에서 진입 NAV가 아예 보존되지 않는다. 진짜 entry_price 컬럼은 인덱서의 LP 전입 라이터가 처음 보는 포지션을 만들 때만 쓴다. 지우지 않고 그 자리에 사실을 적었다. 취득원가 손익 기능이 이걸 근거로 명세됐을 수 있기 때문이다. 열어서 넘긴 질문: 입금이 진입 NAV를 보관해야 하나? 없으면 포지션별 손익을 유도할 수 없다.

정리하다가 낡은 주장 둘을 더 고쳤다. Phase 3에 아직 "Admin이 상환 큐를 검토… reserveBalance 확인… approveRedemption 호출"이 게이트로 적혀 있었다. v3-82가 그 확인을 requestRedemption 안으로 옮겼고 approveRedemption은 선택적 수동 정산으로 남겼다. 07은 그 뒤로 맞았고 05가 뒤처진 쪽이었다. 그리고 수수료 단계가 treasuryWallet을 "불변이고 타임락이 걸린 목적지(proposeTreasuryChange + 7일 타임락으로만 변경)"라고 불렀다. proposeTreasuryChange0건 이고, 실제 setTreasuryWallet은 의도적으로 즉시 적용되는 admin 쓰기이며, 지갑은 전역 하나가 아니라 풀별 스토리지다. 04 → Fee Destination이 맞게 적고 있었다.

파트너 웹훅 정정은 04v3-02가 둘 다 가리키는데도 안내서 단계 안에 살고 있었다. 이제 자기 카드를 갖는다. v3-115가 단일 거처이고 세 페이지가 거기로 링크한다.

문서: 05-investment-lifecycle · 08a · 11-db-schema(portfolio_positions가 깔끔한 앵커를 얻는다) · 14-decisions v3-115. 검증: 빌드 초록, check-doc-anchors가 깨짐 2개(둘 다 허용 목록) / 예상 밖 0으로 복귀. 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-06.

📅 2026년 8월 5일 — 04-pool-models가 고고학을 덜어 내고, 지어낸 파트너 API 두 번째가 나온다 (v3-114)

🧹 현재형으로 쓴 개념 명세, 그리고 그게 드러낸 낡은 주장 셋

04-pool-models가 문서에서 사실 하나 안 빼고 1365 → 1134줄 이 됐다(v3-114). 잘라낸 것은 전부 주인 문서에 있는지 먼저 확인했다. "왜 바뀌었나" 서술은 각자의 v3-NN 카드로, 마이그레이션 번호와 배포일은 11-db-schema와 이 체인지로그로, NAV와 환율은 06으로, epoch와 상환 장치는 07로, 컨트랙트 내부는 08 / 08a로 갔다. 04에만 살던 항목 하나는 자르기 전에 v3-106으로 옮겼다. POST /tranche-writedown이 NAV를 어떻게 적용하는지다. 페이지 안에서는 중복 블록 다섯이 각각 하나로 접혔고, 거의 같은 가변성 표 둘(같은 질문을 두 번 묻는 93줄)이 표 하나가 되면서 epoch_duration_days와 NAV 안전 범위도 흡수했다. 제목은 차원 24개를 다 담는다고 주장했는데 둘 다 어느 표에도 없었다. 옛 앵커 둘은 남긴다. #editability-after-active 하나에만 다른 페이지에서 들어오는 링크가 여섯 개였다.

이번 회차에서 낡은 주장 셋이 떨어져 나왔고 각각 코드와 대조했다. is_hidden투자자 목록에서 풀을 숨기고 상세를 404로 만든다고 문서화돼 있었다. pools.get.list는 그걸 operator 갈래 안에서만 필터하고 isPubliclyVisible은 아예 안 읽으므로, 투자자에게는 숨긴 풀이 평범하게 보인다. 10-status-machines는 이미 맞게 적고 있었고 04가 뒤처진 쪽이었다. v3-02는 아직 "파트너에게 API/웹훅으로 통지"로 끝나 있었다. 같은 날 아침 05에서 뺀 바로 그 창작이다. 그리고 04가 풀 종류로 이름 댄 EXTERNAL_PARTNER는 데이터베이스에도 람다에도 컨트랙트에도 없다.

🔴 조치할 값어치가 있는 발견: 08-smart-contracts → Partner Notification이 존재하지 않는 파트너 API를 통째로 공개하고 있었다. POST {partner_endpoint}/aset-governance-action에 예시 페이로드가 붙어 있고 거기 can_object_until 이 들어 있어서, 7일 거버넌스 타임락 동안 파트너에게 이의 제기권이 있다는 뜻이 됐다. grep은 두 식별자 모두 0건 이고, pools.post.governance.ts는 어떤 채널로도 누구에게도 알림을 아예 안 보낸다. 온체인 이벤트는 진짜이므로 두 페이지 모두 이제 참인 것을 적는다. 파트너가 그 이벤트를 본다. 조작된 요소는 지우지 않고 그 자리에 이름을 댄다. 파트너가 그 페이로드에 맞춰 만들었을 수 있기 때문이다. 열어서 넘긴 질문: 거버넌스 알림이 있어야 하나? 시작했다고 누가 알려 줘야 타임락이 검토 기간이 된다.

문서: 04-pool-models · 08 → Partner Notification · 14-decisions v3-114. 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-05.

📅 2026년 8월 5일 — 사이드바에 정리 축을 하나만 준다. 문서 종류별 열 개 그룹 (v3-113)

🗂️ 어느 기능 옆에 있느냐가 아니라 문서가 *무엇인가* 로 묶는다

백엔드 사이드바를 축 하나로 다시 묶었다. 오리엔테이션 → 신원/컴플라이언스 → 돈 개념 → 컨트랙트 → 백엔드 런타임 → 레퍼런스 → 운영 → 프론트엔드 → 메타로, 그룹 열 개다. Getting Started · Identity & Compliance · Protocol & Money · Contracts · Platform / Backend · Reference · Operations · Frontend · Decisions & Roadmap · Team(v3-113). 셋은 새 그룹이다. Contracts가 08 + 08a를 가져간다. 컨트랙트 구현과 코드 레퍼런스는 그것이 구현하는 개념과 다른 종류의 문서이기 때문이다. Identity & Compliance는 03 + 21을 Getting Started에서 빼 온다. 거기가 자리 못 찾은 걸 다 흡수하고 있었다. Reference는 읽는 게 아니라 찾아보는 페이지 11 · 15 · 24를 모으고 20-joob-pool-config를 얻는다. 그건 일반 프로토콜이 아니라 펀드 하나의 설정이다. 09a-custody는 반대로 Platform에서 Protocol & Money로 간다. 비수탁과 money-path 불변성은 돈 개념이기 때문이다. 26-glossary는 일부러 Getting Started에 남는다. 조회 부록이 아니라 오리엔테이션 도구다. 변경은 내비게이션뿐 이다. 전부 .vitepress/config.ts에 있고 내용과 파일명과 앵커의 diff는 0줄이며, check-doc-anchors는 1187 / 깨짐 2(둘 다 허용 목록) / 예상 밖 0 기준선으로 돌아왔다. ko도 같은 순서로 같은 열 그룹을 미러링하고, 빠져 있던 항목 넷(26 · 08a · 22 · 25)을 새 스텁으로 얻었다. 기존 ko 파일은 전체 미러본까지 포함해 손대지 않았다.

문서: docs/vitepress/.vitepress/config.ts · 14-decisions v3-113. 내용·스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-05.

📅 2026년 8월 5일 — money path가 다시 지도가 된다. 구현 깊이는 주인에게로 (문서만)

🗺️ 페이지 자기 헌장에 맞춘 재구성 — 새 결정 없음

23-money-path의 머리말은 줄곧 하나를 약속했다. 돈이 어디로 움직이는가, 그리고 지갑/은행 명명의 단일 출처와 온체인 ↔ 플랫폼 밖 경계다. 이번 주 초 입금 보강이 §3을 코드 감사 깊이로 밀어 올렸는데(wei 산술, 라이터 경쟁, 불변식 테스트 내부로 192줄) 그건 다른 문서다. §3은 이제 125줄 이고 페이지는 다시 평평한 절 여덟 개다.

지운 게 아니라 옮겼다. 바닥에 떨군 건 없고, 잘라낸 것마다 그걸 소유한 곳에 착지했다.

  • 리저브 분할 반올림(내림 방향, raw 우선 근거, 먼지 하한 둘이 다른 이유) → 08a → Funds-integrity guards. 08의 Amount Units 표에는 없어서, 링크가 존재하려면 먼저 거처가 필요했다.
  • 라이터 둘의 기록 내부(출처 괴리, 멱등성, POST /deposits와 인덱서 사이의 effective_value 평가 경쟁) → 11-db-schema → deposits. 여긴 전에 대사기 한 줄 메모밖에 없었다.
  • wei/소수 공식08 → Amount Units가 이미 소유하므로 링크만.
  • wind-down 가격 산정(settledUnclaimedLp 분모, redemptionCommitted를 더는 안 빼는 이유) → 06 → R10이 이미 소유한다. §7은 지도의 답만 남긴다. 어느 버킷이 종착 지급을 대는지, 그리고 이미 나간 파트너 잔액은 온체인에서 회수 못 한다는 것.

사실마다 거처 하나. 리저브가 §6과 §6a에 둘 다 정의돼 있었다. 합쳤고 §1은 이제 §6을 가리킨다. 수수료 목적지는 §4에만 정의한다. 법정통화 구간이 기록 안 된다는 사실은 §2와 §3e 홉 표에 적고, §5의 중복 문장은 포인터가 됐다.

재구성하다 낡은 항목 넷을 고쳤다. fund_fee_wallet이 아직 "DB에도 컨트랙트에도 아직 없다"로 적혀 있었다. 마이그레이션 0067과 온체인 fundFeeWallet으로 나갔고, 같은 페이지 §4가 이미 그렇게 적고 있었다. 수수료 분류는 아직 admin_fee_pct / perf_fee_pct를 이름 대는 "현재 대 향후" 컬럼 쌍을 갖고 있었는데, 마이그레이션 0075가 bps 키로 대체했다. 이제 구간별 상태를 담은 컬럼 하나이고, 성과 수수료는 ⏸️ 휴면으로 표시 한다. 요율을 설정하는 admin 화면이 없기 때문이다(fees.ts가 0을 계산한다). 예제 계산이 일어날 수 없는 성과 수수료 $200을 빼고 있어서 순액이 틀렸다. 그리고 §4의 1~3 없이 떠 있던 "4." 번호는 평범한 문단이 됐다.

ko/23-money-path는 영문보다 나빴다. 스텁이 아니라 178줄짜리 전체 미러본인데, 삭제된 함수 셋을 참조하는 데까지 흘러가 있었다. 도해와 단계 양쪽의 releaseToPartner(), 그리고 현재 수수료 장치로 적힌 distributeYield + withdrawFees다. 리저브 미러 컬럼 이름도 틀렸고, 조기 이탈 패널티가 reserveBalance늘린다 고 아직 주장하고 있었다. v3-85fund_wallet으로 뒤집은 것이다. 전부 고쳤고 v3-112의 파트너 잔액 규칙도 넣었다.

막혀 있어서 일부러 안 건드린 것: hold-back 문장 전부(§6의 휴면 레버 블록, §3a / §3f / §3g의 단서, §8 불변성 행)는 재구성 명세의 의존 목록에 따라 hold-back 리뷰를 기다린다.

같이 훑은 것: v3-112에 따른 "90%". 02 · 04 · 05 · 07 · 08 · 09a · 11 · 20 · 24 · 26과 09a / 24 / 23의 ko 미러본에 걸친 산문 44곳이 이제 파트너 잔액 이라고 적는다. 90%는 기본값 reserve_bps = 1000에서만 나오는 숫자다. 리저브 0으로 출시한다는 가정에서는 잔액이 100%이고 2000 bps에서는 80%이므로, 90%라고 쓰면 풀별 설정 필드가 상수가 된다. 문서가 인용한 투자자 배너 문구 둘도 다시 썼다(apps/web / apps/admin-web에 없음을 확인했으므로 FE 드리프트는 없다). 14-decisions와 이 체인지로그 안의 15건은 날짜 붙은 역사라 그대로 뒀다.

🔴 그리고 가짜 웹훅은 04보다 05가 더 나빴다. 05-investment-lifecycle Phase 1 ⑦단계는 파트너 웹훅을 언급만 한 게 아니라 완전한 API 계약 을 공개했다. 이름 붙은 필드 여덟 개짜리 POST {partner_endpoint}/aset-lp-mint 요청 바디, 5회 재시도 스케줄(1s/5s/30s/5m/1h 지수 백오프), 놓친 이벤트용 GET /lp-mints?pool_id=X&since=… 풀 엔드포인트다. apps/infra, apps/web, apps/admin-web 전체 grep이 세 식별자 모두 0건 을 준다. 파트너가 그 페이지를 보고 만들었을 수 있다. 이제 그 단계는 실제로 입금을 기록하는 것(라이터 둘)을 설명하고, 조작된 요소를 전부 이름 대는 🔴을 단다. 조용히 지우는 대신 기록으로 남긴다.

출처: JY, 2026-08-05. 스키마·컨트랙트·결정 변경 없음.

📅 2026년 8월 5일 — hold-back은 만들어졌고 배포됐고 한 번도 안 당겨졌다. 그걸 이름 붙이고 "90%"를 버린다 (v3-112)

🎚️ 만들었는데 안 당긴 상태를 부를 말, 그리고 파트너가 실제로 받는 것을 부를 말

hold-back / heldFundReleases 장치가 조용히 폐기된 건 아닌지 판단하려고 코드 감사를 돌렸다. 폐기 안 됐고 깨진 것도 없다. 답은 문서에 부를 말이 없던 세 번째 상태였고, 그래서 이 항목은 이름 붙이기와 정합성 작업이다(v3-112). 배포된 동작은 안 바뀐다.

감사가 찾은 것. 컨트랙트 쪽은 완성돼 있고 퍼즈 테스트도 됐다. 스토리지, setFundingRestricted, clamp가 붙은 _releaseHeldFunds, 충당 경로 둘, 이벤트, 게터가 전부 배포된 ABI에 있다. 제품 쪽은 소비자 절반이 만들어진 채 놀고 있다. 인덱서가 FundingRestrictedSet을 보고, business/holdback-release.ts가 clamp 알림을 결정하고, holdback_release_deferred가 카탈로그에서 ⏸️ ON HOLD다. 그리고 프로듀서는 아예 없다. setFundingRestrictedOnChain은 호출자가 0이고, s.fundingRestricted는 초기화 없이 정확히 한 곳에서만 쓰이므로 모든 풀에서 false이며, 충당 경로 둘은 도달 불가이고 heldFundReleases어디서나 0 이다. §6a는 이미 danger 안내에서 이걸 솔직히 적고 있었고, 주변 페이지들이 아니었다.

D1 — "휴면 레버". 스위치가 온체인에 있고 테스트됐고 동작하는데 아무도 안 당긴다. 제거된 것도, 깨진 것도, 자동인 것도 아니다. 이제 26-glossary → Status labels에 한 번 정의하고 hold-back이 나오는 곳마다 적용한다. §3g의 실패 표를 보고 계획하는 독자가 그중 한 행은 일어날 수 없다는 걸 알 수 있다.

D2 — "90%"가 아니라 "파트너 잔액". 분할은 amount × reserveBps / 10000이 리저브로 가고 나머지가 파트너로 간다. 90%는 기본 reserve_bps에서만 나오는 수치다. 리저브 0 출시 가정에서 잔액은 100% 다. 23-money-path의 아홉 곳과 용어집, 알림, v3-100이 이제 잔액이라고 적는다. ⚠️ 다른 페이지는 아직 90%를 인용하고(04-pool-models에 대부분이 있다) 여기서 훑지 않았다.

🔴 새 질문 하나, 닫지 않고 넘겼다. _releaseHeldFunds는 여유 잔액을 의무 에 대고 계산하는데 Foundry 뒷받침 불변식은 다섯 을 센다. unclaimedYield가 분배 시점에 차감되므로 SUM(pendingYield)(홀더에게 약속됐고 물리적으로 풀에 있으며 청구를 기다리는 수익)는 한 번도 안 빠지고, freeNorm이 정확히 그만큼 부풀려진다. clamp가 고치려고 쓰인 §9-3-b 버그와 같은 계열인데 의무 하나가 더 많다. 레버가 휴면이라 잠재 상태이고 코드 읽기만 했다. 퍼즈 캠페인은 다시 안 돌렸다. hold-back 제품 작업을 시작하기 전에 반드시 정리해야 한다. → 컨트랙트 담당자.

같이 고친 것: 26-glossaryunclaimedYield를 "분배됐는데 아무도 안 청구한 수익"이라고 적고 있었다. 반대다. 예치됐고 아직 분배 안 됐다(YieldLib.sol:122가 적립, :179가 차감). 그 오독이 정확히 다섯 번째 의무가 사라진 경로라서, 용어집이 이제 이름이 오해를 부른다고 표시하고 pendingYield를 별개 의무로 이름 댄다.

문서: 23-money-path §6 · 26-glossary · 22-notifications · 14-decisions v3-112. 출처: JY(제품), 2026-08-05.

📅 2026년 8월 5일 — 재투자는 공표된 NAV로 가격을 매긴다. 오늘 아침의 열린 질문이 결정이 됐다 (v3-111)

🔄 공표 NAV 규칙 하나, 경로 셋

오늘 앞선 money-path 입금 감사(아래 항목)가 reinvests.navPerToken으로 가격을 매기는데 입금과 상환은 effectiveNav를 읽는다는 걸 찾았고, 버그가 아니라 열린 질문 으로 기록했다. 재투자되는 수익은 이미 풀 안에 있다는 게 방어 가능한 해석이었기 때문이다. 그 질문이 이제 제품 결정 으로 닫혔다. 재투자는 입금과 똑같이 effectiveNav 로 가격을 매긴다(v3-111).

결정을 지은 근거는 전환되는 수익이 accruedYield에 있고 그건 풀에 대한 청구권이지 가격이 매겨진 LP 포지션이 아니라는 점이다. NAV 변동이 그걸 상각하지 않으므로 투자자 몫으로 흡수된 게 없다. 그러니 공표 전 NAV로 전환 가격을 매기면 공표된 장부가가 뒷받침하는 것보다 적은 LP를 주는 셈이고, 같은 상각을 두 번 물리게 된다. 이미 쥔 토큰에서 한 번, 새 토큰의 환율에서 또 한 번이다. R2·R3는 투자자에게 유리한 무료 옵션 을 없애려고 존재하는데, 여기서는 가격 오류가 반대 방향으로 갔다. 그러니 두 경로는 애초에 대칭적인 논변이 아니었다. 둘 다 effectiveNav를 가리킨다.

이제 같은 블록에서 같은 크기의 재투자와 신규 입금이 같은 수의 LP 토큰 을 민팅한다. 새 파라미터도 설정도 거버넌스 표면도 DB 컬럼도 없다. 재투자 게이트(allowRollover, minReinvestAmount, v3-64에 따른 동일 풀 전용)와 E1 money-path 동등성은 그대로다. 이건 전환의 가격 이고 그 외에는 아무것도 아니다.

🔴 코드 수정 대기 — CH QA 카드. YieldLib.reinvests.navPerToken 대신 PoolCommonLib.effectiveNav(s)를 읽어야 한다. PlatformPool.sol:753과 맞춘다. 표현식 하나의 한 줄이지만 컨트랙트 변경이다. poolImplementationimmutable이라 새 구현체 + 팩토리로 나가고 새로 만드는 풀에만 닿는다. 현재/목표 규율에 따라 문서는 목표 규칙을 정본으로 적고, 문장마다 두 번 서술하는 대신 현재 동작을 이름 대는 🔴 안내를 하나 둔다.

문서: 05-investment-lifecycle → Reinvest V1 Policy · 06-writedown-nav → R2·R3(표가 이제 경로 셋을 경로별 상태와 함께 나열한다) · 14-decisions v3-111. 출처: JY(제품), 2026-08-05.

📅 2026년 8월 5일 — 프로토콜 절 훑기. 08-04 배포와 wind-down 분자 수정이 절반만 전파돼 있었다 (문서만)

🧭 드리프트 감사 — 새 결정 없음

외부 리뷰가 프로토콜 페이지에서 🔴 항목 넷을 지적했다. 둘은 브랜치에서 이미 고쳐져 재현이 안 된다(15-api-reference는 v3-102 이후로 settleYield라고 적고 include_due를 문서화한다. 04-pool-models Fee Destination은 배포된 4인자 settleYield 형태를 담고 "CH handoff"는 없다). 그 자체가 발견이다. 리뷰가 그 커밋들보다 앞선 빌드를 읽고 있었으니 게시된 사이트를 상대로 한 감사는 이미 닫힌 항목을 열려 있다고 보고할 수 있다. 나머지 둘은 진짜였고, 쫓아가다 그 아래 더 크고 같은 모양의 문제가 드러났다.

2026-08-04 배포와 wind-down 수정이 06 / 11 / 14에 닿고 멈췄다. 커밋 0abe168previewEpochClaim, settledUnclaimedLp, wind-down 정정의 양쪽 절반을 냈고 그 페이지들은 그렇게 적는다. 그런데 형제 페이지들은 같은 동작을 아직 대기 중이라고 적고 있었고, 때로는 ✅ 배포됨 행과 같은 표 안에서 그랬다. 아래 사례는 전부 이제 일관되며, 상시 ⚠️ 새 풀만 단서를 단다(Clones가 생성 시점에 구현체를 고정하므로 0abe168 이전 풀은 여전히 옛 코드를 돈다).

  • previewEpochClaim이 "배포된 풀에서 아직 호출 불가 / 재배포 필요"07-redemption §epoch, 그리고 08a의 "배포까지 막힘". FE가 막힌 건 새 팩토리로 만든 풀 이지 배포가 아니고, 무언가를 쓰는 것도 아니다.
  • R10 wind-down 분모가 "재배포 필요" / "🔴 목표"04-pool-models, 08a, 23-money-path §7, 그리고 08-smart-contracts 컨트랙트 표의 "분모 정정 대기". 배포됐다.
  • 분자는 반대 방향으로 낡아 있었다. 여섯 페이지가 아직 distributable = reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted를 인쇄했다. 그 뺄셈은 0abe168에서 제거됐다. _reserveFilledGross가 정산 시점에 유동성 버킷 셋을 이미 차감하고 총액을 disjoint한 redemptionCommitted에 주차하므로, 또 물리면 같은 돈을 두 번 뺀다. $150 펀딩, $100 정산이면 그 식은 max(0, 50 − 100) = 0을 줘서 $50을 쥔 풀을 0원에 가격 매겼다. 이걸 기록한 건 06과 14뿐이었다. 두 오류는 상쇄되지 않고 누적 됐다. 하나는 분자를 줄이고 다른 하나는 분모를 부풀렸으니, 그래서 한 변경에서 양쪽이 같이 움직였다. v3-100 정정 2에 이제 대체 표시가 붙는다.
  • v3-84 / v3-8508-smart-contracts에서 "컨트랙트 변경 재배포 대기" — 배포된 RedemptionLib에 살아 있다. payout = grossAmount − request.penaltyAmount(RedemptionLib.sol:524), 패널티는 s.fundWallet로, isYieldPenalty는 언제나 false다.

삭제된 함수가 현재 장치로 이름 대지고 있었다. releaseToPartner()존재하지 않는다. 이벤트 ReleasedToPartner와 인라인된 safeTransfer다. 그런데 04 / 08 / 08a가 여덟 곳에서 그걸 부르라고 지시했고, 번호 붙은 단계 하나와 도해 셋이 거기 든다. 04-pool-models는 제거된 withdrawFees를 유일한 수수료 호출로 두고 있기도 했다. 실제로는 settleYield(stablecoin, netAmount, treasuryAmount, poolMgmtAmount)이고, distributeYield + withdrawFees컨트랙트에서 사라졌다. 전부 고쳤다.

여기서 닫지 않고 넘긴 항목 둘:

  • pool_chain_deployments.reserve_balance가 아니라 pools.reserve_balance23-money-path §1과 §6이 컬럼 이름을 틀리게 적었다. 인덱서는 실행마다 pools.reserve_balance(마이그레이션 0088)를 쓴다. pool_chain_deployments 컬럼은 존재하지만 한 번도 쓰인 적이 없다. → NAV 담당자: 이게 "폴백이 발동하기는 하나"라는 열린 질문의 답이다. 1차를 채우는 게 없으면 폴백은 언제나 발동한다.
  • 파트너 LP 민팅 웹훅은 존재하지 않는다04-pool-models의 입금 단계 8~9가 POST {partner_endpoint}/aset-lp-mint와 "파트너가 자기 기록을 갱신한다"를 설명했다. apps/infra 전체에서 aset-lp-mint / partner_endpointgrep하면 아무것도 안 나온다. 그 자리에 🔴 미구현으로 표시했다. 만들 것인가 버릴 것인가는 제품 판단이다.

같이 훑은 것: 게시된 페이지에서 리터럴 :::로 렌더되던 컨테이너 펜스. 07-redemption, 08-smart-contracts, 21-holder-verification(고아 / 중복 / 들여쓰인 닫는 펜스, 닫기와 열기를 합쳐 버린 :::::: 오타 하나), 그리고 05-investment-lifecycle에서 여섯 곳을 고쳤다. 05의 ::: partner는 등록된 컨테이너 타입이 아니었다. 이제 fund-only이고, 등록돼 있으며 그 단계 배지가 쓰는 것과 같은 주황을 이미 쓴다.

⚠️ 남은 리터럴 ::: 넷은 다른 결함이라 그대로 뒀다. VitePress는 중첩 컨테이너를 렌더 안 하는데 02-core-concepts(info 안의 warning), 06-writedown-nav(tip 안의 danger, 두 번), 14-decisions(decided 안의 tip, v3-106)가 각각 하나씩 중첩한다. 고치려면 감싸는 블록을 재구성해야 하고, 그건 펜스 수리가 아니라 내용 결정이다.

출처: 외부 리뷰 + apps/contract/src0abe168에 대고 한 JY 확인, 2026-08-05. 스키마·컨트랙트·결정 변경 없음.

📅 2026년 8월 5일 — 입금이 상환에 이미 있던 money-path 깊이를 얻고, 거기서 빈틈 둘이 떨어진다 (문서만)

🧾 통합과 감사 — 새 결정 없음

23-money-path § Stream ①은 세 흐름 중 가장 얕았다. 네 단계짜리 정상 경로와 예제 하나였고, 상대편 상환 흐름은 고갈, hold-back clamp, 지연 릴리스, 뒷받침 불변식까지 다뤘다. 이제 온체인 구간을 단계별로 다루고, LP 수량과 가격(§3b), 리저브 반올림과 먼지(§3c), reserve_bps가 어디서 오고 언제 읽히는지(§3d), 법정통화 구간을 홉 단위로(§3e), 라이터 둘에 걸친 기록 출처(§3f), 실패와 진행 중 상태(§3g), 그리고 법정통화 구간까지 담은 숫자 예제(§3h)를 담는다. 전부 PlatformPool.deposit, YieldLib.reinvest, GovernanceLib, 인덱서 라이터, process_deposit_atomic에서 썼다. 코드가 답 못 하는 건 설명하지 않고 열린 항목으로 적었다.

기록으로 남기는 정정 셋. releaseToPartner()함수가 아니다. ReleasedToPartner는 이벤트이고 전송은 deposit() 끝의 인라인 safeTransfer다. 04 / 08 / 08a가 이걸 호출로 렌더하고 있었고 같은 날 고쳤다. LP는 navPerToken이 아니라 effectiveNav로 가격이 매겨진다. R2·R3가 실제로 입금 경로에 닿는 방식이 그것이다. 그리고 fund_bank_account / fund_fee_bank_account는 이름이지 컬럼이 아니다. pools에도 funds에도 그런 필드가 없다.

감사가 드러낸 빈틈 둘. 둘 다 그 자리에 기록했고 아직 결정 안 됐다.

  • 🔴 reinvesteffectiveNav가 아니라 s.navPerToken으로 가격을 매긴다. 대기 중인 NAV 인하 동안 재투자하면 낡고 높은 NAV로 민팅되어, 같은 블록에서 같은 크기로 신규 입금한 것보다 LP를 적게 받는다. 의도인지 R2·R3 누락인지는 열린 질문 으로 뒀다. → ✅ 같은 날 제품 결정으로 닫혔다. 재투자는 입금과 마찬가지로 effectiveNav로 가격을 매긴다(v3-111). 코드 수정은 대기 중.
  • 🔴 릴리스 구간에는 오프체인 기록이 없다. ReleasedToPartnerFundReleasesHeld가 인덱서 감시 이벤트에 없어서 10/90 분할이 어느 테이블에도 없다. Reinvested도 마찬가지로 감시 밖이라, 입금에는 2분쯤 안에 도는 대사기가 있는데 재투자에는 없다.

컨트랙트 경계 너머에서 솔직한 발견은 법정통화 구간이 양방향 모두 기록되지 않고 송금별 확인도 없다는 것이다. 상환 쪽이 더 깊게 읽히는 비대칭은 설계가 아니라 계측이다. 거기서는 부족액이 컨트랙트 상태이고 스케줄러가 쫓고 돌아오는 구간에 기록 엔드포인트가 있다. 입금의 릴리스에는 그게 하나도 없고 fund_wallet 잔액을 읽는 것도 없어서, Aset의 어느 화면에서도 환전 안 된 잔액과 환전된 잔액을 구분할 수 없다. 릴리스된 USDC를 실수령 법정통화와 누가 어떤 주기로 어떤 증빙에 대고 대사할지는 열린 항목으로 뒀다.

이 절 밖이라 적어만 두고 안 고친 것: §1과 §6이 DB 미러로 pool_chain_deployments.reserve_balance를 이름 대는데, 인덱서가 쓰는 컬럼은 pools.reserve_balance다(마이그레이션 0088. pool_chain_deployments 컬럼은 존재하지만 쓰인 적이 없다).

출처: JY, 2026-08-05. 스키마·컨트랙트·결정 변경 없음.

📅 2026년 8월 4일 — NAV/epoch 컨트랙트 작업이 dev에 배포됐고, 새 풀에만 닿는다 (v3-105 · v3-107 · v3-109)

🚀 새 구현체는 새 팩토리를 뜻하므로 기존 clone은 옛 코드를 유지한다

결정 셋의 컨트랙트 절반이 함께 나갔다. 새 팩토리 0xE1E2E9743fd3d00F6f50eBFCb765A91411D6DA90 → 새 풀 구현체 0x27D9948FD2A3aF7b035e99d23440A3A877aef968(21,186 B, 19,802 B에서 증가), 커밋 0abe168으로 빌드했다. 2026-08-04 Base Sepolia(dev) 에 배포.

그 배포에 든 것, 믿고 넘어가지 않고 라이브 구현체에 셀렉터로 확인했다. previewEpochClaim(9ccd0389), setEpochSchedule(uint256,uint256,uint256), setEpochFundingDate, setEpochSettleAfter, epochFundingDate, settledUnclaimedLp(915d0aa1). 마지막 게터가 쓸모 있는 것이다. 0abe168도입한 것이라 그 존재가 배포를 그 커밋에 고정한다. 그리고 0abe168R2·R3(PoolCommonLib.effectiveNav. 인하 타임락 동안 입금과 상환 요청이 공표 NAV로 가격이 매겨진다)와 R10(wind-down 분모)도 담는다. epoch 커밋 둘(6c0a08f, 3feeb87)은 그 조상이므로 스케줄 작업은 구조상 포함된다.

⚠️ 운영상 중요한 부분: 이건 기존 풀을 업그레이드하지 않는다. 팩토리에서 poolImplementationimmutable이라 새 구현체를 낸다는 건 새 팩토리를 낸다는 뜻이고, 이미 만들어진 풀은 복제된 그 구현체에 묶인 Clones다. 가장 최근의 기존 풀(2026-07-23 생성)에서 settledUnclaimedLp()리버트 하고 previewEpochClaim은 없다. 이 항목 시점에 새 팩토리로 만든 풀이 없으므로, 프론트엔드가 가리킬 수 있는 어떤 풀에서도 새 동작을 아직 관찰할 수 없다. previewEpochClaim을 붙이려는 FE에는 배포 이후에 만든 풀이 필요하다.

두 번째 결과는 일시적이 아니라 영구적이다. setEpochSchedule생성 전용 이라, 배포 전에 만든 풀은 영영 Model B 스케줄에 앵커될 수 없다. 그 풀들은 요청 윈도가 없는 레거시 게으른 시계를 유지하고, 옮기려면 다시 만들어야 한다.

여기서 확인 못 한 것: 백엔드의 FACTORY_ADDRESS_<chainId>가 이제 새 팩토리를 가리키는지. 그 값은 저장소가 아니라 배포된 람다 환경에 있고, 아직 옛 팩토리를 이름 대고 있으면 새로 만드는 풀도 계속 옛 구현체를 복제한다. 이 배포가 유효하다고 보기 전에 확인할 값어치가 있다.

갱신한 문서: 06-writedown-nav(R2·R3, R10) · 07-redemption · 08a-contract-reference · 10-status-machines · 11-db-schema · 14-decisions v3-105 / v3-107 / v3-109 상태 블록. 출처: CH(배포), JY(검증), 2026-08-04.

📅 2026년 8월 4일 — `ARCHIVED`는 lifecycle 상태였던 적이 없고 `CLOSED`는 들어갈 길이 없다. 아카이브·삭제·숨김을 셋으로 나눈다 (v3-110, 문서만)

🗄️ 아무것도 쓸 수 없는 문서화된 상태와, 존재하지 않는 enum 값

풀이 끝나는 방식에 대칭적인 결함이 둘 있다. ARCHIVEDlifecycle_status 값이 아니다. admin 매퍼가 deleted_at에서 유도하는 라벨이고 진짜 lifecycle이 무엇이든 가린다. 그런데도 문서 넷이 그걸 lifecycle 상태로 나열하고 거기로 가는 전이까지 적었다. CLOSED는 반대다. 진짜 enum 값이고 상태별로 문서화돼 있는데 저장소 어디에도 쓰기 경로가 없다. 일어날 수 없는 상태를 위해 화면과 배지와 문구가 만들어져 있었다.

A — 아카이브를 좁히고, 아카이브가 오용되던 자리를 is_hidden이 가져간다. 아카이브는 이제 끝난 풀을 물린다 는 뜻이다. 종착에 가까운 lifecycle이고 모든 포지션이 0 이어야 하며, 온체인 pause()를 미러링하고, 복원에는 사유 와 감사 흔적이 필요하다. 지금 가드는 풀이 ACTIVE일 때 열린 포지션을 검사하므로, 투자자 포지션이 살아 있는 CLOSED / MATURED / IMPAIRED / WIND_DOWN 풀을 당장 아카이브할 수 있다. 사람들이 돈을 넣어 둔 채로 화면에서 사라진다. 그 헐거움엔 이유가 있었다. 운영자는 살아 있는 풀을 목록에서 내려야 했고 아카이브가 유일한 레버였다. 그래서 좁히려면 빠져 있던 통제가 필요하다. is_hidden 이다. 노출만 바꾸고 그 외에는 아무것도 안 바꾸는 자유 토글이다(능력 변화 없음, 온체인 효과 없음, lifecycle 효과 없음). 공유 엔드포인트 하나에 force 플래그를 두는 안은 기각했다. 두 행위는 되돌릴 수 있는지, 온체인 효과가 있는지, 투자자가 영향을 받는지에서 다르다. 이름은 Archive(되돌릴 수 있음) 대 Delete(영구, DRAFT / DEPLOY_FAILED만)로 고정하고, 감사에 POOL_ARCHIVE / POOL_RESTORE가 생긴다. 지금은 하드 삭제와 아카이브가 POOL_DELETE를 공유하고(metadata.mode로만 갈린다) 복원은 일반 POOL_UPDATE로만 남는다. 사유도 전용 이벤트 타입도 없이 deleted_at: nullPATCH하는 것뿐이기 때문이다.

B — CLOSEDPOST /pools/{id}/closeend_date 자동 마감을 얻는다. 모집이 끝났다는 뜻이다. 입금은 막히고 상환과 수익 청구는 그대로 이며, 풀이 건강하고 end_date가 안 지났으면 되돌릴 수 있다(reopen). 모집을 일찍 닫는 건 운영 행위이고, 그게 MATURED·WIND_DOWN과 갈리는 지점이다. end_date는 이미 있고, B가 거기에 자동 트리거라는 두 번째 일을 준다. 풀이 자기 마감일을 지나서도 ACTIVE로 앉아 있지 않게 한다.

C — 전손 / total loss, 각 언어에 용어 하나씩이고 첫 등장에 escalate_flag를 붙인다. 미관 문제가 아니다. 같은 사건이 필수 에스컬레이션을 촉발하는데, 아무도 일관되게 부르지 못하는 결정은 일관되게 집행될 수 없다. Q2 는 전손이 기존 온체인 7일 proposeImpairmentexecuteImpairment 타임락으로 에스컬레이션된다는 걸 재확인한다. v3-109 D3/T2와 같은 장치 이고, 정책이 혼자서도 읽히도록 마감 쪽에서 기록한 것이다. 두 번째 구현이 아니다. D — 상태 칩 하나. pool-badges.tsx는 자기를 "단일 유효 풀 상태 칩"이라 부르고는 두 번째 Paused 칩을 렌더한다. v3-78 자동 해제가 이미 한 번에 살아 있는 플래그를 하나로 보장하므로, 두 번째 칩은 답이 하나인 질문에 대한 두 번째 답이다.

Notion에 있는 문구 변경 하나만 빼고 구현됐다. 마이그레이션 0123(pools.is_hidden)과 0124(전손 주석). 나머지는 스키마 변경이 필요 없었다.

A — 아카이브가 좁아지고, 이제 컨트랙트도 같이 닫는다. 포지션 검사가 ACTIVE에서만 돌아서 홀더가 남아 있는 CLOSED / MATURED / IMPAIRED / WIND_DOWN 풀을 아카이브할 수 있었다. 풀이 나가는 길에 거치는 상태들, 즉 돈이 아직 남아 있을 가능성이 가장 큰 자리이고, 아카이브는 홀더를 안에 둔 채 풀을 admin 목록에서 숨긴다. 이제 검사가 DRAFT를 뺀 모든 lifecycle을 덮는다. 아카이브는 pause()도 보낸다. 전엔 deleted_at만 써서, 콘솔에서는 안 보이는데 주소를 아는 누구든 deposit()을 계속 넣을 수 있는 풀이 됐다. 복원은 사유를 요구하고 POOL_RESTORE를 남기며 WIND_DOWN을 거절하고(온체인 종착이라 돌아와도 운영이 안 된다) 일부러 unpause를 안 한다. v3-78이 완화는 자동 복귀하지 않는다고 말하기 때문이다. 풀은 pause 상태로 돌아오고 다시 여는 건 별개 행위다.

is_hidden은 이 카드가 적은 것보다 좁게 나갔다. 본문은 "투자자 목록과 상세에서 뺀다"고 하는데, 실제로는 admin 목록 전용이다. 홀더는 상환하고 수익을 청구하고 상각 안내를 읽으려면 풀 상세에 닿아야 한다. 거기서 숨기면 운영자 편의로 내린 결정 뒤에 남의 돈을 두는 셈이다. 풀이 투자자 시야에서 나가야 한다면 그건 CLOSED나 아카이브이지 표시 플래그가 아니다.

B — CLOSED가 드디어 들어갈 길을 갖는다. 제품 전반에서 읽히고 있었는데(상환 핸들러가 받고, 수익 스케줄러가 제외하고, 투자자 배너에 문구가 있다) 쓰는 게 아무것도 없었다. 도달 불가 상태였고, 그래서 "새 돈은 그만 받고 기존 투자자는 만기까지 간다"에 구현이 없었다. POST /pools/{id}/close와 lifecycle 스케줄러의 end_date 패스를 넣는다. ⚠️ 그 패스는 만기 패스 뒤에 돌므로, 둘 다 지난 FIXED_TERM 풀은 MATURED로 나가고 CLOSED로 강등되지 않는다. 강등하면 이미 나갈 자격을 번 홀더에게 조기 이탈 패널티가 되살아난다. 그리고 10-status-machines의 DAG가 어느 상태든 → CLOSED 라고 적고 있었는데 같은 방향으로 틀렸다. 아직 청약을 받고 있는 풀만 닫을 청약이 있다.

D — 칩 하나, 그리고 자동 해제 규칙에 후반부가 생긴다. 이제 Paused가 lifecycle 칩 옆에 앉지 않고 그걸 대체한다(pause는 ACTIVE에서만 허용되므로 두 번째 칩이 나르는 정보가 없었다). MATURED / CLOSED 전이는 is_paused를 해제한다. 🔴 온체인 unpause()까지 포함 하는데 결정문에는 그 말이 없었다. v3-92는 DB만 해제하면 paused() == true가 남아 입금이 보이지 않게 리버트하고 UI에 돌아올 길이 없다고 명시한다. 그게 Test Pool 260616-base를 좌초시켰다. 스케줄러는 무인으로 도니까, 거기서 DB만 해제하면 아무도 안 보는 풀이 좌초된다.

G2는 ②가 아니라 ①을 택했다. 컨트랙트가 없는 동안(발행 → DEPLOYING, 또는 DEPLOY_FAILED → 재시도) 건 pause를 이제 배포 워커가 새 컨트랙트로 옮겨 준다. 대신 pause를 거절했으면, 운영자가 가장 필요로 하는 그 창에서 유일한 통제를 뺏는 셈이다. 실패하면 DB 플래그는 그대로 두고 감사가 outcome: failure를 기록한다. 체인에 맞추려고 지우면 운영자의 결정을 조용히 되돌리는 것이다.

G4의 파급은 가정하지 않고 확인했다. pause에서 OPERATOR를 빼는 건 권한 축소이므로 감사 로그를 봤다. OPERATOR의 pause 이벤트는 0건이다. 아무도 안 쓰고 있었고, 핸들러가 09-rbac과 admin UI 양쪽과 그냥 어긋나 있었던 것이다.

DB가 정리해 준 것 둘. MATURED + PAUSED 유령 조합은 정확히 한 건이고 이미 아카이브돼 있다. A 아래에서는 그게 유령이 아니라 맞는 상태이고 컨트랙트도 동의한다(paused() == true). 그리고 아카이브된 DRAFT 행이 없으므로 G6의 좌초 부작용은 치울 게 없다.

아카이브 게이트는 순수 모듈 lib/shared/business/pool-archive.ts로 옮겼고 테스트 16개가 붙었다. 여기 규칙이 전부 거절이고 거절은 열린 쪽으로 실패하기 때문이다. 허용 목록에서 상태 하나를 빼면 아무것도 안 던지고 아무것도 안 남기고 아카이브가 그냥 성공한다. lifecycle enum 전체를 열거해서, 나중에 값이 추가되면 가드를 조용히 건너뛰는 대신 테스트가 깨진다. 변이 넷이 실제로 문다는 걸 확인했다. 가드를 ACTIVE 전용으로 되돌리면 7개, 쿼리 파라미터가 DRAFT를 이기게 하면 1개, WIND_DOWN을 빼면 2개, 단일 컬럼 S-18 비교면 3개가 깨진다.

아직 열린 것: Notion PRD 문구(심각손실 → 전손). 저장소 밖이다. 검증: infra 326/326, 문구 / 알림 프로듀서 / 온체인 단위 가드 통과, web과 admin-web 타입체크, admin eslint 기준선 그대로. 결정: v3-110. 출처: JY(결정) + CH(구현), 2026-08-04.

📅 2026년 8월 4일 — 리저브는 손실 흡수 계층이 아니고, NAV 분모가 새 입금으로 기록된 손실을 희석시키고 있었다 (v3-109, 문서만)

📉 리저브 $100k에 손실 $100k가 손실 없음으로 기록됐다

리저브는 투자자 입금의 reserve_bps이고 Pool 컨트랙트가 들고 있다. 그러니까 NAV 분모가 값 매기는 청구권 안에 이미 들어 있는 투자자 돈 이다. 그걸 손실에서 또 빼면 투자자가 자기 자본으로 두 번 크레딧을 받는 셈이고, 자산이 실제로 떨어졌는데도 풀은 액면을 보고했다. R8이 리저브를 손실 흡수에서 완전히 뺀다. 순서는 이제 담보 → 버퍼 → Junior → Mezzanine → Senior → NAV 이고, 리저브는 진짜 역할 둘을 유지한다. 상환 지급, 그리고 wind-down 하한 설정이다. 이걸로 v3-16의 first-loss 서술과 BD4의 전제가 물러난다. 10% 규모는 유동성 목표로 살아남는다.

그 계층을 빼면 독립 풀에는 손실과 NAV 사이에 아무것도 안 남는다. 리저브는 출시 시점에 설계상 0이다. 그래서 R6 이 운용사 자기자본 버퍼를 공시 문장(equity_buffer_rule, 자유 텍스트)에서 계산 가능한 파라미터로 승격한다. bufferCap = total_deposited × buffer_rate_bps / 10000, 손실을 누가 지는지 정하는 buffer_direction, 그리고 buffer_basis 플래그다. NET이면 파트너가 보고한 손실이 흡수를 이미 반영한 값이므로 cap을 0으로 강제한다. 두 번 빼면 R8이 방금 없앤 그 이중 계산을 반복하기 때문이다. 🔴 풀별 요율과 약정 방향(처음 N% 대 N% 초과분)은 Joob과의 계약 확인을 기다린다. 합의된 요율은 없다.

R9 — 분모가 total_deposited였고, 그건 반올림 잔재가 아니라 드리프트다. total_deposited는 늘기만 한다. totalSupply는 LP가 소각되면 줄어든다. 고정된 손실을 계속 커지는 누적치로 나누면 새 입금마다 기록된 손실이 희석되고, 회복이 없는데도 NAV가 슬금슬금 올라간다. 이제 분모는 실제로 존재하는 청구권의 수 totalSupply다. 이건 A4에 기록된 2026-07-27 확정을 뒤집는다.

R10v3-100이 wind-down 분자를 고치고 분모를 두고 갔다. distributableredemptionCommitted를 빼는데, totalSupply는 정확히 그 지급을 받치는 에스크로된 LP를 여전히 센다. USD는 나가고 토큰은 남으니 남은 홀더가 그만큼 덜 받는다. 목표는 distributable / (totalSupply − settledUnclaimedLp)이고, 새 카운터를 정산 시점 에 올리고 청구 시점에 내린다. ⚠️ 범위: 정산됐지만 미청구인 LP만이다. 대기 중이거나 이월된 LP는 분모에 남는다. 그 USD도 분자에 있어서 이미 대칭이고, 빼면 epoch 설계가 이월 요청자에게 보장하는 pro-rata 참여가 깨진다. 컨트랙트 재배포 가 필요하므로, 문서는 아직 존재하지 않는 풀에 맞춰 다시 쓰지 않고 현재 동작을 적으면서 목표를 이름 댄다.

같이 정리된 것: R7 — 환율은 NAV의 입력이 아니다(성과는 운영 통화로, 환전은 상환 시점에). 그래서 환율 변동이 타임락을 거는 일이 없다. R2·R3 — 대기 중인 인하는 24시간 공개되므로, 그 창 안의 입금과 요청은 공표된 NAV로 가격이 매겨진다. 옛 문서는 투자가 현재 가격으로 "계속 열려 있다"고 했고, 2026-07-02 구두 결정은 거래가 막힌다고 했는데, 둘 다 구현도 안 됐고 맞지도 않았다. R5·R5.5 — Aset은 펀드의 상각을 미러링하고 자체 대손 곡선을 만들지 않는다. 연체만으로는 NAV가 움직이지 않는다. D1 — 수동 NAV 경로가 ADMIN/SUPER_ADMIN이 된다. 손으로 입력하는 경로를 OPERATOR에게 열어 두면 약한 통제가 실효 통제가 되기 때문이다. D2 — 제안에 기각 사유 기록과 48시간 TTL이 붙는다. D3/T2 — 전손은 proposeImpairment7일 타임락 → 실행으로 온체인 IMPAIRED까지 에스컬레이션해야 하고, 앞서 거론되던 2인 승인을 대체한다(공개된 7일이 동시에 이뤄지는 비공개 서명 둘보다 낫다).

구현했고, 만들면서 결정이 예측 못 한 라이브 결함 여섯을 찾았다. 돈에 무슨 짓을 하고 있었는지 순으로 적는다.

🔴 건강한 풀이 승인 한 번이면 0원으로 가격 매겨질 참이었다. R7은 표시 작업으로 기록돼 있었다. 아니다. 제안 경로가 파트너의 cumulative_loss를 풀의 USD 원금에서 뺐는데, 그 손실은 EFIDR 로 보고된다. 1,022,311,789 빼기 573,206은 −1782를 주고, 그게 1e-6 하한으로 클램프되면서 escalate_flag가 켜졌다. dev가 만들어 낸 유일한 NAV 제안은 대출이 전부 정상인 풀에 대한 전손 상각 이었다. 수정은 비율이고 일부러 환율이 아니다. cumulative_loss ÷ total_subscribed, 둘 다 같은 보고서에서 오므로 통화가 상쇄되고 어떤 환율도 NAV에 닿을 수 없다.

🔴 인덱서가 지난 45일 중 13일 동안 죽어 있었다. Alchemy는 eth_getLogs를 10블록으로 제한하는데 엔진이 더 요청해서, 실행마다 던지고 이벤트가 하나도 미러링되지 않았다. R9를 그냥 켤 수 없었던 이유가 이것이다. 아무도 안 듣는 체인에서 읽은 분모는 DB의 낡은 값보다 나쁘다. 제공자 오류에 따라 줄어드는 적응형 범위로 고쳤고, 리저브/LP 미러를 이벤트 파이프라인 밖으로 옮겨서 로그 실패가 미러까지 끌고 내려가지 못하게 했다.

🔴 리저브 미러가 1e12만큼 어긋나 있었다. reserveBalance()는 18자리 카운터인데 인덱서가 1e6으로 나눴다. $2.10 을 쥔 풀이 $2,100,000,000,000 으로 기록됐다. 인덱서가 같이 죽어 있어서 아무도 못 봤다. 이제 브랜드 타입 RawAmount / NormalizedAmount / NavPrice와 빌드 가드(check-onchain-units.mjs)가 그 축을 주석이 아니라 타입의 일부로 만든다.

🔴 wind-down이 같은 이탈을 남은 홀더에게 두 번 물렸다. R10은 분모의 비대칭 하나로 기록됐는데, 분자에 거울상 버그가 있었다. _reserveFilledGross가 이미 정산 총액을 redemptionCommitted로 옮기므로, executeWindDown이 그걸 또 빼면 $50을 쥔 풀에 max(0, 50 − 100) = 0을 준다. 같은 변경에서 양쪽을 고쳤다.

그리고 테스트 개수가 거짓말을 하고 있었다. tsc는 소스 파일이 사라져도 출력물을 안 지우므로, 삭제된 파일의 테스트 18개가 dist에서 계속 돌았다. 실제 266개인데 284개로 보고된 것이다. 유령 파일을 심어 재현했고, 빌드와 테스트 앞에 rm -rf dist를 넣어 고쳤다.

R6은 답을 기다리기를 그만두고 설정이 됐다. 파트너 계약 조건에 걸려 있었는데, 그건 12시간마다 도는 공식에 대해 기한 없는 기다림이다. 기다림이 막아 주는 건 추측인데, 컬럼은 추측하지 않는다. 0118이 buffer_rate_bps, buffer_direction, buffer_basispools에 얹되 기본값을 줘서 산술이 이전과 비트 단위로 같게 만든다. 방향은 제 몫을 한다. cap 5%에 손실 8%면 FIRST_LOSS에서 투자자가 3%를 지고 EXCESS에서는 5%를 진다. 한쪽을 조용히 가정한 공식은 절반은 틀리고 어느 화면에도 그게 안 보인다. basis는 R8을 한 층 위에서 반복한 것이다. 이미 순액으로 보고된 손실에서 버퍼를 또 빼면 같은 돈을 두 번 세므로 NET이 cap을 0으로 강제한다. 그다음 0119 가 무력한 버퍼를 표현 불가로 만들고(buffer_rate_bps > 0이면 external_fund_id가 필요하다. 스윕이 매핑 안 된 풀을 방문하지 않기 때문), 0121 이 산문을 설정에 종속시킨다. 계층이 없는 곳에 equity_buffer_rule을 설정할 수 없으므로, 투자자에게 보이는 "운용사 first-loss 약정"이 더는 아무것도 아닌 것을 설명하지 못한다.

이틀 안에 가드 하나가 들어왔다 나갔다. 0118은 nav_proposals.recovery_flag도 달고 왔다. 제안이 NAV를 올릴 때 경고하는 것인데, 인상은 타임락 없이 적용되기 때문이었다. 그건 빠진 통제가 아니라 타임락을 설명하는 것이다. 승인은 이미 의도적인 사람의 행위이고, 같은 클릭에 배너를 하나 더 얹는다고 결정되는 건 없다. NAV 인상은 승인 버튼을 받고 그게 전부다. 안 쓴 채로 두지 않고 0120에서 뺐다. 언제나 false인 컬럼은 다음 작성자에게 신호로 읽힌다.

계획서의 블로커 둘은 존재하지 않았다. nav_proposals와 마이그레이션 0090/0091/0093은 전부 dev에 있다. (pending apply) 표시가 낡은 것이었고, 11-db-schema가 경고하는 바로 그 실패 방식이다. T1/T2/D-1도 v3-106에서 이미 나갔다. 그리고 Q1은 소스가 스스로 답했다. deposit()requestRedemption()external이고 KYC 게이트가 걸려 있어서 홀더는 누구든 자기 지갑에서 부른다. 백엔드 게이트는 UI를 쓰는 사람만 묶고 나머지는 안 묶으니 엉뚱한 절반이다. 온체인 답은 PoolCommonLib.effectiveNav()이고 가격 산정 지점 이 그걸 쓴다. settleNav도 포함인데, 공지 창 안에서 정산되는 epoch이 남은 홀더 소유의 유동성에서 상각 전 가격으로 이탈자에게 지급하고 있었다. 결정문은 epoch 풀이 이미 충족됐다고 적었었다.

그리고 R6은 풀 하나짜리 기능으로 드러났는데, 그걸 보호하던 제약이 그 상태를 영구화하고 있었다. 버퍼를 설정으로 낸 건 절반이었다. buffer_rate_bps를 읽는 곳은 computeSuggestedNav 정확히 하나이고, 스윕은 외부 매핑된 풀에 대해서만 그걸 부른다. 독립 풀 13개 중 1개 다. 나머지 12개에서는 운영자가 first-loss 계층을 설정하고 저장되는 걸 보고도 아무것도 흡수되지 않았다. 0119는 거기서 값을 금지하는 걸로 대응했는데, 아무것도 못 읽는 동안엔 맞고 정착 상태로는 틀렸다. 버퍼를 풀 하나짜리 기능으로 얼려 버렸다.

나머지 12개에 버퍼가 못 닿은 이유는 수동 경로가 가격 을 받기 때문이다. 손으로 친 가격은 산술을 한 사람이 이미 손실을 반영한 값이라, first-loss 계층이 뺄 게 안 남는다. 그래서 POST /nav-changes가 이제 new_nav 대신 cumulative_loss를 받고, 스윕이 쓰는 같은 진입점 computeNavFromPoolLoss로 가격을 유도한다. 호출자 둘, 구성 하나, 버퍼가 조용히 적용 안 될 두 번째 자리 없음. 0122가 0119를 물리고 더 좁은 buffer_not_on_tranche_pools로 대체한다(트랑슈 그룹은 워터폴 엔진으로 가격을 매기므로 버퍼를 적용할 자리가 진짜로 없다). cadence_requires_external_mappingfund_report_cadence_days에 대해 매핑 요구를 유지한다. 버퍼와 달리 그건 보고서를 보낼 파트너의 의무를 설명하고, 피드가 없는 풀은 아무것도 못 받기 때문이다.

가드 셋이 같이 왔고 각각은 이미 기록된 실패다. total_deposited를 넘는 손실은 거절한다(이 경로에서 R7 통화 혼동을 잡는 것도 이것이다. 루피아 수치는 자릿수 단위로 넘치고, 안 막으면 하한으로 클램프돼 정당한 전손처럼 읽힌다). 계산된 전손은 승인 경로에서와 마찬가지로 여기서도 proposeImpairment(D3)로 보낸다. 그리고 admin 미리보기는 브라우저의 공식 사본이 아니라 서버 dry_run이라, 공식 온체인 시뮬레이트를 둘 다 돌린다. 브라우저 사본이었으면 둘이 갈라질 두 번째 자리가 됐을 것이다. nav_historyloss_amount(버퍼 뒤 미보전분)와 loss_as_of가 생겨서, NAV 하락이 자기 원인을 말할 수 있게 됐다. 명세 수준에만 있던 인하 사유 메타데이터다.

배포: R6(모든 풀), R7, R8, R9, D1, D2, D3, 그리고 위의 표시 정정과 결함 여섯. forge test 375/375(불변식 스위트 포함), infra 307/307(정직한 수치), PlatformPool 20,835B로 24,576B 한도에 여유 3,741B. dev 배포 대기: R10과 R2·R3는 작성·테스트됐지만 컨트랙트에 있다. R9는 완료다. 배포 후 도달 가능한 풀 13개 전부에 대해 미러가 정확한지 확인했고 같은 날 분모를 옮겼다. R9가 나갔고, 상환 쪽이 결정문에 기록된 입금 쪽보다 나빴다. 카드는 결함을 새 입금이 기록된 손실을 희석하는 것(0.900 → 0.947)으로 설명한다. 미러가 드디어 채워진 상태에서 대수를 따라가 보니 반대 방향이 실제 돈이었다. 0.90으로 상각된 $1,000 풀에서 500토큰 상환이 $450을 지급하면 옛 공식은 0.8182 로 갔다. 이탈자의 상각을 남은 사람 전원에게 한 번 더 물린 것이다. 그리고 다음 입금이 그 일부를 0.9310으로 되돌려 줬다. 두 움직임 다 자산과는 아무 상관이 없다. lp_total_supply로 나누면 둘이 정확히 상쇄된다. p = (V − L)/S일 때 상환은 VV − T·p로, SS − T로 만들므로 가격은 p(S − T)/(S − T) = p로 다시 계산된다.

🔴 그리고 그 상쇄는 분자가 상환분을 뺀 순액이라는 데 달려 있는데, 그건 이 카드 자신의 문구와 모순된다. complete_redemption_atomictvl = tvl − payout을 한다. 위 본문은 그 항을 total_deposited라 부르고 "늘기만 한다"고 적는다. 안 그렇고, 그러면 안 된다. 누적치라면 이탈한 투자자의 원금이 분자에 남는데 토큰은 분모에서 빠지므로, 이탈할 때마다 NAV가 부풀려진다. 틀린 건 컬럼이 아니라 설명이었다.

공급량이 없는 것과 0인 것은 다르다. usableTotalSupply()는 NULL과 0과 음수를 거절하고, 호출자는 원금으로 폴백하는 대신 그 풀을 건너뛴다. 그 폴백이 곧 옛 분모이고 어느 화면에서도 구분이 안 된다. dev에 그 상태인 풀이 넷 있고 그중 하나는 온체인 공급량 0에 $7.1m 을 쥐고 있다. 수동 경로에는 다른 데서 수치를 구한 운영자를 위해 new_nav를 남겨 뒀다. 그러면 폴백이 눈에 보이는 사람의 행위로 남는다.

⚠️ R9는 원장 드리프트도 가격으로 만든다. 옛 식은 손실이 0이면 데이터 품질과 무관하게 모든 풀에 정확히 1.0을 줘서 아무것도 못 드러냈다. 19토큰에 $2.30을 이고 있는 dev 풀 하나가 이제 $0.121 로 가격이 매겨진다. 그 불일치는 원래 있던 것이다. 라이브 풀 13개 전부에 드라이런: 8개는 1.0으로 그대로, 4개는 가드로 건너뜀, 1개가 드러남. 그리고 제안 스윕은 그중 아무도 안 방문하므로 스위치는 무해하게 착지한다.

마지막 남은 미검증 표면을 닫았고, 그게 미검증으로 남아 있던 이유는 기록된 것과 달랐다. computeSuggestedNav는 갈래가 열다섯인데 모든 거절이 같은 null을 준다. 그래서 "이 풀은 가격 못 매김"을 "잘못 매김"으로 바꾸는 버그는 어디에도 안 나타난다. 빈틈을 설명하는 메모는 저장소에 Supabase 모킹 패턴이 없다고 적었다. 실제 블로커는 더 단순했다. db/supabase.ts가 환경변수가 없으면 모듈 로드 시점에 던지므로, 테스트 대상 모듈을 정적 임포트하면 테스트 본문이 돌기도 전에 죽는다. 플레이스홀더 환경변수와 동적 임포트로 풀었고, test 스크립트가 아니라 일부러 테스트 파일에 뒀다. 거기에 가짜 URL을 통으로 깔면 다른 데서 진짜로 빠진 설정까지 조용해진다. 이제 함수가 클라이언트를 기본값 인자로 받으므로 프로덕션에는 캐스트가 없고 가짜는 테스트에 갇힌다.

⚠️ 방향 테스트의 첫 판은 엉뚱한 이유로 통과했다. cap 50에 손실 100이면 FIRST_LOSSEXCESS가 둘 다 50을 미보전으로 남긴다. 그 케이스는 어느 갈래에도 동의한다. 틀린 갈래에도 그렇다. 손실 80으로 다시 썼고, 거기서 둘이 0.97과 0.95로 갈린다. 변이 셋이 스위트가 문다는 걸 확인해 준다. 방향을 뒤집으면 15개, 공급량 가드를 원금으로 폴백시키면 4개, R9 이전 분모를 되살리면 4개가 깨진다.

그리고 파트너를 기다리는 건 아무것도 없다. 한때 "Joob 없이는 진행 불가"로 분류됐던 조건 다섯은 시스템에 없던 입력이 아니었다. 기본값이 정의된 설정이고, 값이 없는 상태는 빈틈이 아니라 지원되는 동작이라 핸드오프 문서를 폐기했다. 기본값이 안전하다고 읽히지 않도록 한 번만 적어 둔다. buffer_rate_bps = 0은 실현 손실과 투자자 사이에 담보 말고는 아무것도 없다(확인된 출시 조건). cadence가 NULL이면 제품이 다음 갱신이 언제인지 투자자에게 말하지 않는다. 7개월에 불규칙 보고 7건은 관찰이지 약속이 아니기 때문이다. apy_basis는 리저브가 사실상 0인 동안 대안과 구분이 안 된다. write_off_policy가 NULL이어도 산술은 안 바뀐다. 그 시점은 파트너가 정하고 Aset은 수치를 미러링하기 때문이다. 실제 버퍼를 합의하면 PATCH 한 번이다. 요율과 함께 방향 도 담아야 한다. 같은 5%라도 방향에 따라 8% 손실에서 투자자에게 3%가 남을 수도 5%가 남을 수도 있다. 문서: 06-writedown-nav · 11-db-schema · 26-glossary. 결정: v3-109. 출처: JY(결정) + CH(구현), 2026-08-04.

📅 2026년 8월 3일 — Yield Pending 탭이 read model로 들어가고, PENDING 고아는 뒤를 쓸어 담는 대신 생산자를 지워서 고친다 (v3-104, 마이그레이션 0113)

🌾 죽은 코드 경로만 만들 수 있는 상태를 위해 청소기를 영구 설비로 두는 셈이었다

v3-104 핸드오프 명세에서 둘이 나갔다. 첫째는 아키텍처 항목이다. GET /yield-distributions?include_due=true. Pending 탭은 나머지 네 탭과 같은 소스를 못 읽는 유일한 탭이었다. 도래했는데 기록 안 된 기간은 행이 아니기 때문이다. pools.next_yield_due로만 존재하고 예정 기간 테이블이 없다. 그래서 admin 화면이 브라우저에서 GET /pools?status=PENDING에 조인하고, 추정치를 지어내고(tvl × apy/100 × periodDays/365. 백엔드가 명세한 적 없는 공식이고 v3-20은 추정 금액을 명시적으로 범위 밖에 뒀다) computeYieldFees를 미러링하는 수수료 분할 두 번째 사본 을 들고 있었다. 같은 돈 계산의 구현이 둘인데 서로 대조된 적이 없다. 이제 목록 엔드포인트가 그 행들을 서버에서 합성한다. source: 'RECORD' | 'DUE', missed_periods, 그리고 단 하나의 수수료 구현 이 계산한 estimated_gross/estimated_net이다. 그 구현은 lib/shared/yield/fees.ts로 빼서, 읽기 엔드포인트가 Supabase와 viem과 알림 디스패처를 GET 람다에 끌고 들어오지 않고도 재사용할 수 있게 했다. 프론트는 estimateExpectedGross, computePoolNet, 그리고 YieldCalcBreakdown 안에서 계산되던 금액을 전부 지웠다. Pending 탭은 서버 페이징/정렬/검색으로 옮겼고, CSV 내보내기가 드디어 다른 목록이 아니라 화면의 큐를 내보낸다.

둘째 항목은 명세가 뒤집힌 지점이고, 그쪽이 더 흥미롭다. 명세의 티켓 2는 오래된 PENDING 청소기였다. 온체인을 읽어 DISTRIBUTED로 치유하거나 FAILED로 표시하고, cron에서는 절대 정산하지 않고, 전이마다 알린다. 라이터에 대고 검증해 보니 빈도 메모가 답 전체가 됐다. PENDING을 넣은 적 있는 유일한 라이터는 레거시 서버 키 생성 경로이고, 그 경로는 제품에서 도달 불가 다. admin UI가 fund_wallet이 있는 풀에는 서버 키를 거절하고(fmSignBlocked), 표시 전용이 아닌 것에는 생성 시점에 fund_wallet이 필수이며(v3-26), 표시 전용 풀에는 pool_address가 없어서 핸들러가 먼저 400을 준다. 청소기는 살아 있는 무엇도 못 만드는 상태를 유지하는 스케줄 람다가 됐을 것이다.

그래서 생산자를 지웠다. POST /yield-distributions는 이제 deposit_tx_hash 없이는 400 이다. FM이 서명한 depositYield만이 분배를 기록하는 길이다. 그리고 마이그레이션 0113 이 그 상태를 안 쓰이는 정도가 아니라 표현 불가로 만든다. 기본값을 PENDING에서 PROCESSING으로 옮기고(컬럼을 그냥 생략한 INSERT가 아니면 고아가 조용히 되살아난다) CHECK (status <> 'PENDING')을 건다. 백필은 일부러 좁다. tx가 아예 없는 PENDING 행 → FAILED / ORPHANED_LEGACY_PATH. tx를 가진 PENDING 행은 마이그레이션을 던지고 중단시킨다. 온체인에서 정산됐을 수 있고, FAILED로 표시하면 지급된 기간을 미지급으로 기록하게 되기 때문이다. 그건 SQL이 내릴 판단이 아니다. ⚠️ yield_status enum 값은 안 뺐다. yield_distribution_investors.status가 미청구 배정에 PENDING을 정당하게 쓰므로, 금지는 테이블 수준 CHECK이다.

포기한 것을 적어 둔다: 서버 키 수익 분배는 영구히 포기한다. 백엔드 키가 온체인 depositYield 권한을 쥐는 풀 모델은 이걸 되돌리지 않고는 다시 쓸 수 없다. 코드 변경이 아니라 컨트랙트 권한 결정이다. v3-26이 이미 비수탁 fund_wallet 풀을 요구하므로 받아들일 만하다.

경로를 지우고 남은 것, 그리고 그걸 위해 만든 것. 진짜 실패 방식 하나가 살아남는다. 예치는 착지했는데 /distribute가 안 돈 PROCESSING 이다. 수익은 풀에 있고 홀더는 못 받았다. 복구는 이미 있었고 아무도 못 알아채는 게 빈틈이었다. 그래서 dashboard_alert_counts.pending_yieldstalled_yield로 이름 바꾸고 24시간 넘은 PROCESSING을 가리키게 했다. 가리키는 대상만 바꾼 게 아니라 이름도 바꿨다. "pending"은 Yield 화면에서 아직 기록할 기간을 부르는 말이라, 옛 이름이 독자를 엉뚱한 큐로 보냈다. 그리고 불가능한 상태를 세는 알림을 두면 영원히 0으로 읽히는데, 그건 틀린 것보다 나쁘다. 영영 조용한 알림은 잘 도는 안전망처럼 보인다. 이제 대시보드 알림은 "Stalled Distributions", 펀드 상세 타일은 "Stalled Yield", Yield 화면의 Failed 탭은 Stalled 로 나열하며 표 자체의 액션을 준다(검증할 예치 tx가 있으면 Distribute, 없으면 사유와 함께 비활성. tx 없는 행은 제거된 서버 경로에서 왔고 인덱서가 대사하므로 재시도하면 두 번 지급할 위험이 있다). ⚠️ 짝이 되는 ADMIN 알림은 안 만들었다. 문구에 막혀 있다. notifications/catalog/는 제품 시트를 미러링하므로("여기서 문구를 지어내지 말 것", v3-103) 생산자가 존재하려면 시트 항목이 먼저 필요하다(제안: yield_distribution_stalled).

명세 문구에서 일부러 벗어난 곳 둘. 둘 다 호출 지점에 주석을 달았다. 새 응답 필드는 include_due=true일 때만 붙는다(§3은 "모든 행"이라 했고 §5는 플래그 없을 때 바이트 단위로 동일한 응답을 요구했다. 회귀 가드 쪽이 안전한 독법이다). 그리고 도래 행은 PROCESSING만이 아니라 열려 있는 PENDING 또는 PROCESSING 기록으로 억제한다. 그러면 람다가 0113보다 먼저 나가는 구간에서도 이중 계산이 없다.

알려진 축소와 한계. 운영자가 직접 입력한 총액에 대한 실시간 순액 미리보기는 없다. 서버는 도래한 걸 아는 기간만 가격 매길 수 있고, 임의 총액을 매기려면 두 번째 구현(이 작업이 없애는 바로 그 결함)이나 범위 밖이던 preview 엔드포인트가 필요하다. FM은 총액으로 depositYield에 서명하므로 빠진 순액이 결정을 막지 않고 최종 순액은 영수증에 있다. 배치 모달의 순액 컬럼도 같은 이유로 나갔다. 그리고 합쳐진 페이지(status 없이 include_due)는 1000행 상한의 기록 접두부 위에서 메모리 정렬하므로, 아주 깊은 페이지에서는 그 안에 정렬돼야 할 기록이 빠질 수 있다. 도래 행을 요청하는 유일한 소비자는 Pending 큐이고 그쪽 기록 측은 구조상 비어 있다.

infra tsc 통과 · 223/223(신규 21개. 동일 입력에서 추정치와 computeYieldFees 대조, 명세의 D+115 · 4 periods 케이스를 포함한 missed_periods, 기준 없음일 때 0이 아니라 null인 경로, SGT 월 경계 기간 라벨, 합쳐진 정렬을 덮는다) · 가드 셋 통과 · admin-web tsc -b와 프로덕션 빌드 통과. 도래 행 선택은 라이브 dev DB에 대고 확인했다(후보 풀 7개, 그중 1개는 열린 기록에 밀림, 118일 늦은 QUARTERLY 풀이 정확히 2기간을 지고, TVL 0인 풀이 정확히 null로 추정). 마이그레이션 0113 dev 적용. 문서: 14-decisions v3-104 · 11-db-schema · 15-api-reference · 13-operations · 18-failure-types · 10-status-machines. 출처: CH, 2026-08-03.

2026년 7월 71 entries

📅 2026년 7월 31일 — 알림 시스템 재구축. 행 하나가 두 가지 일을 하고 있었고, 어색한 부분은 전부 거기서 나왔다 (v3-108)

🔔 투자자가 이미 읽은 인앱 알림이 `status = 'FAILED'`를 달고 있을 수 있었다

그 문장이 진단 전부다. notification_logs는 인앱 수신함 항목이자 이메일 발송 로그인 행 하나였다. 그래서 상태는 이메일을 설명하는데 그 행 자체가 투자자가 보고 있는 물건이었다. SUPPRESSED는 "이메일은 건너뜀, 인앱은 정상"을 말하려고만 존재했다. WEBHOOK enum 값은 행 하나에 channel 컬럼이 하나뿐이라 쓸 수가 없었다. 그리고 recipient_id는 행에 따라 user_id이거나 pool id이거나 fund id였다. 발송 워커가 80줄짜리 해석기를 이고 다닌 이유이고, fund_id가 없는 풀의 FM 알림이 이메일도 피드 항목도 오류도 없이 아무에게도 안 닿은 이유다.

오늘의 요구사항에 대고 처음부터 설계하라면 답은 통상적인 분리이고, 그래서 그걸 냈다. notification_events(무슨 일이 있었나) → notifications(누구에게 알리나. 상태 없는 수신함 항목) → notification_deliveries(어떻게 나갔나. 채널당 한 행), 그리고 email_suppressions. 마이그레이션 0110이 백필 없이 옛 테이블을 드롭한다.

팬아웃이 생성 시점으로 옮겨 갔고, 결과가 가장 많은 변경이 그것이다. 읽음 상태가 이제 사람별 이다. 팀원이 알림을 열어도 팀 전체가 지워지지 않는다. 채널을 고르는 자리에서 선호가 적용되므로, 수신 거부는 배달 행이 없음 을 뜻하고 따라서 실패 카운트에 나타날 수 없다(SUPPRESSED는 사라졌다). 수신자를 못 정하면 호출 지점에서 실패한다. 그리고 admin 피드의 펀드 격리 로직은 지웠다. FM이 다른 펀드의 행을 못 보는 이유는 그 행들이 다른 사람 앞으로 돼 있기 때문이다.

누가 이벤트를 받느냐가 이제 이벤트의 속성이다. 카탈로그 항목마다 audience: ['opsTeam', 'fundManagers']를 선언하고, 생산자는 { poolId }만 넘긴다. 생산자 32개 전부에서 기계적으로 유도해 보니 라이브 버그가 나왔다. fund_member_changed, 즉 "당신의 역할과 권한이 변경되었습니다"가 ops 팀 전체로 해석되고 있었다. 딱 한 명에게만 참인 문장이다. 중복 제거도 제약이 됐다. idempotency_key UNIQUE가, 조회 자체가 오류 날 때마다 중복을 통과시키던 fail-open 조회 넷을 대체한다.

🔴 그리고 이 전부와 독립인 바운스 빈틈. SendEmail이 성공했다는 건 SES가 메시지를 받았다는 뜻뿐이다. 몇 분 뒤 오는 바운스를 아무도 안 듣고 있었다. 설정 세트도 있었고 BOUNCED / SPAM_FILTERED enum 값도 있었는데 SendEmailCommand가 그 세트를 한 번도 지목하지 않았다. 그래서 SES가 만든 모든 바운스가 버려졌고, DELIVERED는 시스템이 뒷받침 못 하는 주장이었다. 이제 SNS 웹훅이 MessageId로 대조하고 영구 바운스와 불만을 차단 목록에 넣는다. ⚠️ 배포 때 SES 설정 세트에 SesFeedbackTopicArn을 붙여야 한다. 안 그러면 피드백은 여전히 버려진다.

배달은 이제 큐다(SQS + DLQ). 실제 백오프(1/5/15/60/240분)가 붙는다. 옛 워커는 실패한 행을 즉시 놓아 줘서 9분 만에 세 번을 태웠고, 짧은 SES 장애에 걸린 건 전부 영구 실패했다. 이메일은 저장된 변수에서 발송 시점에 렌더된다. 그래서 문구 수정이 아직 큐에 남은 것에도 닿고, 홀더 1000명 분배가 같은 HTML 문서 1000벌을 저장하지 않는다.

시트 커버리지 54개 중 52개. epoch_demand_finalizedover_funding_detected는 epoch 수요 스냅샷(A1·A2)에 막혀 있다. 그게 없으면 알림이 아직 바뀔 수 있는 총액을 "확정"이라고 말하게 된다. 시트와 코드의 수신자 불일치 넷을 찾았지만 일부러 그대로 뒀다. 구조 리팩터 도중에 누가 이메일을 받는지 바꾸는 건 잘못된 위험이고, 그중 하나는 리저브 부족에 대한 admin 가시성을 없앴을 것이다. 제품과 함께 본다.

infra tsc 통과 · 196/196 · 가드 셋 통과 · cdk synth 통과. 프로듀서 가드가 재구축 중에 값을 했다. fm_notification_failed의 유일한 생산자가 삭제 중인 워커라는 걸 잡았다. 0110을 적용해 dev에 배포한 뒤 라이브 SES에 대고 확인했다. 바운스와 불만이 각각 발송 → SNS → 웹훅을 4초 안에 왕복했고 provider_message_id로 대조됐으며 맞는 사유로 주소를 차단했다. 그 피드백을 나르는 이벤트 목적지는 이제 설정 세트 옆 SesStack에 산다. ApiStack에서 만들면 환경마다 수동 단계가 되고, dev는 완전히 배포된 것처럼 보이는 파이프라인 뒤에서 모든 바운스를 버리고 있었다. 문서: 14-decisions v3-108 · 22-notifications · 11-db-schema. 출처: CH, 2026-07-31.

📅 2026년 7월 31일 — Model B는 머지됐고 배포 안 됐다. 스케줄을 설치하는 세터에 호출자가 없다 (v3-107)

🧩 앵커 스케줄에 대한 건 전부 만들어졌다. 그걸 켜는 호출만 빼고

epoch FE 작업을 시작하기 전에 apps/contract/src / apps/infra / apps/web에 대고 쓰기 경로를 감사했다. 엔진은 머지됐다. 그걸 설치하고 운영하는 배관이 아니다.

🔴 setEpochSchedule은 어디에도 호출자가 없다. 배포 설정 호출 목록(lib/shared/contract/create-pool.ts)에도, 람다에도, admin 앱에도 없다. 세 인자는 CreatePoolOnChainParams의 필드조차 아니다. 백엔드는 funding_anchor_date / recall_lead_days / request_window_days를 모으고 검증하고 데이터베이스에서 멈춘다. 그래서 모든 풀이 fundingAnchor == 0으로 배포되고 PoolCommonLib.hasAnchoredSchedule이 false로 읽히며 요청 윈도 게이트가 건너뛰어진다. 게으른 시계가 돌아오고 틈에서 요청이 받아들여진다. v3-100은 이걸 "Model B는 플랫폼 전체가 아니라 새 풀의 속성"으로 적었다. 호출자가 없으니 어느 풀의 속성도 아니고, 레거시 풀 7개를 지우면 Model B 풀만 남는다는 FE 전제도 성립하지 않는다. 빠진 줄 하나보다 나쁘게 만드는 제약이 둘이다. 세터가 recallLeadDays + requestWindowDays < epochDurationDays를 검증하므로 setEpochDurationDays 뒤에 돌아야 하고, 생성 전용 이라 앵커 없이 첫 입금을 받은 풀은 영영 앵커될 수 없다. 이건 프론트만이 아니라 풀 생성을 막는다. 결정: 배선하고, 배포 후 앵커를 단언하고, DB엔 스케줄이 있는데 체인엔 없는 상태는 지원되는 설정이 아니라 배포 결함 으로 다룬다.

확정 / 예정 배지가 컬럼을 얻는다. v3-105는 배지가 데이터베이스를 읽는다고만 정하고 어디인지는 안 말했다. pools.next_funding_date_confirmed_at + next_funding_date_set_by이고, 온체인 setEpochFundingDate tx가 확정될 때만 쓰며 정산이 회차를 넘길 때 null로 만든다. 출처는 회차마다 다시 무장해야 한다. 안 그러면 배지가 이전 회차의 "확정"을 물려받는다. 두 컬럼 다 없으므로 이건 세부가 아니라 previewEpochClaim 옆의 강한 FE 블로커 다.

2026-08-04 갱신: 두 컬럼 모두 dev에 적용됐다(마이그레이션 0111_epoch_funding_date_provenance). 이 블로커는 닫혔다. previewEpochClaim은 별개 축이었고 그것도 닫혔다. 2026-08-04 dev 배포(아래). ⚠️ 새 풀만이므로 FE에는 새 팩토리로 만든 풀이 필요하다.

쓰기 경로 둘과 읽기 경로 하나가 더 있다. setEpochSettleAfter도 호출자가 없어서 "발행사가 늦으니 정산을 미룬다"는 반자동 손잡이의 절반이 닿을 수 없다(SettleAfterTooLate(cap)이 상한을 드러내야 한다. 운영자가 그걸 알 다른 길이 없다). 인덱서에는 스케줄 이벤트 셋 중 어느 것의 라이터도 없어서 설치된 조건도 확인도 지연도 미러링되지 않는다. 셋 다 GovernanceLib 선언이라 ABI 조각이 필요하고, 그러면 이미 배포된 풀에 대해서도 소급 복구된다. 그리고 GET /pools/{id}DETAIL_SELECT에서 스케줄 컬럼 여섯 개를 전부 빠뜨리는데 LIST_SELECT는 나른다. 투자자 풀 상세 페이지가 그걸 null로 읽으므로 날짜도 배지도 카운트다운도 타임라인도 아예 렌더될 수 없다.

처음으로 기록 — C7: 청구는 만료되지 않는다. 2026-07-24에 결정했는데 문서에 적힌 적이 없다. 기한도 스윕도 남은 홀더로의 환원도 없다. RWA 맥락에서 시간 경과로 투자자 돈을 몰수하는 건 지지할 수 없고, 예약분은 redemptionCommitted에 격리돼 있어서 미청구 지급이 아무것도 막지 않는다. 받아들인 비용: redemptionCommitted는 스스로 안 비므로 wind-down 분자가 영구히 줄고 hold-back clamp가 조여진다. 나중에 만료로 "정리"되지 않도록 기록해 둔다.

페이지 넷이 아직 대체된 엔진을 설명하고 있었다. 2026-07-31 동기화 회차가 07-redemption / 23-money-path / 04-pool-models를 덮고 멈췄다. 08aexecuteEpoch 행은 한꺼번에 세 가지가 틀렸고("돈이 안 움직임" · "구현 대기(CH)" · "epoch별 pot") 함수 표에는 epoch 세터가 아예 없었다. setEpochSettleAfter문서 어디에도 없었다. 10-status-machines는 아직 "⚠️ 엔진 재설계 결정됨, 구현 대기"를 달고 있었다. 15-api-reference는 아직 POST /pools/{id}/freezepropose_extend / execute_extend / cancel_extend를 광고했는데 셋 다 410 Gone이다. 08-smart-contracts는 아직 FreezeExtend* 이벤트를 살아 있는 타임락 경로로 나열했다. 전부 고쳤다.

문서: 14-decisions v3-107 · 07-redemption · 08a-contract-reference · 10-status-machines · 11-db-schema · 15-api-reference · 08-smart-contracts. 출처: JY, 2026-07-31.

📅 2026년 7월 31일 — epoch 낡은 문서 동기화(결정 없음), 그리고 hold-back이 스스로 풀리지 않는 것으로 드러났다

🧹 epoch 흐름 절이 아직 우리가 대체한 엔진을 설명하고 있었다

재설계 이전 epoch 설계를 이고 있던 페이지들을 훑었다. 앞선 문서가 아니라 apps/contract/src에 대고 다시 확인했다. 독자가 그대로 행동할 만한 방식으로 넷이 틀렸다.

단계별 흐름(07-redemption §Flow)이 통째로 재설계 이전이었다. 정산이 "돈을 안 움직인다"고 적었는데 움직인다. _reserveFilledGross가 충당된 총액을 epochFundTopUp[id] → heldFundReleases → reserveBalance 순으로 차감해 redemptionCommitted 스칼라에 주차하고, 그게 이중 커밋 수정의 전부다. 단일 fillRatio를 설명했는데 엔진은 이월분 먼저, 그다음 신규 수요 를 각 계층 안에서 pro-rata로 채운다. 정산 게이트를 settlementAllowedAt(funding date. + recallLeadDays까지만 미룰 수 있다)이 아니라 currentEpochEndsAt으로 잡았다. 이미 나간 것 둘을 미래형으로 적었다. 요청 윈도 게이트와 요청 시점 수익 정지다(YieldLib이 에스크로된 LP를 분배 분모에서 뺀다). 먼지를 "→ 리저브"로 적었는데 하한 먼지는 redemptionCommitted 안에 안 쓸린 채로 남는다. 그리고 lpRemaining이 청구 시점에만 줄어든다 는 걸 빠뜨렸다. v3-105 취소 게이트 전체가 그 사실 위에 서 있다.

Model B에는 거처가 없었다. 윈도 의미가 v3-93 개정 문단 안의 절로만 존재해서, 페이지가 "윈도 사이에는 요청이 거절된다"고 평범하게 말한 적이 없다. 이제 자기 절을 갖고 축 셋(epoch_duration_days 주기, request_window_days, recall_lead_days), funding date에서 거꾸로 유도하는 법, ASCII 타임라인, 위기 시 오버라이드, 레거시 풀 예외를 담는다. 관련: epoch_schedule_typeDB 라벨 로 문서화한다. 온체인에 scheduleType은 없고 주기는 epochDurationDays이며 MONTHLY → 28 / QUARTERLY → 84 매핑은 BE 쪽이다. 컨트랙트가 달력 계산을 전혀 안 하는 이유가 정확히 그것이다. ("28/84 삭제됨"이라는 오류 자체는 v3-91에서 이미 고쳤다. 남아 있던 건 04-pool-models가 아직 scheduleType을 이름 대고 철회된 epochClaimable pot을 열린 항목으로 부르던 것이다.)

🔴 hold-back은 자동으로 안 풀리고, 제품에서 그걸 켜고 끌 수 있는 것도 없다. 이 체인지로그의 조상 문장과 v3-26 (epoch)이 둘 다 90% hold-back이 "백로그가 해소되면 자동 릴리스된다"고 적었다. GovernanceLib을 읽으면, 유일한 트리거는 setFundingRestricted(false)이고 그 트랜잭션 안에서 _releaseHeldFunds를 한 번 돌린다. physicalBalance − (reserveBalance + redemptionCommitted + unclaimedYield)로 클램프되고 못 푼 잔액은 버킷에 남는다. 다시 시도하는 게 아무것도 없다. 게다가 setFundingRestrictedOnChain은 어느 람다에도 admin 화면에도 호출자가 없어서, 집행 레버는 현재 직접 컨트랙트 호출로만 닿는다. 파급이 둘이다. 레버가 제품에서 사실상 사용 불가이고, 이미 나간 holdback_release_deferred 알림이 FM에게 잔액이 "그 청구들이 정산되면 자동으로" 지급된다고 말했다. 라이브 문구에 있는 CLAUDE.md 규칙 2-b 결함이다. 거짓 주장은 같은 회차에서 뺐다(시트 + 레지스트리). 두 문자열 모두 이제 장치를 주장하지 않고 조건만 적으며, /automatically/를 단언하던 단위 테스트(그 주장이 리뷰를 통과한 이유)가 이제 반대를 지킨다. 지급을 진짜 자동으로 만들려면 커밋된 채무가 해소되면 setFundingRestricted(false)를 부르는 스윕이 필요하다. 안 만들었다.

⏸️ 이벤트는 보류이고 문구는 잠정이다(JY, 2026-07-31). hold-back에 제품 경로가 없어서 FundingRestrictedSet(restricted=false)는 어떤 Aset 화면으로도 일어날 수 없고, 항목·생산자·문구가 전부 휴면이다. 그래도 거짓 주장을 뺀 건 맞는 선택이었다. 거짓 문구가 기능을 기다리며 레지스트리에 앉아 있을 자격은 없다. 수동 컨트랙트 호출이면 그게 나가기 때문이다. 다만 그건 최종 승인이 아니다. 최종 문구는 출시 후 hold-back을 구현할 때, 레버의 실제 동작(그리고 릴리스 스윕이 있는지)이 밝혀질 때 정한다. 문구 시트에 [HOLD]로 표시했고 일부러 inAppTitle에는 안 미러링했다. prioritycritical 그대로다. 발송 차단 게이트는 안 넣었다. 이벤트가 발동할 수 없고, 그 가드는 hold-back 설계의 몫이다.

리저브와 hold-back은 이제 별개 버킷으로 문서화된다. 23-money-path가 풀 잔액 하나를 공유하는 의무 넷(reserveBalance / heldFundReleases / redemptionCommitted / unclaimedYield)과 뒷받침 불변식을 얻고, 그다음 자주 뒤섞이는 둘을 나란히 놓은 표를 얻는다. 리저브는 풀 자신의 완충이고 NAV 손실을 흡수 한다. hold-back은 주차된 파트너의 돈 이고, 손실 흡수를 왜곡하지 못하도록 절대 리저브에 더하지 않으며, 리저브보다 먼저 쓰인다. 같은 페이지가 wind-down 분자(reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted)도 얻는데, nav = reserve / totalSupply가 🔴W 청산 버그였고 v3-100에서 고쳐 배포됐다는 명시 메모가 붙는다. 일반 NAV 하한도 아니고 아직 열린 것도 아니다.

⚠️ 위의 "리저브 … NAV 손실을 흡수"는 R8(v3-109, 2026-08-04)로 물러났다. 리저브는 손실을 흡수하지 않는다. 가격이 매겨지는 청구권 안에 이미 들어 있는 투자자 돈이라 상계하면 이중 계산이 된다. 상환 유동성일 뿐이다. 이 항목의 나머지(공유 의무 넷, hold-back 구분, wind-down 분자)는 유효하다. 역사로 그대로 둔다.

건드린 페이지: 07-redemption · 23-money-path · 04-pool-models · 14-decisions(v3-26 epoch hold-back 불릿 정정). vitepress build 통과. 같은 리뷰에서 나온 NAV/트랑슈 거버넌스는 v3-106이다. 출처: JY, 2026-07-31.

📅 2026년 7월 31일 — 전멸한 Junior가 데이터베이스에서만 부실 처리되고 있었고, 상각에는 두 번째 서명이 필요 없었다 (v3-106)

🧨 트랑슈 워터폴이 온체인에서 NAV를 내리고, 그다음 오프체인에서 에스컬레이션했다

POST /tranche-writedown은 어려운 부분을 제대로 한다. 그룹 손실 하나를 후순위 구조로 분해하고, 수동 경로가 쓰는 그 updateNAV를 통해 풀마다 새 NAV를 밀어 넣으며, 영향받는 풀 전부에 사전 시뮬레이트를 돌려 예측 가능한 리버트가 무엇도 움직이기 전에 4xx로 드러나게 한다. 그러고는 방금 전멸시킨 Junior에 대해 lifecycle_status = 'IMPAIRED'impairment_proposed_at = now()pools에 바로 쓰고 온체인은 아무것도 안 부른다.

v3-92 실패의 반복이다. 데이터베이스가 체인에 없는 상태를 주장하는 것인데, 이번 사례는 빠져나갈 길이 없다. 온체인에서 풀은 ACTIVE로 남으므로 lifecycle을 읽는 게이트는 계속 ACTIVE처럼 군다. 락업과 패널티 면제가 적용되지 않는다. 전멸한 트랑슈의 투자자가 여전히 락업에 막히거나 조기 이탈 패널티를 물면서, UI는 위기 조건이 발효 중이라고 말한다. 복구 경로는 전부 막혀 있다. propose는 타임스탬프와 비-ACTIVE 상태 둘 다에서 409를 주고, execute는 자기 DB 쪽 타임락 검사를 통과한 뒤 온체인에서 NoImpairmentProposal로 리버트하며, setLifecycleStatus는 설계상 IMPAIRED를 거절한다.

결정(v3-106): wipedOut 갈래가 proposeImpairmentOnChain을 부르고, DB는 제안됨 만 기록하며, 라벨은 다른 모든 impairment와 마찬가지로 7일 타임락 뒤 executeImpairment에서 뒤집힌다. 더 빨리 막아야 하는 입금은 lifecycle 쓰기가 아니라 is_paused온체인 pause() 로 막는다. 지연이 곧 기능이다. IMPAIRED는 투자자에게 보이는 면제가 붙은 공개 지급능력 신호다.

같이 내린 판단 둘. 엔드포인트가 ADMIN / SUPER_ADMIN으로 간다. 지금은 OPERATOR를 받으므로 운영자 한 명이 호출 한 번으로 그룹 전체를 상각할 수 있다. A4 승인은 일부러 여기까지 넓히지 않는다. 워터폴은 손실 하나가 N개의 NAV 쓰기로 분해된 것이라, 구간마다 승인 큐를 두면 그룹이 절반만 승인된 채 정산될 수 있다. 그리고 완전 소진만 IMPAIRED다. 부분 손실을 흡수한 Senior는 NAV < 1.0인 채로 ACTIVE에 남는다. v3-12의 상각 대 lifecycle 구분을 트랑슈에 적용한 것이고, 그래서 그룹이 IMPAIRED Junior와 ACTIVE Senior를 정당하게 나란히 둘 수 있다.

그리고 "NAV 0"을 우리가 설명해 온 방식에 대한 정정. NAV 하한 장치는 없다. 실제 동작은 서로 무관한 가드 셋이다. 컨트랙트가 newNav == 0을 거절하는 것, DB CHECK (nav_per_token > 0), 그리고 tranche.post.writedown.ts 안에만 사는 NAV_FLOOR = 0.000001이다. 여기에 epoch 정산의 별개 오라클 손상 가드가 붙는다. 그러니 0은 어디서도 표현될 수 없다. 전멸한 트랑슈는 1e-6이고, "사실상 0"이라고 말하는 건 숫자가 아니라 lifecycle 전이다. ⚠️ 잠재: navDeviationCapBps > 0이면 완전 전멸을 아예 적용할 수 없다(1.0 → 1e-6은 어떤 상한도 넘는다). 라이브는 아니다. setNavDeviationCap을 부르는 게 없다. 다만 그걸 켜는 사람은 상한 해제 → 상각 → 복원 단계를 먼저 밟아야 한다.

같은 페이지에 묶여 있던 wind-down 분자 항목은 이미 나간 것이지 열린 게 아니다. executeWindDownv3-100 이후로 reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted로 청산 가격을 매겨 왔다. 거기서 남은 건 문구다. pools.post.wind-down 알림 문구와 PlatformPool.sol:762 독블록이 아직 리저브만 쓰는 공식을 설명한다. 문서: 14-decisions v3-106 · 04-pool-models · 06-writedown-nav · 10-status-machines. 출처: JY, 2026-07-31.

📅 2026년 7월 31일 — 부분 체결 뒤 취소하면 LP를 돌려주고 현금은 주인 없이 남았다. 이제 취소 전에 청구해야 한다 (v3-105)

✂️ 예약분에 주인이 없었고, 윈도 게이트는 아무것도 안 지키고 있었다

v3-100이 이걸 CH에게 남겼다. 정산은 체결 총액을 redemptionCommitted로 옮기면서 LP를 안 태운다. 소각과 지급 둘 다 청구를 기다린다. 그런데 cancelRedemption은 여전히 포지션 전체인 ep.lpRemaining을 돌려주고, 요청은 청구 경로가 거절하는 상태에 남긴다. 감소는 _payCommitted뿐이라 예약분이 주인 없이 영구히 앉아 있다. 이걸 구석 사례가 아니라 진짜 결함으로 만드는 건 이렇다. 취소는 윈도 게이트가 걸려 있고 정산은 컷오프 인 funding date에 일어나므로, 부분 체결된 투자자가 할 수 있는 유일한 취소는 미청구 체결 뒤에 오는 취소다. C10의 이월 예외는 이 상황을 막는 울타리가 아니라 그리로 들어가는 길이다.

여기서 손해 보는 건 투자자가 아니다. 포지션 전액에 해당하는 LP를 들고 나간다. 손해는 풀이 두 번 본다. 정산에서 현금이 유동성 버킷을 떠났고, 부풀려진 redemptionCommittedwind-down 분자 에서 영구히 빠지며(남은 홀더가 정확히 그만큼 덜 받는다) hold-back 릴리스 clamp 를 영구히 조인다(그만큼은 파트너에게도 영영 못 간다).

결정(v3-105): 청구할 게 남아 있으면 취소를 거절한다. filledLp > 0 → revert ClaimBeforeCancel. 새 상태도 요청별 회계도 없다. 사용 흐름은 청구하고 나서 취소이고, 첫 단계는 언제나 가능하다. claimRedemption에는 윈도 게이트가 없기 때문이다. 이미 청구한 요청은 다음 정산까지 filledLp == 0으로 읽히므로 평범한 취소는 막히지 않는다.

2026-08-05 갱신: 나갔다. 리버트는 RedemptionLib.sol:626에 있고 2026-08-04 dev 배포다(새 팩토리 → 새 풀 구현체, 커밋 0abe168). ⚠️ 새 풀만이다. 그 배포 전에 만든 풀은 옛 구현체의 clone이라 여전히 포지션 전체를 돌려준다.

"안 채워진 잔량만 돌려준다"는 이 구조에서 만들 수 없는 것으로 드러났다. _epochFillMath는 체결분을 principalLp × epochNewFillRatio[vintage]에 carry 구간을 더해 유도하므로, principalLp를 체결액으로 줄이면 이미 그 비율이 반영된 숫자에 같은 vintage 비율을 다시 적용하게 된다. 그걸 고치려면 요청을 순수 carry 형태로 다시 앵커해야 하는데, 그게 정확히 _settleEpochClaim이 하는 일이다. 그 선택지의 모든 판본이 체결분을 먼저 정산하므로, 결국 위의 결정에 상태만 더한 것이 된다. 취소 안에서 자동 청구하는 안도 기각했다. 트랜잭션 하나를 아끼기는 한다. 다만 청구는 canRedeemrequestHeld를 게이트로 두므로, 접어 넣으면 KYC가 회수됐거나 홀드된 투자자가 아예 취소를 못 하게 된다. 취소에 일부러 게이트를 안 두는 이유가 바로, 풀 수 없는 LP를 쥔 채 갇히는 사람이 없게 하려는 것이다.

🔴 이게 없는 뷰 하나 때문에 프론트엔드를 막는다. 분할은 공개 상태로 계산할 수 없다. epochRequests(epochId, principalLp, lpRemaining, yieldAccSnapshot)을 주고 gBase / hBase / generation이 없으며, epochH / epochCarryGen / generationCloseH에는 게터가 아예 없다. 그래서 previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD)는 있으면 좋은 게 아니라 필수다. 그게 없으면 "60 체결 / 40 이월" 표시도, 정직한 취소 문구도, 게이트가 왜 발동했는지 설명할 방법도 없다. _epochFillMath는 이미 view다. 이건 래퍼다.

2026-08-05 갱신: 나갔다. previewEpochClaim(PlatformPool.sol:317RedemptionLib.sol:703)은 2026-08-04 dev에 배포된 구현체에 있다. 셀렉터 9ccd0389로 확인했다. ⚠️ 새 풀만이다. 기존 풀에서는 여전히 리버트하므로 FE는 새 팩토리로 만든 풀이 있어야 테스트할 수 있다.

후반부: "확정 / 예정" 배지는 데이터베이스를 읽는데, 오늘 상태로는 언제나 예정이라고 말한다. 저장된 온체인 날짜가 확정된 날짜라는 뜻은 아니다. 정산이 fail-open 날짜를 이벤트 없이 같은 슬롯에 실체화하고 1회차는 앵커에서 백필되므로, 원시 게터는 아무도 확정 안 한 날짜를 "확정"으로 보고한다. 진짜 플래그는 새 스토리지와 새 뷰가 필요한데 풀은 EIP-1167 clone이라 새 풀만 덮게 된다. 그래서 출처는 프론트가 이미 읽는 pools.next_funding_date 옆에 둔다. 그런데 apps/infra에서 setEpochFundingDate를 부르는 게 없다. admin 편집 경로가 DB에서 멈춘다. 그러니 지금 모든 온체인 회차 날짜는 fail-open 파생값이고, 확인 액션이 체인에 닿아야 배지에 의미가 생긴다. 그러면 SEMI_AUTO 알림도 무엇에 대한 알림인지가 생긴다. 감사용으로는 EpochFundingDateSet이 유일한 정확한 확인 신호다. GovernanceLib에 선언돼 있어서 PlatformPool.abi.json에는 없지만 로그는 풀 주소 아래 나오므로, 조각을 추가하면 컨트랙트 변경 없이 소급해서 복구된다.

열린 것: 청구 우선 게이트가 들어가면 KYC가 회수된 epoch 투자자는 청구도 취소도 못 하고, rejectRedemptionQUEUED / PARTIALLY_FILLED를 받은 적이 없다. 그래서 어떤 admin 액션으로도 요청을 닫을 수 없다. P1이다. 문서: 14-decisions v3-105 · 07-redemption · 08a-contract-reference. 출처: JY, 2026-07-31.

📅 2026년 7월 31일 — admin Yield 목록이 브라우저에서 돈을 계산하고 있었다. 도래 기간이 read model로 옮겨 간다 (v3-104)

💰 Pending 탭은 엔드포인트 둘을 프론트에서 조인한 것이었고 자기 수수료 계산까지 갖고 있었다

나머지 Yield 탭 넷은 yield_distributions 행을 나열한다. Pending은 아직 행이 없는 풀을 나열한다. 분배 기록은 누군가 총액을 기록해야 만들어지고, 그전까지 의무는 pools.next_yield_due / yield_overdue로만 존재한다. 예정 기간 테이블이 없다. 그래서 그 탭은 브라우저에서 GET /poolsGET /yield-distributions?status=PENDING에 조인하고, 금액 칸을 채우려고 추정치를 지어냈고(tvl × apy/100 × periodDays/365. v3-20 명세는 추정 금액을 명시적으로 범위 밖에 뒀다) computeYieldFees를 미러링하는 수수료 분할 두 번째 사본 을 들고 있었다. 같은 돈 계산의 구현이 둘이고 서로 대조하는 테스트가 없다. 백엔드에서 수수료 정책이 바뀌면 화면 숫자가 낡은 채로 남고 실패는 한 건도 안 난다.

결정(v3-104): GET /yield-distributions?include_due=true. 페이로드 하나에 실제 기록과 합성된 도래 행이 함께 오고 source: 'RECORD' | 'DUE'로 구분된다. 도래 행마다 풀, 기간 라벨, 도래일, 그리고 실제 분배를 정산하는 그 수수료 코드가 서버에서 계산한 추정 총액/순액이 붙는다. 정렬·검색·페이징·역할 범위도 다른 탭들처럼 서버 쪽이다. admin 프론트는 표시 전용으로 떨어진다.

DUE 행을 yield_distributions에 실체화하는 안은 기각했다. 그건 말 그대로 "테이블 하나"이고 바로 그게 위험이다. 원장 테이블에 돈을 안 나르는 행이 생긴다. platform_stats 총 수익, 투자자용 목록, 인덱서의 tx_hash 대사, /{id}/distribute 가드가 각각 새 상태를 제외해야 하고, 하나만 놓쳐도 홀더에게 지급된 액수를 부풀린다. 원장은 사실을 담고, 사실과 의무를 섞는 목록은 read model이다.

그 추정치의 파라미터 둘도 같이 정했다. 기준은 TVL에 대한 APY 이고, 나중에 바뀌면 보이도록 행에 estimate_basis: 'APY_ON_TVL'을 붙인다. 직전 기간 실제 총액에 앵커하는 안은 첫 분배에서 비어 있고 TVL이 움직이면 낡는다는 이유로 기각했다. 그리고 풀은 missed_periods를 담은 도래 행 하나 를 받고 추정치가 놓친 기간 전부를 덮는다. 일일 스윕은 놓친 next_yield_due를 앞으로 굴리지 않으므로, 안 그러면 4개월 늦은 풀이 한 기간짜리 수치를 보여 주며 채무를 4분의 1로 줄여 말한다.

🔴 그리고 명세를 따라가다 나온 것: PENDING 행에는 주인이 없다. yield_distributions에 넣는 라이터는 둘뿐이다. FM 경로는 PROCESSING을 넣고 /{id}/distribute를 위해 멈춘다. 그러니 진짜 "기록됐지만 아직 분배 안 됨"은 PENDING이 아니라 PROCESSING이다. 레거시 서버 경로는 PENDING을 넣고 같은 호출 안에서 정산한다. 인덱서는 DISTRIBUTED만 쓴다. 따라서 PENDING에 앉아 있는 행은 insert와 settleYield 사이의 크래시 고아이고, 아무것도 그걸 치유하지 않는다. 인덱서는 tx_hash로 대사하는데 그 행엔 아직 없고, yield.scheduler.reconcileclaimable_yield만 미러링한다. 그 기간은 영원히 미분배로 읽히고 홀더가 지급받았는지 DB로는 알 길이 없다. 빈도는 낮지만(PENDING을 넣는 경로는 레거시 서버 키인데 admin UI가 fund_wallet 있는 풀에 대해, 즉 실제 온체인 풀 전부에 대해 거절한다) 결과는 그렇지 않다. 청소기는 결정만 하고 안 만들었다. 핸드오프 티켓 2이고, 아키텍처가 아니라 정확성 항목이다. (2026-08-03 대체: 청소기는 끝내 안 만들었다. PENDING을 넣는 경로가 제품에서 도달 불가라, 경로를 지우고 마이그레이션 0113이 그 상태를 아예 금지한다. 위의 8월 3일 항목 참조.)

오늘 제품에서 실제로 바뀐 것. Pending 탭이 이제 공유 DataTable로 렌더되므로, 손으로 만든 행 목록 대신 기록 탭들과 같은 컬럼 헤더, 정렬 가능한 헤더, 검색 행, 페이지네이션 푸터를 갖는다(컬럼은 1:1로 매핑된다. Date → Due, Total Amount → Est. Amount, Status → 연체 D+n). 풀 이름 링크에 flex-1이 붙어 있어서 앵커 박스가 행의 남는 폭을 다 덮었고, "이 행 선택"으로 의도한 클릭이 전부 풀로 이동했다. 그리고 막힌 행들이 거기서 나갔다. 그 탭은 기록할 기간의 큐이므로 배지가 이제 행 수와 같다(배지와 표와 Overview 패널이 "pending"의 서로 다른 세 뜻으로 7, 8, 1을 보여 주고 있었다). 그리고 Failed 가 주목 탭이 된다. FAILED 표에 PENDING 고아의 Stuck 표가 붙고, 행 액션은 없으며, 비어 있지 않을 때만 렌더되므로 청소기가 나오면 사라진다. Overview의 Pending 행도 같은 이유로 Stuck으로 이름이 바뀐다. (2026-08-03 대체: StuckStalled가 됐고 이제 24시간 넘은 PROCESSING 행을 나열한다. 마이그레이션 0113 이후 PENDING은 불가능하고, PENDING 고아와 달리 정체된 행에는 안전한 복구가 있어서 표가 Distribute 액션을 유지한다.) 문서: 14-decisions v3-104 · 13-operations · 15-api-reference · 18-failure-types. 출처: JY, 2026-07-31.

📅 2026년 7월 31일 — 알림 문서가 코드를 따라잡는다. 키 56개, 문구의 단일 출처는 코드, 그리고 아무것도 배달되고 있지 않다 (v3-103)

📮 페이지 둘이 이미 고쳐진 시스템을 설명하고 있었다

22-notifications13-operations가 아직 2026-07-28 감사를 이고 있었다. 문구 레지스트리를 우회하는 생산자 여덟, 생산자 없는 freeze 마일스톤, 강제되지 않는 투자자 선호, 아무것도 안 보내는 impairment/wind-down 문구다. 넷 다 ch/product 작업에서 고쳐졌고 그중 어느 것도 문서에 안 닿았다. 그래서 페이지들은 더 없는 빈틈으로 독자를 안내하면서, 이제 존재하는 이벤트 넷은 빠뜨리고 있었다. copy.ts와 생산자 맵에 대고 키 단위로 다시 확인했다. 레지스트리 키 56개, 가드 통과.

문구의 단일 출처는 이제 코드다(v3-103). copy.ts와 Notification Copy 시트를 손으로 같이 고치다 보니 양방향 으로 갈라졌고 어느 쪽이 이기는지 규칙이 없었다. 감사에서 나온 실제 차이는 전부 코드 쪽이 옳았고(각각 문서화된 이유나 버그 수정이 뒤에 있었다) 렌더되는 건 코드다. 시트는 레지스트리의 뷰가 되고, 손으로 맞추는 대신 생성 하기를 권한다. 손으로 맞춘 미러는 다시 갈라진다. 먼저 고쳐야 할 시트 행 하나는 redemption_rejected인데, 상세 행에 아직 {reason}을 담고 있다. 그건 운영자의 내부 AML 메모이고, 제목에 렌더하면 PII 유출이자 수사 정보 누설 위험이다.

문구 관련 미결 넷을 같이 닫았다. holdback_release_deferred는 critical이다(FM이 안 그러면 오류로 읽을 금액 차이를 설명하는 유일한 메시지다). fund_member_changed는 두 시점을 유지한다. 당사자에게 가는 이메일은 2인칭, 인앱은 3인칭이다. ADMIN 행은 공유 운영 피드이지 개인 수신함이 아니기 때문이다. 운영자 범위 분리는 명시적으로 미룬다. ADMIN 피드에서 운영자를 떼려면 새 recipient_type과 admin 앱 키 32개에 걸친 수신자 결정이 필요한데, 그 값을 치를 운영자 전용 이벤트가 아직 없다. 그리고 em dash 금지 규칙이 이제 알림 문구에도 적용된다. 그건 어제 여기 열려 있던 질문이었고 이제 정리됐다. 알림 문자열은 플랫폼을 떠나는 사용자 대상 텍스트이므로, 예외로 두면 제품에서 가장 많이 읽히는 문구만 기계가 쓴 것처럼 들려도 되는 유일한 표면이 된다. 대체는 공백 하이픈이다. 시트는 이미 변환했고 copy.ts 항목 전수 훑기는 BE 작업이다(dev 핸드오프 §B3).

문서가 빠뜨리고 있던 것. 새 이벤트 넷(redemption_exit_gate_blocked, epoch_funding_date_due, holdback_release_deferred, pool_lifecycle_active)과 *_ops 인지 변형, freeze_exit_window_open / freeze_expired를 실제로 만드는 freeze 자동 만료 스윕, 그리고 투자자 선호 강제(2026-07-30 라이브, 마이그레이션 0108)다. pool_lifecycle_active는 첫 선택형 투자자 이벤트이고, 그 덕에 선호 강제가 관찰 가능해진다. 수신자는 명시적 옵트인 목록(pool_follows, 0109)이다. 문구가 "알림을 요청하셔서"라고 말하는데 요청 안 한 사람에겐 거짓이기 때문이다. 반대로 사라진 것: freeze_extended 와 그것이 보고하던 온체인 연장이다. 7일 freeze에 7일 타임락은 freeze가 끝난 뒤에야 발동하거나 72시간 이탈 차단을 소급해 다시 시작시킬 뿐이었다.

🔴 그리고 이 전부를 제치는 것: 아무것도 배달되고 있지 않다. 발송 워커가 SENDING 행을 소비하지 않고 마지막 DELIVERED 행이 2026-07-03 이다. 생산자와 문구와 페이로드는 완성돼 있고 마이그레이션 0100–0109를 적용해 dev에 배포돼 있다. 그러니 이 페이지들의 모든 "라이브" 주장은 코드 경로에 대한 것이지 메일이 도착한다는 뜻이 아니다. EventBridge 규칙은 존재하므로 첫 질문은 "워커가 왜 고장 났나"가 아니라 "실제로 배포된 게 어느 커밋인가"다. 백로그 위험은 이미 해소했으므로(1139 → 8행) 폭풍 없이 배출을 켤 수 있다. 두 페이지 모두 이제 배달을 함의하는 대신 그 단서로 시작한다.

13-operations의 이벤트별 표는 갱신이 아니라 제거했다. 키 56개짜리 레지스트리의 13행 스냅샷이 자꾸 목록으로 읽혔다. 거기 남는 건 운영 규칙이다. 채널, critical 대 optional, 재시도.

스키마 페이지도 같은 드리프트를 한꺼번에 두 방향으로 이고 있었다. 11-db-schema는 0107까지 확인돼 있어서 알림 마이그레이션 둘이 없었다. 0108(투자자 선호를 event_key로 다시 키잡음)과 0109(pool_follows)다. 더 나쁜 건, 머리말은 0107까지 테이블 46개라고 하는데 자기 꼬리말은 아직 마이그레이션 0050까지 테이블 40개 라고 적고 있었다. 한 페이지에 두 수치가 테이블 일곱 개와 마이그레이션 59개만큼 어긋난 채 있었다. 둘 다 이제 schema.sql에 대고 확인한 0109까지 테이블 47개 · enum 26개 로 읽고, 0100–0107의 _(pending dev apply)_ 표시는 지웠다. 그 묶음은 2026-07-30에 dev에 닿았다. 문서: 22-notifications · 13-operations · 11-db-schema · 14-decisions v3-103. 출처: JY, 2026-07-31.

📅 2026년 7월 31일 — 수익 기간이 이제 트랜잭션 하나다. 수수료 구간이 성공한 분배 뒤에서 실패할 수 없다 (v3-102)

🧾 백엔드가 걷지도 않은 수수료를 기록하고 있었다

runYieldDistributiondistributeYield(net)를 부르고 나서 withdrawFees(...)를 부른 뒤 두 번째를 .catch()로 콘솔 한 줄에 넘겼다. 그러니 수수료 구간이 실패하면 홀더는 적립되고 수수료는 안 걷힌 채로 행이 DISTRIBUTED로 마감되며 fee_amount걷힌 것처럼 기록됐다. 게다가 고칠 방법이 없었다. POST /{id}/distributeDISTRIBUTED 행에서 일찍 반환하고 withdrawFees는 호출자가 정확히 하나여서, 어떤 제품 화면으로도 재시도할 수 없었다. 플랫폼 밖에서 컨트랙트를 직접 불러야만 걷혔을 것이다. 돈이 사라진 건 아니다(unclaimedYield에 남는다). 다만 데이터베이스가 일어나지 않은 지급을 주장했고, 그 잔재가 unclaimedYield를 영구히 부풀려 hold-back 릴리스가 파트너에게 주는 금액을 조용히 줄인다. 현실적인 촉발 조건은 평범했다. pool_mgmt_bps는 있는데 fund_fee_wallet이 설정 안 된 풀(즉시 적용되는 타임락 없는 세터라 흔한 설정 누락), 아니면 RPC 딸꾹질이다.

settleYield(stablecoin, net, treasuryFee, poolMgmtFee)가 둘을 대체하고, 옛 진입점 둘은 제거된다. 그래서 기간을 정산하는 두 번째 길이 없다. 정산되거나 온체인에서 아무 일도 안 일어났거나 둘 중 하나다. 병합에 둘이 따라왔다. 순액 적립과 수수료 구간 둘이 이제 상태가 하나라도 움직이기 전에 한 번의 검사로 unclaimedYield에 대해 상한이 걸린다. 09a-custody가 명시적으로 람다 호출 순서 관례 로 기록해 둔 불변식이 온체인 상한으로 승격된 것이다. 그리고 풀이 작아졌다. 21,064 → 20,929 B(EIP-170 여유 3,512 → 3,647). 래퍼 둘이 하나가 됐기 때문이다. YieldDistributed / FeesWithdrawn은 시그니처를 유지하므로 인덱서는 안 건드린다.

시그니처가 일부러 스케일을 섞는다. netAmount는 정규화(18, 원장 전용)이고 수수료 구간 둘은 raw다(각각 전송을 감싼다). 그건 v3-101의 규칙을 적용한 것이지 예외가 아니다. 그리고 브랜드 백엔드 타입이 둘을 컴파일 타임에 교환 불가로 만든다. 이 경로에서는 스케일을 틀려도 여전히 리버트가 안 나기 때문에 그게 중요하다.

컨트랙트를 건드릴 만했던 이유: 풀은 immutable 구현체를 가진 팩토리 뒤의 불변 EIP-1167 clone이라, 이건 새 구현체와 새 팩토리와 모든 풀 재생성 비용이 든다. 분배가 한 번도 안 돌았기 때문에만 싸다. 대안은 삼켜지는 실패 경로를 프로덕션까지 영구히 이고 가는 것이었다.

같이 닫은 것: 뒷받침 불변식이 자기 독블록이 주장하는 것보다 적게 단언하고 있었다. 이전 판독은 이걸 _releaseHeldFunds의 라이브 누수라고 불렀는데 틀렸다. 릴리스는 heldFundReleases로 클램프되고 그건 불변식 안에 있다. 검증 빈틈 이었고 원인은 이름이었다. unclaimedYield()의 natspec은 "예치되고 분배됐지만 홀더가 아직 청구 안 한"이라고 적었고 퍼즈 테스트 독블록은 "분배됐지만 미청구인 수익"을 보호하려고 있다고 적었다. 그런데 그 필드는 분배 시점에 차감 된다. 그러니 모두가 커버된다고 믿은 항이 애초에 합계에 없었다. 분배된 수익은 집계 없이 사용자별 accruedYield 매핑에만 살아서, 수정은 하네스의 몫이다. 이제 Σ pendingYield(investor)가 불변식의 일부이고 공허하지 않음을 확인했다(실행 중 그 항이 약 49e18까지 간다). 주석 둘 다 고쳤고 새 온체인 스토리지는 없다.

그리고 하루 전에 미뤘던 단위 가드의 읽기 방향이 이제 강제된다. v3-101formatUnits를 뺐다. 12곳이 첫날부터 예외 12개를 요구했을 것이기 때문이다. 12곳 전부 변환했다. 금액과 NAV는 타입 디코더(fromRaw / fromNormalized / fromNavPrice)로, LP 잔액과 충당 비율은 이름 붙은 스케일과 함께 normalizeAmount로 갔다. 그래서 가드가 이제 예외 0개 로 양방향을 덮는다. 변환하다가 구조 수정이 하나 더 나왔다. 즉시 상환 지급 미러가 요청 핸들러 안에서 스케일 셋에 걸쳐 있었는데, 축을 섞는 게 어울리는 자리인 computeInstantShortfalllib/shared/contract/로 옮겼다.

빨간 테스트 하나 더. 무관하지만 들를 값어치가 있었다. PlatformKYCSoulbound.test_RenewExtendsExpiryInPlacedev와 prod 프로파일에서 실패하고 기본 프로파일에서 통과해서, make test-prod는 한 번도 초록이었던 적이 없다. solc는 호출 안에서 block.timestamp를 상수로 볼 수 있는데, 실제 트랜잭션에는 참이고 시간을 워프하는 테스트에는 아니다. via_ir의 강한 CSE 아래에서 두 번째 block.timestamp + ONE_YEAR가 첫 번째로 접혀서 이미 만료된 날짜로 갱신됐다. 목표를 expiresAt에서 유도해 고쳤다.

forge test dev와 prod 프로파일 모두 369/369(이 작업 전엔 365/1). 원자성 회귀 3개 신규와 강화된 불변식. ABI 재생성(263 → 262). infra tsc 통과 · 162/162 · 가드 넷 통과 · 프론트엔드 둘 다 줄어든 ABI에 대해 타입체크. ⚠️ 배포 안 됨. settleYield는 배포된 clone에 없으므로, 새 구현체 + 팩토리가 나가고 풀을 다시 만들기 전까지 수익 정산이 막혀 있다. 문서: 14-decisions v3-102 · 05-investment-lifecycle · 23-money-path · 09a-custody · 제거된 함수 이름 때문에 페이지 8개 추가 갱신. 출처: CH, 2026-07-31.

📅 2026년 7월 31일 — 1e12 버그는 컨트랙트에 없었다. 금액 축이 타입이 되고, NAV 상각에서 두 번째 라이브 사례가 나온다 (v3-101)

📐 여기서 스케일을 틀리면 절대 리버트가 안 난다. 그게 문제의 전부다

JY의 핸드오프는 백엔드를 고칠지, 아니면 수익 세 단계가 전부 raw를 받도록 컨트랙트를 바꿀지 물었다. 둘 다 아니다. 컨트랙트는 이미 일관돼 있다. 금액을 받는 진입점 여덟 개가 예외 없이 규칙 하나를 따른다. 이 tx에서 움직이는 토큰을 가리키는 인자는 Raw(그 스테이블코인의 소수 자릿수)이고, 풀 원장 의 수치를 가리키는 인자는 Normalized(18)다. depositYield / withdrawFees / fundRedemption / deposit은 전송을 감싸고, distributeYield / claimYield / reinvest / reserveConsumed / setHardCap과 설정 상한들은 원장 카운터를 만난다. 그러니 "가운데 수익 단계만 이상하다"는 건 전송 함수와 회계 함수를 헷갈린 것이고, raw로 통일했으면 모든 허용 스테이블코인을 합산하는 unclaimedYield에 통화를 강요하게 됐을 것이다. 이제 표를 공개했다. 08-smart-contracts → Amount Units.

근본 원인은 축이 줄곧 산문으로만 적혀 있었다는 것이다. 문서화는 돼 있었다. PlatformPool.sol@param netAmount ... (normalized to 18 decimals)가 있었고, 틀린 그 백엔드 패키지 안에 YIELD_NORMALIZED_DECIMALS = 18 상수까지 있었다. 그런데도 미끄러졌다. 시끄럽게 실패할 수도 없다. 이 인자들에 걸린 온체인 검사는 전부 상한 이라, 1e12 작은 값은 통과하고 tx는 성공하며, 오프체인 기록은 따로 계산되므로 그럴듯하게 남는다. 불일치는 투자자의 claimYield가 리버트할 때까지 기다린다. 그래서 축을 이제 브랜드 타입 으로 만들었다(lib/shared/contract/units.tsRawAmount / NormalizedAmount / NavPrice). 모든 온체인 금액 파라미터가 자기 축을 선언하고 틀리면 컴파일 오류다. scripts/check-onchain-units.mjs(infra build + test에 물림)가 변환 모듈 밖의 parseUnits를 금지한다.

타입을 적용해 보니 아무도 제보 안 한 두 번째 라이브 사례가 나왔다. updateNAV(reserveConsumed)nav-changes.post.proposenav/apply-proposal 양쪽에서 소수 6자리로 파싱되고 있었는데, 컨트랙트는 그걸 18자리 카운터인 s.reserveBalance에서 바로 차감한다. 그러니 모든 상각이 리저브를 사실상 하나도 안 태우고 성공을 보고했고, 상환 충당과 wind-down NAV에서 리저브가 부풀려진 채 남았다. 제보된 버그와 같은 서명이다. 너무 작고, 리버트 없고, 기록은 그럴듯하다.

제보된 쪽은 실제 피해가 없다. 실제 분배가 한 번도 안 돌았고(yield_claims = 0, yield_distribution_investors = 0, DISTRIBUTED 행 둘은 시드 데이터) 그래서 온체인 accumulatedYieldPerShare가 틀어진 게 없고 마이그레이션도 필요 없다. YieldDistributed.netAmount는 같은 변경에서 18로 다시 디코드해야 했다. 인덱서가 그걸 6으로 읽어 기록된 숫자에서 버그를 상쇄 하고 있었기 때문이다. 한쪽만 고치면 기록이 깨졌을 것이다. yield_per_share는 상쇄된 적이 없고 그냥 1e12만큼 낮았다.

범위를 함의하지 않고 적어 둔다. 가드는 인자 구성(parseUnits)을 덮고 읽기 방향(formatUnits, 12곳)은 안 덮는다. 거기서 스케일이 틀리면 눈에 보이는 표시 오류이고, 오늘 금지하면 첫날부터 탈출구 12개가 생긴다. 컨트랙트 쪽 항목 둘은 각자의 이유로 열려 있고 둘 다 단위와 무관하다. 2+3단계를 settleYield 하나로 합치는 원자성(삼켜진 withdrawFees 실패가 순액은 분배됐는데 수수료는 미수인 상태를 남기고, 재시도하면 두 번 분배된다), 그리고 뒷받침 불변식에 분배됐지만 미청구인 수익이 빠져 있어 _releaseHeldFundsaccruedYield를 받치는 잔액을 내보낼 수 있는 문제다.

infra tsc 통과 · 테스트 150/150(축·경계 테스트 9개 신규) · 문구 + 프로듀서 + 새 단위 가드 통과 · 심어 둔 위반에 대고 가드 확인. ⚠️ 배포 안 됨. 문서: 14-decisions v3-101 · 08-smart-contracts → Amount Units. yield-distribute-scale-mismatch-handoff.md의 A/B 선택을 대체한다. 출처: CH, 2026-07-31.

📅 2026년 7월 30일 — hold-back clamp에 목소리가 생긴다. 만들다가 돈 알림 넷이 1e12만큼 어긋난 걸 찾았고, 온체인 라이브 하나도 나왔다 (v3-100)

💰 금액을 말하는 게 유일한 일인 알림을, 틀린 금액을 말하던 알림 넷 옆에 넣었다

holdback_release_deferred(FM, 인앱 + 이메일)가 v3-100이 연 빈틈을 닫는다. 90% hold-back을 풀면 이제 정산됐지만 미청구인 채무를 받치지 않는 부분만 지급되는데, 파트너 쪽에서 보면 그건 그냥 기대보다 적게 준 릴리스와 구분이 안 된다. 인덱서가 FundingRestrictedSet(restricted=false)를 본다. 이벤트에 금액이 없어서 heldFundReleases그 이벤트 자신의 블록에 고정해 되읽는다. _releaseHeldFunds가 그 트랜잭션 안에서 돌았으므로 거기 값이 정확히 남은 금액이고, 실시간으로 읽으면 그 뒤 청구들이 손댄 값이 나온다. 잔액이 0이거나 읽기 실패면 아무것도 안 보낸다. 이 메시지는 수치를 말하려고만 존재하므로 틀린 숫자는 없는 것보다 나쁘다. dedupByEntity가 아니라 매일 재무장한다. 전자는 풀당 평생 한 번이다.

그다음은 단위다. 풀은 모든 USD 수치를 18자리로 정규화하는데(PoolCommonLib.normalizeAmount) 인덱서가 그중 넷을 USDC의 6으로 디코드해 1e12 만큼 부풀렸다. 스케일이 틀린 금액도 그럴듯한 숫자로 렌더되고, 그게 정확히 리뷰를 통과하는 방식이다. 고친 것: epoch_funding_needed("Awaiting Funding" KPI 뒤의 funding_shortfall 컬럼 포함), epoch_settlement_complete의 지급액(redemption_requests.payout_amount와 모든 redemption_fills 행도 썼다. 투자자에게 보인다), redemption_pending_reserve의 부족액, 그리고 NAV_PRECISION 대신 LP_PRECISION으로 나누던 readEpochShortfall이다. 마지막 건 두 번 중요했다. 1e6 수요를 18자리 잔액과 비교해서 실제 풀에서는 부족액이 약 0으로 계산됐고, D-2 / D-12h 파트너 펀딩 알림이 한 번도 발동하지 않았다. 앞선 G4 패널티 수정과 같은 계열이다. 이제 NORMALIZED_DECIMALS로 이름을 붙여서, raw 전송 금액(Deposited.amount, YieldDeposited.grossAmount)과의 구분이 기억이 아니라 명시가 된다.

🔴 그리고 일부러 그대로 둔 것 하나. runYieldDistributionnetAmountparseUnits(…, 6)으로 변환해 distributeYield에 넘기는데, 그건 18자리 스케일을 기대한다. 이웃 둘인 depositYieldwithdrawFees는 진짜로 raw 스테이블코인 단위를 받는다. 가운데 것을 틀리기 쉬운 이유가 그것이다. 확인되면 수익이 1e12배 적게 분배되고 배포된 풀은 이미 틀어진 accumulatedYieldPerShare를 갖고 있으므로, 한 줄 수정이 아니라 CH와 라이브 상태 확인이 필요하다. 인덱서의 대응 디코드는 일부러 뒤집는다. 지금은 두 오류가 상쇄되고, 하나만 옮기면 기록된 수치가 망가진다. CH와 함께.

검증: infra tsc -b · 155/155 · 문구 가드 632파일 · 알림 프로듀서 가드 키 56개 · 문서 빌드 통과. 문서: 22-notifications · 14-decisions v3-100. 출처: JY, 2026-07-30.

📅 2026년 7월 30일 — epoch 마무리. 우리가 명세한 pot은 설계에서 빠졌기 때문에 만들어진 적이 없다. v3-93의 열린 항목 여섯을 닫고, 라이브 누수 하나를 찾았다 (v3-100)

🔁 우리 항목 둘이 틀렸고, 그중 하나가 진짜 버그를 가리고 있었다

ch/product 병합으로 v3-91 / v3-93을 코드에 대고 확인할 수 있게 됐는데, 가장 먼저 떨어진 게 문서 실패였다. v3-91은 epoch별 epochClaimable[id] pot을 명세했다. 그 설계는 2026-07-27에 철회됐고(청구가 여러 epoch에 걸치므로 epoch별 pot은 O(V)이고 O(1) 청구를 죽인다) 단일 redemptionCommitted 스칼라 로 대체됐는데, 철회가 이 페이지에 안 닿았다. 그래서 어제의 상태 표가 CH에게 이중 커밋 수정이 착지했는지 물으면서 의도적으로 설계에서 빠진 필드를 찾고 있었다. 스칼라는 나갔고, 불변식은 온체인 assert가 아니라 Foundry 테스트로 강제하며, _debitLiquidity / _debitPrincipal 분리와 스칼라 때문에 필요해진 setFundingRestricted clamp도 함께 있다. wind-down 분자(🔴W) 도 같은 계열의 정정이다. 청산이 reserveBalance만으로 가격을 매기고 회수된 돈을 담은 버킷 둘을 무시했다.

v3-93의 열린 항목 여섯을 닫았다. 전부 2026-07-27에 결정됐는데 이제야 적힌다. 과다 펀딩 잔액은 FM 알림과 함께 앞으로 이월된다(🔴 "이월된다"는 틀렸다. 2026-08-17에 v3-137이 정정했다. 잔액은 자기 회차에 묶인다). admin 거절에는 컷오프 게이트가 필요 없다(epoch 요청은 아예 거절할 수 없다). 별도 규제 강제 취소 경로는 없다(holdRequest와 청구 시점 KYC 게이트가 덮는다). demand == 0은 🔴F에 접힌다. instant PENDING_RESERVE 취소는 무해하다. 그리고 WIND_DOWN 시점의 미결 epoch 수요는 위기 예외가 윈도 게이트를 건너뛰면 일관된다.

🔴 그리고 나머지를 검증하다 병합된 코드에서 라이브 누수 하나를 찾았다. cancelRedemptionep.lpRemaining을 돌려주는데 그건 청구 시점에만 줄어든다. 그래서 청구 안 한 부분 체결 투자자가 에스크로된 LP를 전부 돌려받고, 체결된 몫의 USD는 청구자 없이 redemptionCommitted에 남아 wind-down 분자에서 영구히 빠지며 남은 홀더가 그걸 문다. v3-93은 C10 윈도 게이트가 이걸 막는다고 가정했는데 안 막는다. 이월 예외가 정확히 거기로 가는 길이고, 그 동작은 test_C8_CancelRolledOver_ClearsCarryBucket으로 고쳐진 게 아니라 고정 돼 있다. CH와 함께.

🔴F가 그중 최악이었고 문서 항목이 아예 없었다. 그동안 07-redemption은 깨진 공식을 설계로 게시하고 있었다. 완전 충당은 생존 비율 래더를 0으로 만드는데, 완전 충당은 운영 목표 다. 그러니 유동성 게이팅이 첫 건강한 회차에서 죽었고 기존 epoch 테스트 27개는 전부 초록이었다. 세대별로 래더를 다시 시작해 고쳤다. 배포 전에 알아야 할 예외가 둘이다. fundingAnchor == 0이미 배포된 epoch 풀 7개 를 레거시 경로에 영구히 묶고(그래서 Model B는 새 풀의 속성 이다), ep.gBase == 0 폴백이 진행 중인 라이브 요청 2건 을 덮는데 그건 옛 완전 충당에 대고 먼저 확인해야 한다.

같이 고친 것: v3-91이 스케줄 근사를 거꾸로 적었다. 28/84일 주 배수를 버렸다 고 했는데, 그걸 채택한 것이야말로 컨트랙트가 달력 계산을 안 하는 이유이고 v3-93이 이미 그렇게 적었다. 그리고 wind-down 문구 게이트가 이제 풀렸다. 그 결정은 "컨트랙트보다 앞서 문구를 바꾸지 말 것"이라고 했는데 컨트랙트가 움직였다. totalEpochTopUp이 파트너의 fundRedemption으로 채워지므로 회수된 자본이 실제로 분자에 닿고, "경로가 없다"는 전제가 거짓이다. "회수된 자본"이라는 표현을 쓸지는 이제 제품 판단이고 10-status-machinesv3-95에 표시했다.

그 밖에: funding date 알림 리드 3 → 7일(LEAD_DAYS, BE 대기). hold-back 릴리스 잔액에 FM 알림이 생긴다(clamp가 FM 기대보다 조용히 적게 줄 수 있어서 목소리가 필요하다. 문구 + 생산자는 알림 트랙). 72시간 출금 차단은 v3-28에 따라 그대로다. 문서: 14-decisions v3-100 · 07-redemption · 04-pool-models · wind-down 공식을 페이지 6개에서 추가 정정. Notion "Epoch Redemption — 코드 검수 정리" 핸드오프 회신. 출처: JY, 2026-07-30.

📅 2026년 7월 30일 — AML 이탈 게이트가 온체인으로 간다. 모든 즉시 정산 경로가 검사되고, 시간별 스윕이 리버트를 보이게 만든다 (v3-99 컨트랙트 단계 배포)

🔒 이제 게이트는 호출자마다가 아니라 지급 진입점이다

요청이 PENDING_RESERVE에서 기다리는 동안 회수된 홀더가 여전히 지급받을 수 있었다. _executeRedemptionPayout에 닿는 호출자 셋 중 claimRedemptionFallbackcanRedeem을 다시 확인했고, approveRedemptionfundRedemption 안의 파트너 펀딩 자동 정산은 안 했다. 그래서 같은 홀더 상태가 어느 경로로 정산되느냐에 따라 지급이 되기도 거절이 되기도 했다. 이제 검사는 단일 지급 진입점 에 있고 RedemptionBlockedByKyc로 리버트한다. 호출자마다가 아니라 거기서 막는 게 요점이다. 빈틈이 생긴 이유가 정확히 검사 없이 호출자가 추가된 것이기 때문이다.

비대칭은 유지된다(v3-31). REVOKED와 NONE은 거절, 만료된 SBT는 이탈 가능. 그걸 조이면 만료된 서류가 자금 함정이 되고, v3-28이 금지하는 게 그것이다. 거절된 요청도 갇히지 않는다. admin이 Return position으로 닫으면 포지션이 되돌아간다.

리버트는 Aset에게 조용하므로, 기다리는 요청을 시간마다 훑어 파트너가 성공할 수 없는 호출에 가스를 쓰기 전에 admin에게 알리는 스윕이 함께 나간다. 스윕은 상태를 안 바꾸고(AML 차단은 cron이 아니라 Aset이 명시적으로 기록하는 행동으로 남는다), 같은 헬퍼를 쓰는 쓰기 경로 호출자들과 달리 체인을 못 읽을 때 일부러 fail-closed가 아니다. 여기서는 지급도 없고 응답을 기다리는 사람도 없으며, RPC 딸꾹질마다 알리면 admin이 알림을 건너뛰도록 훈련된다. 인덱서가 RedemptionRejected도 미러링하므로, 멀티시그에서 온체인에 바로 올린 거절이 영영 오지 않을 정산을 기다리는 행을 남기지 않는다.

마감 사유 사전 선택은 같은 검사에 배선했지만 스윕이 아니라 실시간 으로 읽는다. 새 읽기 전용 GET /redemption-requests/{id}/exit-gate가 다이얼로그가 열리는 순간 묻는다. 그래서 힌트가 한 시간 낡을 수 없고 스윕이 아직 안 닿은 요청에서도 동작한다. 이건 열린 쪽으로 실패한다. 모르면 상태 기반 기본값을 그대로 둔다. 같은 헬퍼를 쓰는 쓰기 경로와 정반대인데, 여기서 잘못 짚으면 펀딩 마감을 컴플라이언스 마감으로 라벨링하고 그 라벨이 투자자 알림을 고르기 때문이다. 마감 사유도 @aset/types로 옮겼다(두 벌 있어서 API가 검증하는 목록과 패널이 제시하는 목록이 달랐다). 그리고 /counts가 이제 마감 내역을 돌려주며 CLOSED는 그 합으로 유도되므로, 투자자가 자기 요청을 철회한 것이 admin 마감에 합산되지 않는다.

forge test 366/366(수정 전 실패를 확인한 회귀 3개 신규) · PlatformPool 21,232 B 그대로(EIP-170 여유 +3,344 B) · ABI 재생성 · infra tsc 통과 · 141/141 · 문구 가드 + 프로듀서 가드 통과 · cdk synth 통과. ⚠️ 배포 안 됨. 이 커밋에 대해 풀 구현체도 infra 스택도 릴리스 안 됐다. 문서: 14-decisions v3-99 · 07-redemption → 이탈 게이트 · 09-rbac. 출처: CH, 2026-07-30.

📅 2026년 7월 30일 — 상환 마감 알림이 사유별로 갈린다. 컴플라이언스 문구는 일부러 보류한다 (v3-99 1단계 배포)

✉️ 알림 하나가 마감 사유 둘을 다 나를 수 없었다

Return position펀딩이 끝내 안 왔거나 홀더가 더는 검증을 통과하지 못해서 요청을 닫는데, redemption_rejected 하나로는 첫 번째가 틀렸다. 펀딩 실패는 투자자 잘못이 아니라서 "거절됨"이 비난처럼 읽혔다. 그리고 두 문구 다 두 경우 모두에서 가장 중요한 사실, 즉 LP가 돌아왔다는 걸 언급하지 않았다. 이제 운영자가 고른 분류가 알림을 고른다. UNFUNDED → 새 redemption_returned_unfunded("포지션 반환, 차감 없음, 언제든 다시 요청 가능"), COMPLIANCE / OTHERredemption_rejected이고 "보류 중, 지원팀에 문의"로 좁혔다.

운영자 메모는 투자자에게 절대 렌더되지 않는다. 필수 자유 텍스트 사유는 나중 검토자를 위해 쓰는 것이고 admin 패널을 떠나면 안 되는 내용을 담을 수 있어서, 두 알림 다 인용하지 않는다. 컴플라이언스 알림은 이유 도 밝히지 않는다. 대상자에게 AML이나 검증 트리거를 지목하면 수사 정보 누설 위험이 있다. 그 문구는 법무 승인 대기 다(Notion "Legal Review Required" #25. 소비자 보호상 고지 의무가 누설 금지보다 우선하는지 등 질문 다섯 개). 다만 열려 있는 건 문자열뿐이다. 이벤트 분기, 변수, failure_type 화이트리스트는 확정이라 법무 답변은 코드가 아니라 문구를 바꾼다.

배포: e2e3eca(권한 / CTA / 사유) + a18a2d7(문구 분리). tsc -b 통과 · 127/127 · admin 빌드 + 문구 가드 통과. ⚠️ 배포 안 됨. infra에 자동 배포가 없고 이 커밋들에 cdk deploy가 안 돌았으므로 분기된 알림은 아직 프로덕션에서 안 나간다. 문서: 22-notifications. 출처: JY, 2026-07-30.

📅 2026년 7월 30일 — Return position은 ADMIN 전용이고 사유가 필수다. AML 게이트는 자동 마감이 아니라 리버트한다 (v3-99 종결)

🚪 상환 이탈 게이트의 열린 결정 셋을 닫는다

버튼은 둘이 아니라 하나다. 두 번째 CTA Block for compliance는 뺐다. 주된 명분이 권한 분리였는데 액션이 ADMIN 전용이 되면서 사라졌다. 두 경우가 같은 결과(요청 마감, LP 반환)를 내고 다른 건 뿐이다. 그 차이는 이제 시스템이 사전 선택 하는 필수 사유 가 나른다(스윕이 검증을 표시했으면 compliance, 정체된 부족액이면 unfunded). 운영자가 바꿀 수 있고 자유 텍스트 메모도 필수다. NAV 승인/오버라이드와 같은 제안 후 확인 형태라 새로 배울 게 없다. 사전 선택은 일부러 잠금이 아니다. 검증 플래그는 오탐일 수 있고, 운영자가 다른 사유로 닫는 중일 수도 있다.

ADMIN / SUPER_ADMIN만, FM은 제외. 정리 차원이 아니다. 이 액션의 주 용도는 펀드가 끝내 안 채운 부족액을 닫는 것이고 FM이 곧 그 펀드다. FM에게 남기면 돈을 갚아야 할 쪽이 투자자의 이탈 요청을 끝낼 수 있다. LP는 돌아오지만 이탈은 닫힌 채이고, 반복되면 사실상 거절이다. 이걸로 3자 충돌이 Notion FM 패널 PRD 쪽으로 정리된다. PRD는 FM에게 그런 게이트가 없다고 했는데 코드와 09-rbac이 허용하고 있었다. 그러니 움직이는 건 문서와 핸들러이지 PRD가 아니다. FM은 승인자가 아니라는 원칙으로 FM_ACCEPTED를 뺀 v3-04와 일관된다. FM은 부족액 알림, 읽기 권한, 자기 지갑으로 fundRedemption 서명을 유지한다.

AML 게이트는 리버트하지 요청을 자동 마감하지 않는다. 들어온 리뷰의 전제 하나를 고쳐야 했다. 리버트해도 파트너 돈이 묶이지 않는다. 전송과 지급이 같은 트랜잭션에서 일어나므로 리버트가 전송까지 되돌린다. (분할 펀딩은 앞선 top-up을 안에 남길 수 있는데, 그건 이 변경 이전 일이고 Return position이 이미 그걸 fund_wallet으로 돌려준다. 새로 설계할 경로는 없다.) 자동 마감은 다른 이유로 기각했다. 그러면 파트너의 펀딩 트랜잭션이 Aset의 컴플라이언스 결정을 집행하고 행위자로 파트너가 기록된다. 검사는 호출자별이 아니라 _executeRedemptionPayout 에 들어간다. 검사 하나가 가드 없는 경로 둘을 덮고, 호출자별 패치가 바로 이 빈틈이 생긴 방식이다.

리버트는 우리에게 조용하므로 스윕과 함께 나간다. 실패한 트랜잭션을 지켜보는 게 아니다. 기다리는 요청을 주기적으로 훑어 홀더가 더는 통과 못 하면 admin에게 알린다. 파트너의 리버트된 tx를 지켜보면 놓치는 경우가 생기고 이미 가스를 쓴 뒤에야 발동한다. 스윕은 다이얼로그가 열리기 전에 사유를 알게 해 주기도 하는데, 그게 사전 선택 사유가 작동하는 이유다. 컨트랙트 리버트는 최후 방어선으로 남는다. 둘은 다른 일을 한다.

같은 날 만든 것: e2e3eca의 권한 / CTA / 사유, a18a2d7의 문구 분리, 그리고 이 페이지 맨 위 항목의 컨트랙트 게이트 + 스윕. 배포 안 됨. 문서: 14-decisions v3-99 · 07-redemption · 09-rbac. Notion "상환 출금 게이트 & Reject 재정비" 핸드오프 §4. 출처: JY, 2026-07-30.

📅 2026년 7월 30일 — ch/product 병합 뒤 epoch 엔진 상태를 항목별로 정정

🔁 재설계가 조각으로 착지해서, 상태 배지 하나로는 어느 쪽이든 틀렸다

ch/product를 병합하면서 v3-91 / v3-93 엔진 재설계의 대부분이 나갔고, 그 바람에 여러 페이지가 한꺼번에 두 방향으로 틀렸다. 아직 "결정됐지만 미구현"이라고 적혀 있는데, 하루 전에 쓴 안내는 스케줄 컬럼을 읽는 엔진이 없다고 적고 있었다. 전부 "배포됨"으로 뒤집는 건 거울상의 오류였을 것이다. 재설계가 한 덩어리로 착지하지 않았기 때문이다.

병합된 RedemptionLib / PlatformPoolStorage에 대고 확인해 07-redemption에 항목별 표로 기록했다. 배포됨 — 요청 윈도 하드 게이트(RequestWindowClosed), 달력 앵커(epochFundingDate + settleAfter와 새 pools.scheduler.epoch-funding-date), 접수/정산 커서 분리(acceptingEpochId), 이월 우선 2단 충당(epochCarryDemandLp / epochNewDemandLp / epochAggFillRatio), recallLeadDays 윈도, 요청 시점 수익 정지(에스크로된 LP를 분배 분모에서 빼고, YIELD_BASED 패널티 기준을 잠금 시점에 쌓인 수익으로 옮김). 없음 — epoch별 정산 에스크로(epochClaimable). 그런 필드가 없으므로, 이중 커밋 수정이 다른 방식으로 달성됐는지는 어느 쪽으로도 가정하지 말고 CH에게 물을 열린 질문이다.

어제 내가 쓴 epoch 스케줄 컬럼 6개 안내가 이제 낡은 쪽이고 다시 썼다. 저장소 마이그레이션이 없고 컬럼을 읽는 코드가 없다는 두 주장 모두 이 병합으로 닫혔다(0104_epoch_schedule_cols.sql이 백필하고 schema.sql이 담고, 생성·수정·스케줄러가 전부 읽는다). 사례를 넘어 살아남는 부분은 이것이다. supabase_migrations는 SQL 에디터로 적용한 것을 기록하지 않으므로, 그 페이지 머리말의 (pending apply) 라벨은 확인된 상태가 아니라 의도다.

같이 물린 것: LIQUIDITY_WINDOWS가 아직 v3-91 아래 "API 블록(3곳)" 후속으로 나열돼 있었다. 마이그레이션 0106이 enum 값을 아예 제거했고 pools.post.create도 더는 참조하지 않으므로, 후속은 대기가 아니라 사라졌다.

문서: 07-redemption · 04-pool-models · 11-db-schema · 14-decisions v3-91. 출처: JY, 2026-07-30.

📅 2026년 7월 30일 — 상환 이탈 게이트를 문서화하고, 운영자 액션을 Return position으로 이름 바꾼다 (v3-99)

🚪 무엇이 출금을 막을 수 있는지, 그리고 그중 어느 것이 사람인지

무엇이 상환을 막는지 아무도 안 적어 놔서 규칙을 컨트랙트를 읽어야만 복원할 수 있었다. 그 빈틈에서 잘못된 믿음 둘이 나왔고, 둘 다 하중을 받는 것이라 이름을 댈 값어치가 있다. epoch 풀에는 AML 게이트가 없다(있다. 같은 canRedeem 검사가 요청과 지급 시점에 돈다), 그리고 운영자의 거절 버튼이 AML을 집행하는 방법이다(아니다. 그건 자동이다).

게이트 셋을 이제 07-redemption에 적었다. 락업/만기, 검증, 운영자 액션이다. 실수로 "고쳐질" 가능성이 가장 큰 건 검증 비대칭이다. REVOKED는 이탈을 막고 EXPIRED는 안 막는다(v3-31). 만료로 막으면 투자자 자기 자본이 만료된 서류 뒤에 갇히는데, v3-28이 막으려는 실패 방식이 정확히 그것이다. 그래서 문서가 동작만이 아니라 의도를 적는다.

CTA는 Return position이 된다. "Reject"는 자격을 부인하는 걸 설명하는데, 이 액션은 에스크로된 LP를 돌려주고 홀더에게 포지션을 남긴다. 부인이 아니라 되돌림이다. 이미 예치된 파트너 펀딩은 fund_wallet로 돌아가고, 요청 시점에 수익을 차감하지 않고 패널티는 정산에서만 물리므로 그 밖에 되감을 게 없다(v3-84). ReleaseFreeze는 이미 홀드/해제와 풀 프리즈를 뜻해서 쓸 수 없었다. 컴플라이언스 경우는 같은 버튼이 아니라 admin 전용 두 번째 CTA여야 한다. 지금은 통제 하나가 무과실 정리와 AML 판단을 뭉개고 있고 FM이 그걸 누를 수 있다.

🔴 열어 두고 그렇게 적은 것: 자동 정산 경로는 재검사되지 않는다. _executeRedemptionPayout에는 호출자가 셋인데 claimRedemptionFallback만 먼저 canRedeem을 본다. approveRedemptionfundRedemption 안의 파트너 펀딩 자동 정산은 바로 부르므로, 요청 시점엔 통과했다가 SBT가 회수된 채 PENDING_RESERVE에서 기다린 요청은 펀딩이 착지하는 순간 지급된다. 수정은 _executeRedemptionPayout 안의 몫이다. 검사 하나가 가드 없는 호출자 둘을 덮는데, 호출자마다 패치하면 다음 것이 반드시 놓친다. 그게 나가기 전까지는 수동 Return position이 자동 통제를 대신하고 있고, AML 게이트가 머물 만한 상태가 아니라서 빈틈을 트래커가 아니라 페이지 자체에 적어 둔다.

같이 고친 것: 09-rbac이 아직 FM에게 "상환 승인/거절"을 주고 역할 표에 승인을 나열하고 있었다. v3-82모든 역할에서 수동 승인을 뺐으므로 패널에 남은 상환 결정은 거절뿐이다. ⚠️ Notion FM 패널 PRD(2026-06-18)는 FM에게 거절 게이트가 아예 없다고 하는데 코드와도 이 페이지와도 안 맞는다. 그건 제품 판단이라 조용히 고르지 않고 미해결로 뒀다.

문서: 14-decisions v3-99 · 07-redemption · 09-rbac. Notion "상환 출금 게이트 & Reject 재정비" 핸드오프. 출처: JY, 2026-07-30.

📅 2026년 7월 29일 — KYB 폐기. 플랫폼은 개인만 온보딩한다 (v3-98)

🚫 법인 온보딩은 범위 밖이다

결정(v3-98): 플랫폼은 개인만 온보딩한다. KYB는 미룬 게 아니라 폐기 다. 목표 시점이 없고, v3-75의 정책이 기대던 2026년 8월 SumSub Enterprise KYB 구독도 안 든다. v3-75는 대체됨으로 표시했다. 거기 담긴 관할권 조사는 로드맵이 아니라 참고 자료로 03-kyc-identity에 남는다.

이 구분이 무게를 갖는 이유. "미룸"과 "폐기"는 상태 칸에서 똑같아 보이는데 아래로 내려가면 정반대다. 미룸이면 모든 INSTITUTION 갈래가 데워 둘 값어치가 있는 뼈대이고 "곧 지원"이 정직해진다. 폐기면 같은 갈래가 죽은 경로이고 같은 문장이 지킬 수 없는 약속이다. 문서는 첫 번째 독법으로 정착해 있었다. 03-kyc-identity가 "아직 사용 불가 … 2026년 8월 예정 … KYB는 별도 작업 흐름이고 그게 먼저 나간다"라고 적었으니, 독자는 법인 온보딩이 오는 중이라고 결론 내리는 게 합리적이다. 그 페이지는 이제 폐기로 시작하고 남긴 조사를 어떻게 읽어야 하는지 설명한다.

일부러 남긴 것: kyc_level enum(INDIVIDUAL/INSTITUTION)과 SUMSUB_LEVEL_MAP. SBT가 레벨을 저장하고 v3-63이 이미 레벨 기반 풀 게이팅을 뺐으며, 값을 제거하려면 SBT 컨트랙트까지 손대야 한다. 그러니 휴면 갈래는 무해하다. 이 항목은 그것이 있다는 게 기능이 존재한다는 증거가 아니라고 명시한다. 바로 그 추론이 여기서 막으려는 실수이기 때문이다.

🔴 열어 둔 것 — 투자자 대상 문구. KYCModalVITE_KYB_ENABLED 게이트 뒤에서 아직 "법인 인증은 곧 지원됩니다"를 렌더한다. KYB가 폐기된 마당에 그건 제품이 지키지 않을 약속이고, CLAUDE.md 규칙 2-b가 막으려는 실패 방식이다. "미지원"으로 바꾸거나 개인/법인 선택기를 통째로 빼면 된다. 여기서 일부러 결정하지 않았고 일부러 조용히 고치지도 않았다. 문서 정확성 수정이 아니라 사용자에게 보이는 제품 문구다.

영향 없음: Reg S 적격투자자 모델(v3-74)은 처음부터 개인 우선이었고 독립적으로 나간다.

문서: 14-decisions v3-98 · v3-75(대체됨) · 03-kyc-identity. Notion "KYB(법인 온보딩) — 현황 및 활성화 계획"(단계 = 드롭). 출처: JY, 2026-07-29.

📅 2026년 7월 29일 — 문서 대 코드 대조. v3-69 수수료 분할은 배포됐고, v3-86 아카이브는 S3가 아니라 콜드 테이블이며, 라이브 컬럼 6개가 문서에 없었다

문서 정정만이고 코드 변경 없음. 저장소에 대고 페이지별로 감사했더니 문서가 코드와 정반대를 주장하는 곳 셋과 더 작은 드리프트 다섯이 나왔다. 각각은 구현보다 오래 살아남은 상태 주장 이었고, 그중 둘은 같은 사이트의 다른 페이지와 모순됐다.

  • v3-69 수수료 분할 — "미구현"이라 적혀 있었는데 배포됐다. 배지가 🔧 Decided · on-chain pending이었고 본문은 2-way 분할이 "아직 미구현(CH 핸드오프)"이라고 했다. 04-pool-models도 두 곳에서 그랬다. 끝에서 끝까지 라이브다. PlatformPool.withdrawFees(stablecoin, treasuryAmount, poolMgmtAmount)YieldLib.withdrawFeesunclaimedYield를 한 번 차감하고 0이 아닌 구간을 같은 tx에서 treasuryWallet / fundFeeWallet로 각각 보낸다(address(0)로 태우는 대신 FundFeeWalletNotSet으로 리버트한다). 마이그레이션 0067pools.fund_fee_walletyield_distributions.pool_mgmt_fee_amount를 넣었고, run-distribution.ts가 금액을 오프체인에서 계산하며 yield-distributions.post.distributepoolMgmtAmount를 넘긴다. 결정 제목 도 틀렸다. Pool mgmt가 입금 money-path 지갑인 fund_wallet로 간다고 적혀 있었는데, v3-69의 요점은 별도 fund_fee_wallet으로 간다는 것이다. 04의 "Fee Destination" 절은 게다가 아직 "멀티시그 + 타임락"으로만 닿는 단일 목적지를 설명하고 있어서 같은 페이지의 가변성 표와 모순됐다. treasury_walletfund_fee_wallet 둘 다 타임락 없는 즉시 DEFAULT_ADMIN_ROLE 세터다(지연은 잘못된 주소로 나가는 지급을 못 막고 수정만 가둔다).
  • v3-86 아카이브 — S3는 계획이었고 실제로 나간 건 콜드 테이블이다. 결과란은 "아카이브 스케줄러를 purge → S3 export 후 hot delete로 바꾼다"고 적었는데, 13-operations는 마이그레이션 0082를 정확히 설명하고 있었다. activity_events는 트리거로 append-only이고, archive_expired_activity_events()가 오래된 행을 새 activity_events_archive 콜드 테이블로 옮긴다(원자적 INSERT + DELETE). S3는 나중의 개선이다. 같은 결과란이 BE 라이터와 FE 탭을 대기로 나열했는데 둘 다 만들어져 있다(빈틈 4개 분류 전부의 라이터와 impairment / freeze / qualification에 핸들러가 강제하는 reason, FM 펀드 범위와 결과/심각도 필터와 before→after 상세를 갖춘 Audit/Activity 탭). 진짜로 열려 있는 것 을 이제 적었다. 감사 대상이 엔터티 딥링크 없이 평문으로 렌더되고, outcome: 'failure'를 쓰는 건 pools.worker.deploy뿐이라 실패 경로 커버리지가 얇다.
  • 06-writedown-nav 1단계 — 명목 리저브가 바로 위 안내와 모순됐다. 예제 계산이 리저브에서 $1,000,000 × 0.10을 끌어왔는데, 같은 절 맨 위 A4 경고 안내는 리저브가 deposits × reserve_bps아니라 살아 있는 소진 잔액이라고 말한다(v3-16). 이제 예제가 reserve_bps는 적립 목표 일 뿐이고, 쓰는 수치는 PlatformPool.reserveBalance()(pools.reserve_balance로 미러링)이며, 둘이 여기서 일치하는 건 풀이 출시 시점이기 때문이라고 적는다. 3단계도 같은 처리를 받았다. 곱셈 연쇄와 A4 절대값 재계산은 NAV $1.00에서 일치하고 그 뒤로 갈라지는데, 절대값 쪽이 실제 동작이다.

같은 회차에서 고친 더 작은 드리프트 다섯:

  • 11-db-schema에 프로덕션에 살아 있는 컬럼 6개가 빠져 있었다. epoch_schedule_type · funding_anchor_date · recall_lead_days · request_window_days · epoch_cycle_mode · next_funding_datev3-93을 위해 2026-07-27에 Supabase main에 바로 적용됐는데, migrations/에도 schema.sql에도 이 페이지에도 없다. 배포된 스키마를 미러링하는 게 이 페이지의 목적인데도 그렇다. 여섯 개 다 이제 구현하는 v3-93 의미와 함께 문서화됐고, 결과 셋을 명시하는 안내가 붙는다. 저장소가 라이브 스키마를 재현할 수 없다, 컬럼 존재 ≠ 기능 존재(엔진은 여전히 컨트랙트 앵커 재설계에 막혀 있다), 그리고 migration_history는 믿을 만한 기록이 아니다(SQL 에디터 적용은 스키마를 조용히 바꾼다. 이 페이지 머리말의 (pending apply) 라벨이 확인된 상태가 아니라 의도인 이유다). 저장소 쪽 마이그레이션 담당: CH.
  • 09-rbac가 페이지 키 7개를 나열했는데 실제로는 9개다. 존재하지 않는 키를 이름 대기도 했고(funds. 진짜는 fund_managers) activitynotifications를 빠뜨려서 FM의 펀드 범위 Activity 접근과 Operator notifications 게이트(S-16)가 둘 다 안 보였다. 이제 ALL_PAGE_KEYS에서 역할별 기본값과 함께 열거한다.
  • 15-api-reference가 핸들러를 17개 적게 셌다. "핸들러 116개 / 엔드포인트 132개"라고 적었는데 실제로는 엔드포인트 핸들러 133개 + 스케줄러 13개 + 워커 3개가 라우트 136개를 담당한다.
  • 21-holder-verification에 이미 정리된 결정 둘이 열려 있었다. 5-상태 모델은 나갔고(resolveHolderState) portfolio_positions.source 값 목록은 살아 있는 CHECK다(0014). 두 번째를 체크하면서 명시적 경고를 단다. 값 목록을 정하는 것이 생산자를 만드는 것으로 오해되기 딱 좋기 때문이다. 실제로 쓰이는 건 TRANSFER_IN뿐이다.
  • 04-pool-modelsoperating_currency에 "API 처리 대기"라고 적었다. 생성/수정에서 받고 트랑슈 그룹 소속을 게이트한다. fx_rate_source는 enum 검증된다. 진짜로 빠진 가드(비USD 운영 통화에 fx_rate_source가 없으면 거절)를 모호한 "대기" 뒤에 숨기지 않고 그대로 이름 댔다.

이 종류의 오류가 반복되는 이유: 상태 주장은 결정할 때 한 번 쓰이고, 구현이 다른 커밋으로 착지해도 그걸 다시 열게 만드는 게 없다. 양방향으로 일어난다. 이미 나간 작업을 "대기"라고 적는 문서(v3-69), 그리고 나간 작업을 아예 빠뜨리는 문서(컬럼 6개)다. 싼 자가 점검은 이렇다. pending / not yet implemented 주장을 보면 믿기 전에 지목된 심볼을 grep한다. 그리고 "배포된 스키마를 반영한다"는 주장을 보면, 배포된 스키마에 쓰기 경로가 둘인데 흔적을 남기는 건 하나뿐이라는 걸 기억한다.

여기서 안 건드린 것(다른 곳 소유): 알림 동작 문서(22-notifications, 13-operations 이벤트 표)는 Notion 정리본 + 코드 핸드오프에 따라 진행 중인 알림 PR과 함께 나간다. 레거시 이벤트 여덟이 copy.ts를 우회해 인앱 카드를 깨뜨리던 생산자 쪽 빈틈도 그 브랜치에서 고쳐진다(P0-A).

문서: 11-db-schema · 09-rbac · 15-api-reference · 21-holder-verification · 14-decisions v3-69 · v3-70 · v3-86 · 04-pool-models · 06-writedown-nav. 출처: JY, 2026-07-29.

📅 2026년 7월 28일 — freeze 마일스톤에 알림이 붙는다. 날짜를 미리 알리고 재개 알림 둘을 넣는다. 알림 목록도 재확인 (v3-97)

🧊 아무것도 알리지 않던 두 순간

freeze는 마일스톤이 트랜잭션도 이벤트도 없이 지나가는 유일한 상태다. v3-28 비대칭 아래에서 value-out은 freeze_started_at 후 72시간에 다시 열리고 freeze 자체는 7일에 만료되는데, 둘 다 읽을 때 계산된다. 그러니 알림의 출처가 될 수 있는 건 플랫폼뿐인데 하나도 안 보냈다. pool_unfrozenEmergencyUnfrozen에서만 큐에 들어가고 자동 만료는 그 이벤트를 안 낸다. 한편 pool_frozen은 뒤에 생산자도 없이 "활동이 재개되는 대로 알려 드리겠습니다"를 약속했고, 출금이 72시간에 다시 열린다는 말 없이 입금 과 출금 이 멈춘다고 적었다. 결정(v3-97): 날짜를 미리 알리고 동시에 재개 알림 둘도 보낸다. 택일이 아니다. (1) pool_frozen / freeze_extended가 약속 대신 withdrawalsResumeAt + haltLiftsBy를 나른다. v3-95 이후 FE 배너가 하던 것과 맞춘다. (2) +72시간에 새 freeze_exit_window_open. 예상 시각을 말했다고 재개를 확인해 줄 의무가 없어지지 않고, 72시간은 투자자가 자기 돈에 대해 행동할 수 있게 되는 순간이다. (3) +7일에 새 freeze_expired. pool_unfrozen과 일부러 구분한다. 자동 만료는 운영자 판단이 아니라 설계대로 도는 것이라, 문구가 "자동으로 해제됨"이라 적고 로그가 출처를 갈라 둔다. (4) 중복 제거를 freeze 주기에 건다. (event_type, pool, created_at >= freeze_started_at), partner-funding 패턴이다. dedupByEntity아니다. 그건 풀당 평생 한 번이라 첫 freeze 이후를 전부 침묵시킨다. 연장은 freeze_started_at을 다시 잡으므로 키가 알림 둘을 재무장하는데, 연장이 72시간 차단을 다시 시작시키니 그게 맞다. (5) freeze 문구에는 UTC 라벨을 명시한 날짜시각 포매터 가 필요하다. formatDate()YYYY-MM-DD로 자르므로 72시간 마일스톤을 표현할 수 없다. 기각: 채널 분리(인앱 전용은 채널 필터 설비가 필요하고, 가장 중대한 알림을 가장 약한 채널로 보내게 된다). 새 이벤트 둘은 자동 만료 DB 수정과 함께 나간다(CH). 오늘은 7일 지점이 지난 걸 아는 게 없고, 스윕이 아직 얼어 있는 풀의 72시간 지점도 봐야 한다. 또 발견: freeze_extendedUntil이라 라벨된 행에 새 freeze 시작 시각을 넣어서, 연장 이메일이 오늘 날짜를 freeze 종료로 보여 준다.

알림 목록을 copy.ts(항목 38개)에 대고 재확인했다. 22-notifications생산자가 있는 이벤트 일곱 이 빠져 있었다. redemption_escalated, yield_distribution_due / _overdue / _escalation, kyc_reverify_due, sbt_mint_failed_user, nav_proposal_pending이다. 거기에 lp_mint_failed(문구만 있고 목록에도 미룬 목록에도 없었다)도 더한다. 발송 워커 주기를 정정했다(약 1분이 아니라 3분). large_deposit은 명확히 했다. admin-notification-preferences의 그 문자열은 admin 선호 분류 이지 생산자가 아니므로 "생산 안 됨"이 여전히 맞다.

문서: 14-decisions v3-97, 22-notifications. v3-96의 열린 항목에 대한 조치. Notion "Freeze auto-expiry" BE 핸드오프.

📅 2026년 7월 27일 — 수익 기록 = 분배. 상태별 복구, 그리고 finalize 전에 distribute tx_hash를 저장 (v3-96)

💸 한 번에 분배, 안전한 복구, 고아 PENDING 없음

admin Yield 페이지는 RecordDistribute 를 PENDING 기록의 "분배 준비" 큐를 낀 두 단계로 그렸는데, 백엔드는 이미 한 요청에서 분배한다. yield-distributions.post.create가 전체 분배를 인라인으로 돈다. 서버 경로(deposit:false)는 삽입 후 runYieldDistribution을 부르고 DISTRIBUTED(또는 FAILED)를 반환한다. FM 경로는 클라이언트 deposit_tx_hash와 함께 PROCESSING 을 기록하고, POST /{id}/distribute가 그 예치가 온체인에 착지했는지 확인하고 마무리한다. PENDING 행을 처리하는 비동기 워커가 없다. 그러니 남아 있는 PENDING은 "대기" 상태가 아니라 시작됐다가 끝나지 않은 분배다. 결정: (1) 수익을 기록하는 것이 곧 분배하는 것이다. 액션 하나이고 라벨은 "Distribute"다. 수수료는 절대 선행 조건이 아니다(net_yield_fee_config가 null이면 총액의 100%가 투자자에게 가고, 수수료 요율은 v3-69에 따라 어느 lifecycle 단계에서도 수정 가능하다). (2) 맹목적 재시도가 이중 분배를 낼 수 있으므로, 복구는 엔드포인트가 안전하게 지원하는 범위로 한정한다. /{id}/distributePROCESSING만 받는다(DISTRIBUTED가 되면 멱등). PROCESSING → "Distribute"(마무리), FAILED → "Re-distribute" 는 새 기록을 연다(실패한 시도는 분배한 적이 없으므로 재생성이 안전하다), PENDING → 셀프서비스 액션 없음(온체인에서 이미 분배했을 수 있는 finalize-DB 고아라 재시도하면 두 번 지급할 위험이 있다. 읽기 전용으로만 드러낸다). (3) 근본 원인 수정(apps/infra): runYieldDistribution이 이제 tx_hash + tx_submitted_at온체인 distributeYield가 성공한 직후, finalize UPDATE 전에 쓴다. 전에는 그 UPDATE가 tx_hash를 쓰는 유일한 자리라, 온체인 분배 후 크래시나 DB 실패가 나면 tx_hash 없는 고아 행이 남았다. 인덱서(writeYieldDistributed)는 tx_hash로 대사하므로 그 행을 영영 치유할 수 없었다. tx_hash를 일찍 쓰면 인덱서가 그런 행을 DISTRIBUTED로 대사한다. 고아도 안 남고 재시도로 두 번 지급할 일도 없다. 온체인 호출과 금액은 안 바뀐다. admin UI도: 기록 모달 게이트가 지갑만 본다(수수료가 더는 막지 않는다). Calculation 절이 풀 TVL · APY · 수익 기간 → 예상 수익을 항목화하고, 그다음 총액 → 수수료(부제 + 구간별 분해, 풀의 수수료 단계로 가는 실시간 링크, 미설정이면 "none") → 투자자 순액을 보여 준다. Overview는 연체된 풀 전체의 예상 수익으로 시작하고, 전체 분배 합계는 counts.ALL을 쓴다. tsc -b 초록(admin + infra). infra 변경은 배포 대기 다(자동 배포 없음).

문서: 14-decisions v3-96, 04-pool-models → Yield, 23-money-path.

📅 2026년 7월 27일 — 문구가 한 번도 못 가졌던 실패 신호를 얻는다. 문구 모듈 + 빌드를 막는 가드 + CLAUDE.md 규칙 2-b (v3-96)

🚧 v3-95는 문자열을 고쳤고, 이건 장치를 고친다

v3-95는 틀린 문구를 고치고 맞는 문구를 적었다. 그런데 다음 검증 안 된 줄이 똑같이 나가는 걸 막는 건 없었다. 장치를 정확히 이름 댈 값어치가 있다. 뻔한 진단 둘은 다 틀렸기 때문이다. 부주의도 아니고 리뷰 빈틈도 아니다. 문구는 여기서 자동 실패 신호가 없는 유일한 표면이다. 타입은 tsc에서 깨지고, 스키마 드리프트는 쿼리에서 깨지고, 컨트랙트 변경은 테스트에서 깨지는데, 조작된 문구는 컴파일되고 타입체크를 통과하고 렌더되고 리뷰를 통과하고 배포된다. v3-95의 결함 둘 다 정확히 그렇게 했고 내내 초록이었다. 더 나쁜 건 이 실패 방식이 리뷰에 적대적이라는 점이다. 컴포넌트에 텍스트 슬롯을 주고 명세를 안 주면, 쓰이는 것은 "틀려 보이지 않는 문장"이다. Date.now() + 45일"검토 완료: 예상 …" 으로 렌더된 건 무작위 오류가 아니다. 설득력 있는 목업이 원래 그렇게 생겼고, 이 FE는 목업에서 이식됐으니 그럴듯한 가짜 내용이 가짜라는 표시 없이 딸려 온 것이다. 근본 원인: 명세의 부재는 문구를 쓸 때 안 보이고 리뷰할 때 비싸다. 장치 셋, 각각 그 부재를 보이게 만든다. (1) 문구 모듈 — 상태 문구가 컴포넌트에서 나와 apps/{web,admin-web}/app/shared/copy/status.ts로 간다. 그래서 표면을 파일 하나에서 감사할 수 있고, 문구 변경이 JSX에 숨지 않고 문구 diff로 읽히며, 각 문자열이 자기 주장을 강제하는 코드 경로를 달고 있다(리뷰어는 동작을 재구성하는 대신 인용을 따라가고, 인용을 못 대는 작성자는 자기가 추측 중임을 안다). (2) scripts/check-copy.mjs가 빌드를 막는다 — 긍정형 보장/보호 표현, 지어낸 기한, 요일 주기, 그리고 문구 모듈 안의 시계 읽기를 금지한다. 일부러 lint 규칙이 아니라 빌드 단계다. CI는 lint가 아니라 build를 돌리고, pnpm lint는 지금 두 앱 모두에서 기존 오류 약 65개로 실패하므로 eslint만으로는 아무것도 못 막는다. 부정형 면책은 설계상 통과한다("수익은 보장되지 않습니다"는 필수 문구다). 그래서 인접이 아니라 부정어 되돌아보기가 필요했다. 인접 방식은 "a guaranteed amount가 아님"에서 깨졌는데, epoch-countdown.tsx의 실제 줄이고 첫 초안이 그걸 잡아냈다. 회귀 케이스 10개로 확인했다. 알려진 v3-95 나쁜 문자열 8개 전부 발동하고 부정형 둘은 통과한다. 탈출구는 copy-guard-allow: <사유>이고 사유는 필수다. (3) CLAUDE.md 규칙 2-b 가 규칙 2를 버튼에서 텍스트로 넓힌다. 검증 안 된 동작 문구는 그럴듯한 텍스트가 아니라 TODO(copy)와 드러난 질문이어야 한다. 이미 받아들여진 규범의 확장으로 적어서 자리 잡게 했다. 에디터 피드백용으로 app/shared/copy/**에 eslint no-restricted-syntax도 미러링했다. 지금 채택 안 한 것: CI에 lint 추가(오류 65개 밀린 것에 막힘), 목업 내용 표시자(목업 작업 흐름의 몫). tsc -b 두 앱 초록. 문구 가드가 331파일에서 통과. 게이트를 켠 build:dev 전체 초록. 스키마/BE/컨트랙트 변경 없음. 열린 것: 확인 다이얼로그, 알림 이메일 문구(lib/shared/notifications/copy.ts), 비활성 툴팁, 빈 상태가 감사 안 됐다. 알림 문구가 결과가 가장 큰데, 플랫폼을 떠나면 회수할 수 없기 때문이다.

문서: 14-decisions v3-96, 10-status-machines → Status Banner Copy. v3-95를 강제 가능하게 만든다.

📅 2026년 7월 27일 — 상태 배너 문구는 정확성 표면이다. 단일 출처 표와 규칙 다섯, 그리고 조작된 투자자 드로어 제거 (v3-95)

🗣️ 코드가 안 하는 걸 주장하던 문구

v3-94 배너를 감사하다가 같은 결함 계열이 모든 상태 표면에 있는 게 드러났다. (1) 🔴 투자자 pause 드로어는 처음부터 끝까지 조작이었다(web/shared/ui/PoolStatusAlert.tsx). 마운트 시점 Date.now() 로 계산한 "Paused: {날짜}" 줄(언제나 오늘이고 실제 pause 날짜가 아니다), "검토 완료: {오늘 + 45일} 예상" 기한, "상태 업데이트: 매주 금요일 게시" 주기, "대안: 원금 보호 를 동반한 구조적 청산" 결과, 그리고 원금이 "기초자산에 담보돼 있다"(풀은 UNSECURED일 수 있다), 수익이 "검토 중에도 계속 발생한다"(발생은 MANUAL_CLAIM이라 그런 게 없다), "풀이 다시 열리면 출금이 재개된다"(멈춘 적이 없다)고 주장하는 "Your Investment Is Protected" 블록이다. 여기에 죽은 버튼 둘(CLAUDE.md 규칙 2)이 붙는다. 어느 것도 데이터에서 안 왔고 전부 약속으로 읽힌다. (2) 🔴 paused 배너가 플래그를 뒤집었다. "당신의 투자는 보호됩니다. 풀이 다시 열리면 출금이 재개됩니다." is_pausedcapital in만 막고, soft pause에서 상환은 정상 처리된다. (3) frozen 배너가 기한이 없는 것처럼 읽혔다("나중에 다시 확인해 주세요"). freeze는 비대칭이고 스스로 만료되는데(v3-28) 마일스톤 컬럼 둘이 이미 read model에 있으면서 안 쓰이고 있었다. (4) is_paused는 재투자도 막는데 어느 admin 문구도 그걸 언급 안 했다. 결정: 상태 문구는 장식이 아니라 정확성 표면 이다. 틀린 상태 줄은 틀린 nav_per_token과 같은 결함 계열이다. 10-status-machines → Status Banner Copy가 이제 두 앱의 단일 출처 표 이고 규칙 다섯을 따른다. 스케줄러가 쓰지 않는 한 어떤 상태도 자동이라 부르지 말 것 · 보호/보장/회수 표현 금지 · 지어낸 날짜나 주기 금지(Date.now() 날짜는 기본값이 아니라 조작이다) · 축을 밝힐 것(capital in 대 value out) · 모든 조작 요소는 동작해야 함. 배포: 정직한 paused 문구. frozen이 이제 실제 재개 시각과 만료 시각을 적음. 드로어를 무엇이 멈췄나 · 무엇이 계속되나 · 알아 둘 것 으로 다시 만들고 진짜 지원 mailto:를 붙임. admin 배너는 "이 풀의 입금이 멈췄습니다. 상환, 출금, 수익 청구는 계속됩니다."로, "Resume Pool"은 "Resume Deposits"로. Deposit Pause 컨트롤이 이제 capital in — 입금과 재투자 라고 적음. 문서: 기능 표의 Paused/Frozen 행 정정과 새 Frozen은 비대칭이고 스스로 만료된다 하위 절. tsc -b 두 앱 초록. 스키마/BE/컨트랙트 변경 없음. 열린 것: wind-down "리저브" 표현은 일부러 컨트랙트에 맞춰 뒀다(결정 참조).

문서: 14-decisions v3-95, 10-status-machines. v3-94의 문구 절반을 구현한다.

📅 2026년 7월 27일 — pause는 수동 전용이고 연체가 자동 pause를 만들지 않는다. 자동 pause는 pause 출처가 먼저 필요하다 (v3-94)

⏸️ admin UI가 존재하지 않는 안전망을 안내하고 있었다

풀 상세 배너가 is_paused && yield_overdue일 때마다 "이 풀은 수익 분배 연체로 자동 pause 되었습니다" 라고 적었다. 쓰기 경로를 전부 감사했더니 그런 장치가 없었다. is_paused = true를 쓰는 건 pools.post.pause(ADMIN/OPERATOR 엔드포인트)와 pools.post.create뿐이다. 온체인 pause()onlyRole(PAUSER_ROLE) whenActive whenNotFrozen이고 사람 서명만 받는다. pools.scheduler.yield-duenext_yield_due를 다시 계산하고 yield_overdue 불리언을 세우고 알림을 큐에 넣을 뿐이며(전이 시 한 번 도래, 3일 유예 뒤 연체, 7일 이하 간격 재발송), 그 플래그를 쓰는 소비자는 전부 배지 아니면 필터다. 상환에는 연체 개념 자체가 없다. epoch-execute는 정산과 이상치 알림을 촉발하고 partner-funding은 알림을 보내며 둘 다 늦었는지 판단하지 않는다. 시스템에서 진짜로 자동인 유일한 정지는 executeEpoch 안의 NAV 낡음/편차 홀드이고(v3-32) 그건 입금이 아니라 정산 을 막는다. 그러니 배너는 우연에 발동해서 그걸 인과로 설명했고, 안전망으로 읽히면 운영자가 정작 pause해야 할 풀을 하게 만든다. 결정(현행 유지): pause는 수동 전용 이고 어떤 상태 문구도 다르게 말하면 안 된다(v3-95에서 수정). 연체 신호는 알림 + 운영 플래그로 남는다. 결정(선행 조건): 자동 pause는 지금 스키마 위에서 만들 수 없다. is_paused는 출처 없는 맨 불리언이라 풀 수 없는 충돌 둘을 강제한다. 재개 모호성(분배 시 자동 재개하면 무관한 이유로 걸어 둔 사람의 pause를 조용히 되돌리고, 수동 재개면 FM이 기록된 사유 없이 기다린다), 그리고 에스컬레이션 재진입(v3-78/v3-92가 에스컬레이션 시 플래그를 자동 해제하므로, 스윕이 운영자가 방금 일부러 다시 연 풀을 즉시 다시 pause한다). pause_reason + paused_by가 강한 선행 조건이다. 열린 질문 여섯을 권고와 함께 기록했다. 그중 으뜸은 yield_overdue가 오늘 기준 건전한 술어가 아니라는 것이다(앵커가 앞으로 안 굴러가므로 분배를 기다리는 풀이 영구히 연체다. 자동 pause는 우리가 열어 두고 싶은 건강한 새 풀을 정확히 골라 멈춘다). 그리고 3일 유예는 알림 주기이지 자본을 멈출 근거로는 한참 짧으며, 알림 에스컬레이션과 운영 큐와 fundingRestricted와 IMPAIRED가 이미 그 실패를 덮으므로 답은 아마 pause가 아니다. 상환 연체는 v3-93이 늦었는지 잴 회차별 funding date를 준 뒤에야 정의할 수 있다. 스키마/BE/스케줄러/컨트랙트 변경 없음.

문서: 14-decisions v3-94, 10-status-machines → 기능 표. v3-78 + v3-92의 제약을 받는다. 문구 수정은 v3-95.

📅 2026년 7월 27일 — epoch 요청 윈도는 하드 게이트다. funding date는 회차마다 admin이 입력하고, 취소 윈도 = 요청 윈도 (v3-93)

🚪 동결에는 커서 이동이 아니라 실제로 거절하는 게이트가 필요하다

v3-91은 Model B(짧은 requestWindowDays 윈도, 그다음 아무것도 안 받는 틈)를 명세했는데 동결을 접수/정산 커서 분리 로 설명했다. 커서 분리만으로는 아무것도 거절하지 못한다(acceptingEpochId는 모든 순간에 정의돼 있으므로 모든 요청이 어딘가에 착지한다). 그래서 requestWindowDays는 강제되지 않았고 엔진은 여전히 Model A였다. v3-93 이 네 지점에서 그걸 개정한다. (1) 요청 윈도는 하드 게이트이고 그것이 곧 동결이다. requestRedemption[windowOpen(N), cutoff(N)) 밖에서 RequestWindowClosed로 리버트한다. 하한만 검사하면 된다. N = floor((now − anchor)/duration) + 1이 이미 now < cutoff(N)을 함의하기 때문이다. 커서 분리는 남지만 강등 돼서 늦은 정산 안전망이 된다(cron 지연으로 N+1 윈도가 열릴 때까지 epoch N이 미정산으로 남는 경우). 윈도가 하중을 받는 이유는 규칙 1(요청 시점에 수익 정지)과 짝을 이루기 때문이다. 늘 받으면 컷오프 직후에 넣은 요청이 아무것도 못 벌면서 duration + recallLeadDays(월간이면 약 40일)를 기다린다. v3-91 프리셋이 말하는 14~17일과 대비된다. (2) 회차마다 기준점 하나. admin이 입력하는 funding date다. fundingAnchor는 월말 규칙이 아니다. 발행사가 자기 정산일을 오프체인으로 보고하고 admin이 회차마다 입력한다(§3 반자동이라 FM 지갑 모델은 안 건드린다. 온체인 날짜 세터 없음). cutoff(n) = fundingDate(n) − recallLeadDayswindowOpen(n) = cutoff(n) − requestWindowDays이고, C2 fail-open 기본값은 직전 fundingDate + scheduleType 주기다. 그래서 컨트랙트는 달력 계산을 절대 안 하고(날짜 라이브러리도, 미리 구운 스케줄 배열도 없다) scheduleType은 fail-open 스텝으로 줄어든다. 낡은 서술 둘을 정정한다. §3 표의 "월말 / 분기말", 그리고 v3-91의 컷오프 앵커 스케치다. (3) 반자동 funding date 손잡이는 그 회차의 요청 윈도가 열리기 전까지만 자유롭다. 규칙을 더하는 게 아니라 §3 흐름("승인 → 윈도 열림")을 고정하는 것이다. 윈도가 열린 뒤 지연된 회차는 정산만 옮긴다(독립 settleAfter[id]이고 컷오프 유도의 입력이 아니다). 조정은 지연 방향만 된다. 실패 둘을 막는다. 투자자가 이미 본 마감을 다시 쓰는 것, 그리고 닫힌 윈도를 다시 열어 발행사에게 이미 통보한 총액이 바뀌는 것이다. (4) 취소 윈도 = 요청 윈도. cancelRedemption이 같은 게이트를 달아서, 취소가 통보된 총액을 거짓으로 만들거나 Σ claims = epochClaimable[id]를 깰 수 없다. 이월된 수요가 갇히지도 않는다(부분 체결 투자자는 다음 윈도에서 취소한다). 그리고 괴롭힘 경로 둘이 공짜로 닫힌다. BE 변경 없음(온체인 취소는 확인 후 미러링한다). 투자자 UI는 에스크로 기간의 수익이 환급되지 않는다고 적어야 한다. 컨트랙트 구현 대기(CH). 온체인은 아직 아무것도 안 바뀐다. 출처: Notion 핸드오프에 대한 JY 리뷰, 2026-07-27.

문서: 14-decisions v3-93(v3-91 개정), 07-redemption. Notion "Epoch Redemption" 핸드오프.

📅 2026년 7월 27일 — 상태 플래그 자동 해제는 온체인 unpause까지 해야 한다 (v3-92)

🔗 is_paused 자동 해제가 이제 DB 컬럼만이 아니라 컨트랙트까지 닿는다

Test Pool 260616-base에서 발견했다. admin pause가 502 "On-chain pause operation failed" 를 냈고 UI로는 복구할 길이 없었다. 온체인 paused()true인데 DB is_pausedfalse라서 pause()EnforcedPause()로 리버트했다. 흔적: PAUSE(07-03) → FREEZE(07-23) → UNFREEZE(07-27), UNPAUSE 없음. freeze의 v3-78 자동 해제가 컨트랙트를 안 건드리고 DB 컬럼만 껐고, OZ PausableemergencyFreeze()나 lifecycle enum과 독립이다. 결과: UI는 풀이 열려 있다고 보여 주는데 입금은 whenNotPaused에서 조용히 리버트를 계속했고, 스스로 낫는 것도 없었다(인덱서가 paused()를 되미러링하지 않는다). 수정: pools.post.freeze(freeze) · .impairment(execute) · .wind-down(execute)이 이제 공유 clearOnChainPause(poolAddress, chainId)(lib/shared/contract/pause.ts)를 부른다. 실시간 paused()를 읽고 켜져 있으면 unpause()를 보내는데, 에스컬레이션 tx 다(먼저 unpause하면 에스컬레이션이 실패했을 때 컨트랙트가 입금 열린 채로 남는다. 08 §A 빈틈이다. unpause()는 가드가 없고 pause()whenActive/whenNotFrozen을 달고 있어서 안전하다). is_paused = false는 컨트랙트가 실제로 unpause됐을 때만 쓴다. unpause가 실패하면 true로 유지한다(감사 pause_clear_failed, 성공하면 unpause_tx_hash 기록). 그래서 DB가 체인에 없는 상태를 절대 주장하지 않는다. BE(apps/infra): tsc --noEmit 초록(배포 대기. infra에 자동 배포 없음). 이미 어긋난 풀은 POST /pools/{id}/pause {"paused": false}를 한 번 보내면 된다.

문서: 14-decisions v3-92(v3-78 개정), 10-status-machines → 자동 해제, 08-smart-contracts §A.

📅 2026년 7월 24일 — epoch 상환 엔진 재설계. 달력 앵커 스케줄, 수요 동결, 정산 에스크로(이중 커밋 수정), 요청 시점 수익 정지 (v3-91)

🔁 epoch 엔진을 한 덩어리로 다시 만든다. 결정됨, 컨트랙트 구현 대기(CH)

RedemptionLib / YieldLib을 epoch 제품 의도와 줄 단위로 대조했더니, 함께 닫아야만 닫히는 맞물린 빈틈 넷이 나왔다. v3-91 이 목표 설계를 정한다(아직 미구현. 현재 온체인 동작은 그대로다). 달력 앵커 스케줄링(고정 fundingAnchor + scheduleType 월간/분기 + requestWindowDays / recallLeadDays. 게으른 시작과 연달아 붙는 방식을 대체한다. claim = fundingAnchor, processingDays 0, NAV 승인은 펀딩 전에 고정). 컷오프 시점의 수요 동결(접수 커서를 정산 커서에서 분리)로 확정 총액을 발행사에 보낼 수 있고 펀딩이 동결된 epoch를 겨냥한다. executeEpochrecallLeadDays 게이트. epoch별 정산 에스크로(정산에서 차감되는 epochClaimable pot에서만 청구가 인출)로 epoch를 넘는 이중 커밋 을 고치고 수익 / 리저브 / 상환 잔액을 격리한다. 요청 시점 수익 정지(lockedYield 제거 에스크로된 LP를 분배 분모에서 제외. 한 세트다. "방식 (b)"를 뒤집는다). 이월 우선 FIFO(2단 래더, O(1)). 그리고 NAV 제안/승인/오버라이드 와 버그 방지용 NAV > 0 하한. 🔴A + 🔴B + 🔴G와 동결은 PR / 감사 단위 하나로 나간다(CH). C3+I와 C8은 그 위에 얹는다. 컨트랙트 외 후속은 PRD로 추적한다(JY). 출처: 감사 + 설계 세션, 2026-07-24.

문서: 14-decisions v3-91, 07-redemption. v3-26, v3-18을 개정한다. v3-46과 맞물린다. Notion "Epoch Redemption" 핸드오프.

📅 2026년 7월 24일 — Joob 펀드 데이터 의미. NPL은 DPD 90, 상각은 약 180 DPD, 펀드 가치는 현금 수취 기준, EOD 09:00 갱신 (v3-90)

🏷️ Joob이 손실 시점과 펀드 가치 기준을 확인해 줬다

2026-07-24 Joob 통화에서 오래 열려 있던 데이터 요청 항목 둘이 닫혔다. 대출은 DPD 90에 NPL이 되고 상각은 약 6개월(약 180 DPD)에 한다. 90일에 부실이 되지만 장부에는 남고(npl_ratio에 들어간다), 실제 상각이 실현 cumulative_loss(Joob의 별도 "상각 / 회수 중" 줄)로 들어가는 건 약 180일이다. 그러니 npl_threshold_days = 90write_off_policy ≈ 180하나로 안 접힌다. 90~180 DPD의 실제 NPL 구간이 있고 위험 배지의 선행 NPL 신호가 정상 발동한다. current_fund_value는 현금 수취 기준이다. 실제로 받은 이자만 센다. 발생 이자와 미수(승인됐지만 미수령) 이자는 정산될 때까지 제외한다(현재 승인→수취 지연은 1~2일). 그 지연이 이자 하루치쯤이라 NAV 대비 중요하지 않으므로, Aset은 현금 fund_value를 그대로 미러링하고 accrued_income 요청은 뺀다(0068 컬럼은 휴면으로 남는다). 주기는 EOD 매일 09:00 갱신 으로 옮겨 간다(DPD와 같다). 문서만이고 스키마/컨트랙트 변경 없음. 미뤄 둔 자동 DPD→NAV 상각(v3-13)은 여전히 대출별 데이터를 기다린다. 출처: Joob 통화, 2026-07-24.

문서: 14-decisions v3-90, 20-joob-pool-config, 06-writedown-nav. v3-72를 다듬는다.

📅 2026년 7월 23일 — 상환 용어. OPEN_ENDED가 정본이고 "revolving"은 UX 별명. 개방형 풀에는 조기 이탈 패널티가 없다 (v3-89)

🏷️ 용어는 OPEN_ENDED이고, 개방형은 패널티 없음이다

3-상태 락업 모델이 "REVOLVING"을 maturity_model 값인 것처럼 썼고(enum은 FIXED_TERM / OPEN_ENDED뿐이고 "REVOLVING"은 API가 거절하는 UX 문구다) 개방형 풀에 대해 자기모순이었다. EARLY 행은 패널티가 있다고 함의하는데 FREE 행은 락업 직후부터 자유롭다고 했다. v3-89 가 둘 다 정리한다. OPEN_ENDED 풀에는 EARLY 구간이 없다(락업 뒤 LOCKED → FREE). 그러니 조기 이탈 패널티가 적용될 일이 없다. EARLY와 패널티는 FIXED_TERM에만 있다. 컨트랙트와 맞고(maturityDate = 0 ⇒ EARLY 아님) 위저드와도 맞는다(만기가 없으면 패널티가 숨겨진다). 용어는 07-redemption04-pool-models 전반에서 OPEN_ENDED 로 정규화했다. "revolving"은 UX 별명으로만 남는다. "Epoch-Based Redemption (REVOLVING pools)" 제목은 "(Open-Ended Pools)"가 되고 그리로 오는 링크 10개를 갱신했다. LINE BK 예제(OPEN_ENDED + PRINCIPAL_BASED)는 무효로 표시했다. 문서만. 출처: JY, 2026-07-23.

문서: 14-decisions v3-89, 07-redemption, 04-pool-models. v3-46을 명확히 한다.

📅 2026년 7월 23일 — admin 상환 단계 위저드. 정합성 + 설정 가드, 정산 UX, redemption_type 라벨 변경 (v3-88)

🔁 상환 단계: 해당 없는 건 숨기고, 막다른 설정은 차단하고, 라벨을 명확히

admin 풀 생성/수정의 Redemption & penalty 단계가 의미 없거나 상환을 막는 설정을 표현할 수 있게 두고, 오독되는 라벨을 쓰고 있었다. v3-88(온체인 RedemptionLib, 백엔드 적격성, 명세에 대고 감사): 게이팅과 instant 전용 "예상 정산" 안내는 해당 모드에서만 보인다(D1/D3). 패널티 절은 수정 시에도 만기로 게이트된다(D2). LIQUIDITY_WINDOWS는 배선 안 된 상환 차단 함정이라 드롭다운에서 제거한다(D5. enum은 온체인에 남고, 수정 화면은 읽기 전용 레거시 폴백을 유지한다). OPEN_ENDED + FIXED_MATURITY는 만기에 도달할 일이 없어서 막는다(D6). FIXED_MATURITY는 epoch 선택기를 숨기고 epoch_duration_days = 0을 강제한다. 만기 온 풀은 한 번에 지급하기 때문이다(D7. admin UX 가드일 뿐이고 컨트랙트는 여전히 그 조합을 허용한다. v3-46바꾸지 않으므로 epoch는 maturity_model과 직교로 남고 FIXED_TERM + ON_DEMAND + epoch도 여전히 유효하다). 정산 필드는 회차 길이 하위 필드를 가진 명시적 Instant / Epoch 모드 선택 이 된다(D8). 그리고 redemption_type은 실제로 통제하는 것으로 라벨을 바꾼다. "Redeem at maturity only" / "Redeem on request"(D9). admin-web만이고 DB/스키마/컨트랙트/BE 변경 없음. tsc -b 초록. ⚠️ 후속(CH): 만기 없는 FIXED_MATURITY는 온체인은 허용하고 백엔드는 막아서 갈린다. 출처: JY, 2026-07-23.

문서: 14-decisions v3-88, 07-redemption, 04-pool-models. admin 계층에서 v3-46을 조인다. Notion "Epoch config 정합성".

📅 2026년 7월 22일 — 풀 상세 정보 구조. Controls는 액션만, NAV 추이는 Overview 차트로, 거버넌스/lifecycle 이력은 Audit Log로 (v3-87)

🎛️ Controls는 액션을 담고, 읽기 전용 이력은 나간다

admin 풀 상세의 Controls 탭이 살아 있는 액션과 인라인 NAV 변경 표와 거버넌스/lifecycle 이력 피드를 일관성 없는 위계 아래 섞어 놓고 있었다. v3-87 이 Controls를 액션 전용 으로 만들고 Deposits, Danger zone, Pool lifecycle에 걸쳐 통일된 3단 구조(그룹 eyebrow → 패널 제목 → 한 줄 부제)를 준다(danger/lifecycle은 빨강, 나머지는 중립. 색은 의미만 신호한다). 각 컨트롤은 촘촘한 행이다. 긴 본문 문단은 확인 다이얼로그가 이미 담고 있어서 뺐다. 거버넌스 변경NAV 변경 은 늘 펼쳐진 인라인 폼 대신 더 무거운 다이얼로그를 여는 액션이 된다(타임락 안내 + 현재값 + 주요 제안 + 대기 중 제안 관리). 읽기 전용 토큰당 NAV 추이는 Overview로 간다. 기간 탭(1M/3M/6M/1Y/All)이 붙은 브랜드 색 시계열 차트다. NAV 제안/오버라이드는 Controls에 남는다. 거버넌스 & Lifecycle 이력은 풀 상세에서 제거한다. 전역 Audit Log(v3-86)가 유일한 거처다. admin-web만 리팩터링이고 BE/DB/스키마/컨트랙트 변경 없음. recharts를 추가한다(web이 이미 쓰던 것). nav-changes-panel / governance-history-panel 위젯을 삭제한다. tsc -b 초록. 출처: JY, 2026-07-22.

문서: 14-decisions v3-87. v3-86 위에 얹는다.

📅 2026년 7월 21일 — Audit Log 재기획. 감사 우선 단일 출처, 행위자 스냅샷, 커버리지 확대, Audit/Activity 분리 (v3-86, v3-41 확장)

🗂️ 감사 로그가 이제 감사 기록(단일 출처)이고 그 위에 읽기 뷰인 Activity 피드가 얹힌다. 느슨하게 합친 피드가 아니다

로그는 v3-41에서 그게 무엇인지 를 안 정한 채 조각조각 조립됐다. 그래서 actor_id가 저장은 되는데 한 번도 해석되지 않았다(UI는 UUID나 "Unknown"이나 "SYSTEM"을 보여 줬다. 2026-07-20 주간: "Actor ID → 사용자 이름 + 역할"). v3-86 이 여섯 층에 걸쳐 범위를 다시 잡는다. 정체성 = 감사 단일 출처 + Activity 읽기 뷰. 쓰기 범위 = 사람의 재량 행위만(경제적·온체인 행위는 읽기 뷰 UNION으로 남는다). 빠져 있던 풀/트랑슈 생성·배포·발행·수정·삭제, 펀드 CRUD/멤버, admin 사용자 CRUD/권한/지갑, 투자자 적격성 라이터를 더한다. 행위자 = 혼합. 사람 스트림은 쓰기 시점에 actor_nameactor_role을 스냅샷으로 잡고(조인 폴백) 이름 · 역할 배지로 표시한다. 스키마 = before→after diff + reason(고위험만) + outcome(실패도 기록). 정보 구조 = Audit/Activity 두 탭이고 FM은 자기 펀드 Activity만 본다. 보존 = DB 수준 append-only, 5년 뒤 S3 콜드 아카이브(하드 삭제를 대체한다). 출처: JY, 2026-07-20 주간 + 2026-07-21.

문서: 14-decisions v3-86, 13-operations → 감사 로그 보존과 내보내기. v3-41을 확장한다. 스키마 변경은 11-db-schema 대기. Notion "Admin-web Audit Log".

📅 2026년 7월 21일 — 조기 이탈 패널티 목적지. Pool 리저브가 아니라 fund_wallet (v3-85)

💸 조기 이탈 패널티는 풀 리저브가 아니라 펀드매니저 지갑으로 간다

v3-84는 조기 이탈 패널티를 reserveBalance로 보냈다. v3-85 는 대신 풀의 fund_wallet 으로 보낸다. 패널티 타입 전부(PRINCIPAL_BASED / FLAT_FEE / YIELD_BASED, instant와 epoch 모두)에 적용된다. 금액과 공식은 그대로이고 목적지만 움직인다. ⚠️ 실제 경제를 짚어 둔다. 평시 navPerToken은 오라클이 세팅하고 reserveBalance와 독립이다(리저브가 NAV에 들어가는 건 WIND_DOWN뿐이고 거기서 nav = reserveBalance / totalSupply다). 그러니 패널티를 리저브에 두는 것이 남은 LP 홀더의 일상 NAV를 올린 적은 없다. 그건 풀의 상환 유동성 완충wind-down pro-rata 회수 를 채웠다. fund_wallet으로 옮기면 둘 다 없어지고 패널티가 FM에게 간다. 컨트랙트(RedemptionLib.sol): 적립 지점 다섯 곳의 s.reserveBalance += penaltysafeTransfer(s.fundWallet, _denormalize(penalty))로 바꾸고 패널티를 리저브 / totalDeposited에서 차감한다(순 지급액이 아니라 총액 전부를 차감해야 한다. 안 그러면 reserveBalance가 실제 USDC 위로 떠오른다). v3-84의 YIELD_BASED payout = 원금 − penalty 변경과 묶어서 낸다. [User/CH] 컨트랙트 재배포 필요(새 풀). 그전까지 배포된 컨트랙트는 리저브 동작을 유지한다. 출처: JY, Redemption Flow QA.

문서: 14-decisions v3-85, 07-redemption → 패널티 타입. v3-84의 패널티 목적지를 대체한다. CLAUDE.md Penalty Types.

📅 2026년 7월 20일 — YIELD_BASED 패널티를 락업 게이트와 함께 되살린다 (v3-84, v3-79 폐기를 뒤집음)

🔁 YIELD_BASED가 다시 유효한 패널티다. 단, 첫 수익 분배 뒤에야 락업이 풀리는 락업 풀에만

v3-79는 "발생 수익의 %" 패널티가 수익이 안 쌓였으면 0 이라 지켜야 할 EARLY 구간에서 무력하다는 이유로 YIELD_BASED를 폐기했다. v3-84 는 그 실패 방식을 없애는 가드 둘을 달고 되살린다. 락업 풀에서만 고를 수 있고(lockup_days > 0), 풀의 첫 수익 분배 전에는 락업이 풀릴 수 없다. 그래서 투자자가 발생 수익 0으로 EARLY에 도달하는 일이 없다. 장치: penalty = accrued_yield × penalty_rate_bps / 10000 → Pool 리저브. PRINCIPAL_BASED가 여전히 기본이고 v3-79의 부분/전액 정책은 그대로다. 이걸로 Joob 풀 도 고아 상태에서 벗어난다(SSA §4.2 = 배당 50% 몰수 = YIELD_BASED 5000 bps). v3-79가 그것과 모순됐다. 차감 대상(2026-07-21 결정): 원금에서. payout = 상환 원금 − penalty이고 패널티는 리저브로 간다(⚠️ 목적지는 나중에 v3-85fund_wallet로 옮겼다). 이미 분배된 수익은 사후 회수할 수 없다. 컨트랙트 변경과 재배포가 필요했고 그 뒤 배포됐다(v3-84/v3-85). 배포된 RedemptionLibpayout = 상환 원금 − penalty를 계산하고 패널티를 fund_wallet으로 보낸다. BE는 락업이 있을 때만 YIELD_BASED를 다시 허용하고 락업 해제 ≥ 첫 수익 분배를 강제한다. FE는 생성 옵션을 다시 넣는다(락업을 설정하면 나타난다). 출처: JY, Redemption Flow QA.

문서: 14-decisions v3-84, 07-redemption → 패널티 타입, 20-joob-pool-config. v3-79의 폐기를 뒤집는다. CLAUDE.md Penalty Types.

📅 2026년 7월 20일 — 락업을 패널티 타입에서 분리한다 (v3-83, v3-66 대체)

🔓 락업이 더는 패널티를 강제하지 않는다. NO_EARLY + 락업이 이제 유효하다

v3-66NO_EARLY를 "t0부터 자유 이탈"로 정의하고 lockup_days = 0 ON_DEMAND에 묶어 온체인에서 강제했다. 그런데 락업(상환이 완전히 막히는 LOCKED 구간)과 조기 이탈 패널티(락업 뒤 만기 전 EARLY 구간의 수수료)는 독립 이다. "30일 잠그고 그다음엔 무패널티 상환"은 평범한 상품이다. v3-66은 생성 위저드도 깨뜨렸다. 3단계가 "5단계에서" 패널티를 요구하는데 5단계는 만기가 없으면 패널티 선택기를 숨겨서 요구를 충족할 수 없었다. v3-83 이 둘을 독립시킨다. NO_EARLY = 조기 이탈 패널티가 0, 그뿐이다. 온체인 가드 둘(GovernanceLib.setLockupDaysNoEarlyRequiresZeroLockup, PoolConfigLibNO_EARLY ⇒ ON_DEMAND)과 이제 안 쓰는 에러를 제거했다. 백엔드 pools.post.create / pools.patch.update를 완화했다. FE 생성 위저드의 결합과 NO_EARLYON_DEMAND 강제를 뺐다. 투자자 web은 이제 모든 패널티 타입에서 실제 lockup_days를 읽고 상환을 순수하게 LOCKED로만 게이트한다. Foundry 테스트 307개 통과(리버트 테스트를 전환했고 락업 + NO_EARLY 상환을 단언한다). ABI 재생성(에러만). tsc -b 초록(web + admin-web). [User] 컨트랙트 재배포 필요(새 풀만. v3-66 가드는 아직 배포 대기였다).

문서: 14-decisions v3-83, 07-redemption → 3-상태 락업, 04-pool-models → penalty_type, 24-field-governance. CLAUDE.md Penalty Types. v3-66을 대체한다.

📅 2026년 7월 20일 — 파트너 펀딩이 PENDING_RESERVE 상환을 자동 정산한다 (v3-82)

⚡ FM이 리저브를 채우면 대기 중인 상환이 자동 정산된다. admin 승인 없음

펀딩된 부족액을 admin approveRedemption으로 게이트하던 v3-76 하위 결정을 뒤집는다. instant 경로에서 리저브 검사는 여전히 요청 시점 에 일어난다(충분하면 즉시 정산, 부족하면 LP 에스크로 + PENDING_RESERVE + fm_shortfall 알림). 이제 파트너의 fundRedemption풀 잔액이 지급액을 덮는 순간 요청을 자동 정산한다. LP 소각 + 투자자 지급 + COMPLETED가 같은 호출에서 일어나고 admin 단계가 없다. 채우고도 여전히 모자라면 PENDING_RESERVE로 남아 다음 펀딩을 기다린다. 완전 자동이다. instant 경로에 컴플라이언스/이상치 홀드는 없다. approveRedemption은 무권한 claimRedemptionFallback 안내 전에 리저브가 독립적으로 늘어난 경우를 위한 선택적 수동 정산으로만 살아남는다. 백엔드(fund.ts + fundRedemptionOnChain)는 이미 자동 완료를 전제로 쓰여 있었다. v3-76이 컨트랙트만 바꿨으므로 이건 BE ↔ 컨트랙트 정합을 복원하는 것이다. 컨트랙트 RedemptionLib.fundRedemption 갱신. Foundry 테스트 307개 통과. PlatformPool 19,480 / 24,576(여유 그대로). [User] 컨트랙트 재배포 필요(새 풀만). DB/ABI 변경 없음.

문서: 14-decisions v3-82, 07-redemption → Instant 상환 흐름. v3-76을 뒤집는다.

📅 2026년 7월 20일 — 풀 상태 플래그 자동 해제와 wind-down의 freeze 제거 (v3-78)

🔀 유효 상태는 하나. 상위 상태가 하위 플래그를 자동 해제하고, wind-down은 더는 freeze하지 않는다

2축 모델(lifecycle enum + is_paused / is_emergency_frozen 플래그)은 유지하되, 플래그가 배타적인 것처럼 동작한다. admin 액션이 같은 BE 쓰기 안에서 하위 플래그를 자동 해제한다. freeze / impairment / wind-down 실행이 전부 is_paused = false를 세운다. 상태를 되돌려도 하위 플래그가 자동 복귀하지는 않는다. pauseACTIVE이고 얼지 않은 풀에서만 받는다. 무엇보다 executeWindDown이 더는 is_emergency_frozen = true를 세우지 않는다(안 A). freeze가 우선순위에서 WIND_DOWN보다 위라서, 청산된 풀이 "Frozen(전면 중단)"으로 표시되며 pro-rata 상환 경로를 감췄고 wind-down의 목적과 모순됐다. 온체인 requestRedemption에는 whenNotFrozen 가드가 없고 RedemptionLib은 wind-down 상환을 허용하므로 상환은 언제나 됐다. 틀린 건 DB 플래그와 FE 표시뿐이었다. BE(apps/infra/lambda): pools.post.wind-down / .freeze / .impairment / .pause 갱신, tsc -b 초록(배포 대기. infra에 자동 배포 없음). admin pool-controls 재설계(단일 상태 표시, 안전→비가역 CTA 순서, 상위 상태가 하위를 비활성화)는 admin-web 세션으로 넘겼다.

문서: 14-decisions v3-78, 10-status-machines → 자동 해제. CLAUDE.md Pool 복합 상태.

📅 2026년 7월 20일 — 조기 이탈 패널티를 PRINCIPAL_BASED로, 그리고 부분 상환 정책 (v3-79)

💸 패널티는 수익이 아니라 상환 원금에. YIELD_BASED 폐기. 종착 상태는 전액만

이 항목의 YIELD_BASED 폐기는 v3-84(2026-07-20)가 뒤집었다. 락업 풀에 한해 되살렸다(락업 해제를 첫 수익 분배로 게이트). 아래의 PRINCIPAL_BASED 기본값과 부분/전액 정책은 여전히 유효하다.

PRINCIPAL_BASED(상환 원금의 %)가 기본 조기 이탈 패널티 가 되고 YIELD_BASED폐기 된다(생성 시 선택 불가. 기존 풀은 PRINCIPAL_BASED로 이관). "발생 수익의 %" 패널티는 수익이 아직 안 쌓였거나 이미 청구된 경우 0이라, 정작 그걸 위해 있는 EARLY 구간에서 이탈을 못 막는다. 그리고 벤치마크 조사에서 실제 선례를 못 찾았다(예금은 약정 이자를 몰수하고 원금까지 물 수 있고, 토큰화 RWA 펀드는 원금의 고정 %를 물리거나 아무것도 안 물린다). 부분 상환ACTIVE / IMPAIRED에서 계속 허용된다(패널티는 상환한 몫에만 pro-rata). MATURED / WIND_DOWN전액만 이다. 컨트랙트 변경 없고 컬럼 삭제도 없다. 컨트랙트는 이미 PRINCIPAL_BASEDgrossAmount × bps / 10000으로 계산하고 grossAmount가 상환한 몫이다(그래서 부분 pro-rata가 자동이고 instant와 epoch가 같다). YIELD_BASED는 죽은 온체인 갈래로 남는다. 대기 중인 핸드오프: FE가 YIELD_BASED 생성 옵션 제거, BE가 기존 YIELD_BASED 풀 데이터 이관(⚠️ 요율 재설정 필요. bps가 "수익의 %"였는데 이제 "원금의 %"다), BE가 종착 상태의 부분 상환 거절(온체인 FullRedemptionRequired 가드는 다음 재배포로 미룸. 안전 문제 아님).

문서: 14-decisions v3-79, 07-redemption → 패널티 타입 / 부분 대 전액. CLAUDE.md Penalty Types.

📅 2026년 7월 20일 — 규칙 7 개정. admin 입력은 percent를 받는다 (v3-80)

📊 admin 생성/수정의 비율 필드는 %로 입력하고 저장 시 단방향으로 bps 변환

admin 입력 을 bps로 두던 v3-73 예외를 개정한다. admin 생성/수정 폼이 이제 reserve_bps / redemption_gating_bps / penalty_rate_bpspercent 로 받는다. 운영자가 2.5를 치면 폼이 저장 시 Math.round(pct × 100) → bps로 변환한다. 저장, API 페이로드, 람다, 컨트랙트는 bps 그대로다. 안전한 이유는 이것이 규칙 7이 금지하는 양방향 브리지가 아니라 입력 경계에서의 단방향 변환 한 번 이고 왕복이 무손실이기 때문이다(percent 소수 2자리 ↔ 정수 bps, 1 bps = 0.01%). 검증: admin 입력은 isPercent(0–100, 소수 2자리)를 쓰고 변환한다. 그 외 경로는 isBps를 유지한다. 대기 중인 핸드오프: admin-web 폼 필드에 suffix="%"= X bps 힌트, 페이로드는 변환된 bps 전송.

문서: 14-decisions v3-80, 14-decisions v3-73. CLAUDE.md 규칙 7.

📅 2026년 7월 20일 — 생성 검증과 REVOLVING / APY 표시 (v3-81)

📅 만기 > 락업, REVOLVING 발행 예외, 개방형 풀의 연 단위 APY

생성/표시 수정 셋. 만기는 락업보다 엄격히 커야 한다(에서 >로). 그래야 EARLY 구간이 길이 0이 될 수 없다. lockup_days = 0maturity_days = 0(REVOLVING) 경계는 그대로다. REVOLVING 풀은 발행 시 end_date 요구에서 면제된다(발행은 언제나 start_date를 요구하고, end_date와 만기는 고정 기간 풀에만 요구한다). 투자자 FE가 개방형 풀에 APY를 연 단위로 보여 준다. APY는 연율이라 고정 기간 풀은 남은 기간에 안분해 보여 주는데("만기 예상 가치"), REVOLVING 풀은 이제 "만기 시"라고 잘못 라벨된 1년치 덩어리 대신 ~$X / 년을 보여 준다(옛 "$108" 과장). 완료: 투자자 web invest-sidebar.tsx, tsc -b 초록. 대기 중인 핸드오프: FE 생성 검증 3곳 + BE 만기 가드 + BE 발행 게이트.

문서: 14-decisions v3-81, 07-redemption → 3-상태 락업.

📅 2026년 7월 16일 — 디자인 시스템을 공유 @aset/ds-editorial로 통합 (v3-77)

🎨 폰트 + 팔레트 + 타입 스케일 + 패턴의 단일 출처(web + admin)

@aset/ds-editorial이 이제 프론트엔드 디자인 시스템 전체를 소유한다. 폰트 패밀리 토큰과 @import 로딩(fonts.css), 전체 색 팔레트와 shadcn 시맨틱 --color-*와 타입 스케일(tokens.css), 패턴 컴포넌트 다섯(patterns.tsx), 그리고 살아 있는 /styleguide 쇼케이스(showcase.tsx)다. 앱별 global.css는 더는 팔레트와 폰트를 다시 정의하지 않는다. web은 자기 사본을 뺐고, admin은 finance-dashboard-kit 뒤에 tokens.css를 임포트해서 Aset 팔레트가 키트를 덮는다. 결과: web과 admin이 같은 팔레트 하나 로 렌더되고, 색이나 폰트를 바꾸는 게 파일 하나 수정이 된다. /styleguide는 두 앱 모두에 마운트돼 있고(인증 밖) 실제 토큰과 컴포넌트를 렌더한다. 그전에 손으로 만든 사본들(claude.ai 아티팩트와 Drive 색상 HTML)을 대체하고 그것들은 이제 폐기 다. 두 앱 다 빌드되고, 두 번들 다 Quicksand와 DM Serif Display를 불러오며 브랜드 #5423e7을 해석한다.

문서: 25-design-system, 14-decisions v3-77.

📅 2026년 7월 16일 — 상환 흐름 전면 개편. 리저브 게이트 즉시 정산 + 락업 하드 차단 (v3-76)

⚡ 리저브가 덮으면 즉시 정산, 모자라면 에스크로 + FM 펀딩 + admin 승인

requestRedemption(instant 풀)이 이제 reserveBalance ≥ payout이면 같은 tx에서 정산한다. LP를 투자자에게서 바로 소각하고 USDC를 지급하고 상태를 COMPLETED로 만든다. 에스크로도 없고 별도 승인도 없다. 모자라면 LP를 PENDING_RESERVE로 에스크로하고 요청 시점에 펀드에 알린다(fm_shortfall). 파트너가 fundRedemption으로 채우고(더는 자동 완료 안 함) admin approveRedemption이 펀딩된 지급을 푼다. PENDING_RESERVE에서 cancelRedemption하면 파트너 펀딩을 환급한다. 입금이 90%를 fund_wallet로 보내므로(리저브 10%) 즉시 정산되는 건 작은 상환뿐이다.

🔒 락업은 하드 차단이다(모델 X). LOCKED = 상환 불가, 온체인 + BE + FE

컨트랙트와 문서의 불일치를 고친다. requestRedemption이 전에는 락업 중에도 패널티를 물리고 상환을 허용했는데(isLockupActive || isBeforeMaturity), LOCKED = 상환 불가07-redemption 3-상태 락업 모델과 모순이었다. 이제 LOCKED 동안(now < invested_at + lockup_days) requestRedemptionRedemptionNotAllowed로 리버트한다. create.ts와 FE 상환 경로 둘(포트폴리오 인라인과 RedeemModal)도 막는다. penalty_type별 수수료는 EARLY 구간(락업 종료 ≤ now < 만기)에만 적용된다. wind-down은 락업을 면제한다. 에스크로된 LP는 포트폴리오에서 "🔒 상환에 잠김"으로 보여 준다. ⚠️ PlatformPool은 clone 기반이라 새로 배포하는 풀 에 적용된다. 컨트랙트 테스트 306개 초록. CLAUDE.md Penalty Types 표와 12-triggers도 고쳤다.

문서: 14-decisions v3-76, 07-redemption. CLAUDE.md Penalty Types.

📅 2026년 7월 16일 — NO_EARLY ⇔ lockup_days=0을 온체인에서 강제 (v3-66)

🔒 setLockupDays가 NO_EARLY + 양수 락업을 거절한다

NO_EARLY는 t0부터 무패널티 이탈을 약속하고, 그건 정의상 락업이 없다 는 뜻이다(lockup_days = 0, v3-66). 락업은 실제 패널티 타입(FLAT_FEE / PRINCIPAL_BASED / YIELD_BASED)이 있을 때만 붙는다. 백엔드는 이미 NO_EARLY + lockup_days > 0을 거절했지만(pools.post.create) 온체인 세터는 아니어서, 팩토리 설정을 직접 호출 하면 규칙을 어기는 풀을 배포할 수 있었다. 이제 GovernanceLib.setLockupDaysnewLockupDays > 0이고 penaltyType == NO_EARLY이면 NoEarlyRequiresZeroLockup으로 리버트해서, 호출자가 누구든 불변식이 온체인에서 성립한다(다층 방어). 가드가 delegatecall 라이브러리에 있어서 PlatformPool 바이트코드 크기는 그대로다(19,480 B). 에러는 풀에 미러링했고 ABI를 재생성했다(에러만이고 셀렉터/시그니처 변경 없음). ⚠️ clone 기반(업그레이드 불가)이라 새로 배포하는 풀 에 적용된다. 컨트랙트 테스트 304개 초록.

문서: 24-field-governance §5. CLAUDE.md NO_EARLY 규칙(v3-66).

📅 2026년 7월 16일 — 수익 수수료 요율을 basis point로 통일 (마이그레이션 0075)

💰 net_yield_fee_config → bps

pools.net_yield_fee_config JSONB의 요율 키들, 즉 0068 비율→bps 통일 이후 마지막까지 percent로 남아 있던 것들이 basis point 로 옮겨 갔다. platform_yield_take_pct / spc_mgmt_pct / pool_mgmt_pct / perf_fee_pct / perf_hurdle_pct*_bps(×100. 1% = 100 bps). 마이그레이션 0075 가 JSONB 키와 값을 제자리에서 다시 쓴다. 계산(run-distribution.ts)은 ×pct/100×bps/10000으로 바꾼다. 성과 허들은 이제 apy_rate×100 > perf_hurdle_bps로 비교한다(apy_rate는 percent 컬럼 그대로다). 검증(pools.post.create / patch.update)은 정수 0..10000이다. FE(web + admin-web)는 bps로 저장/입력하고 formatBpsAsPercent로 표시한다. 리저브/패널티/게이팅과 같다. 온체인 변경 없다. 컨트랙트는 수수료 요율을 저장하지 않는다(0067). 수수료 요율을 percent로 남겨 두던 v3-73 예외를 뒤집는다.

문서: 24-field-governance §3, 14-decisions v3-73. CLAUDE.md 규칙 7 갱신.

📅 2026년 7월 15일 — treasury_wallet 타임락 제거, 즉시 세터로 (v3-69 구현)

💰 treasury_wallet → 즉시 세터

treasury_wallet(Aset 수수료 목적지) 변경이 이제 즉시 admin 세터 setTreasuryWallet(DEFAULT_ADMIN_ROLE)으로 적용된다. 7일 제안→실행→취소 타임락을 뺐고, 이는 수수료 쪽 지갑 변경은 즉시 적용된다 는 v3-69 결정의 구현이다. 수수료는 자동 분배되므로 대기는 잘못된 주소에 수수료를 묶어 둘 뿐이고 나쁜 지급을 막지는 못한다. 기존 setFundFeeWallet과 같은 모양이다. 컨트랙트: PlatformPool.setTreasuryWallet + GovernanceLib.setTreasuryWallet. 제안/실행/취소 함수, TreasuryChangeProposed/Cancelled 이벤트, 대기 중 treasury 스토리지를 제거했고 ABI를 재생성했다. API: POST /pools/{id}/governanceTREASURY를 즉시 적용한다(pool_governance_changes 행 없음, 실행/취소 없음). ⚠️ PlatformPool은 clone 기반(업그레이드 불가)이라 새로 배포하는 풀 에 적용된다. 기존 풀은 7일 treasury 타임락을 유지한다.

문서: 24-field-governance §1, 14-decisions v3-69.

📅 2026년 7월 14일 — KYB(법인) 온보딩 정책 확정 (v3-75)

🏢 v3-75 — KYB 법인 온보딩 정책

다국가 법률 벤치마크(SG·HK·JP·EU·UK·CH·UAE·TH·MY·ID. FATF + SumSub + 국가별 규제기관)로 KYB(법인) 온보딩 정책 을 확정했다. KYB는 뼈대만 있고 프로덕션 준비가 안 됐다. kyc_level enum과 SumSub KYB 레벨 맵은 있지만 FE 법인 제출은 VITE_KYB_ENABLED 뒤에 있고(꺼짐. SumSub Enterprise KYB 구독은 2026년 8월 예정) UBO 수집과 admin 법인 검토는 안 만들었다. v3-74와 별개 작업 흐름 으로 돈다(개인 Reg S 게이팅이 먼저 나간다). 확정된 정책: 대상 관할권 = 같은 KYC/Reg S 허용 목록(별도 목록 없음. ID는 별도 OJK 샌드박스 트랙). 법인 적격성 = 규제 대상/기관은 자동 PROFESSIONAL, 아니면 "대규모 사업체 3중 2"(20M/40M/2M. 통화는 관할권별. ADGM 자기자본 $1M). UBO = 자연인까지 추적하고 20% 이상 지분/의결권과 지배력을 수집 하되(20%는 MY 하한이고 25% 관할권을 덮는다) 관할권별로 판단 한다. 표준은 25% + 지배력 + 고위 임원 폴백이고, 일본은 단계식(단독 50% 초과 / 25% 초과 / 지배력 / 대표이사), 말레이시아는 20% 이며 관할권/위험별 설정으로 둔다. 서류 = 표준 세트 + 관할권 추가분(SG Bizfile, HK NAR1, JP 등기사항). 대표자 = 이사가 아닌 서명자는 이사회 결의나 위임장이 필요하다. SumSub 이 수집·UBO 매핑·스크리닝을 흡수하지만 최종 승인/거절과 책임은 Aset에 남는다. 임계 과 최종 출시 국가, "우리가 보고 의무 기관인가", ID의 DFA 대 증권 판단은 BD/법무 승인 대기다(값은 플레이스홀더로 나간다). 정책과 벤치마크만이고 이 항목에 코드/스키마 변경은 없다.

문서: 14-decisions v3-75, 03-kyc-identity → KYB. Notion "KYB 관할권 벤치마크 & 결정" / "KYB 활성화" / KYC/KYB 기획서.

📅 2026년 7월 14일 — investor_tier → investor_status + eligibility_mode (v3-74)

🪪 v3-74 — 적격투자자 게이팅(비미국 Reg S)

휴면 상태이던 미국 중심 investor_tier enum(ACCREDITED/QP)을 관할권 기반 모델로 대체하고 백엔드 쪽에서 구현한다(온체인 변경 없음). 마이그레이션 0072userspools에서 investor_tier를 빼고, users.investor_status(RETAIL/PROFESSIONAL)와 qualification_country/qualification_basis/qualification_verified_by/_at/_reason/qualification_expires_at(Status Gate 상태와 감사)와 pools.eligibility_mode(STATUS Status Gate / MIN_TICKET Ticket Gate)를 더한다. 기존 행은 RETAIL / MIN_TICKET으로 기본값을 잡는다(가장 덜 제한적). checkKycGating 이 이제 모드로 갈린다. STATUS는 허용 관할권에서 investor_status=PROFESSIONAL을 요구하고(qualification_expires_at 유효성 포함), MIN_TICKET은 amount ≥ min_investment를 강제하며, 기계가 읽을 수 있는 사유 코드를 돌려준다(NOT_KYC, KYC_EXPIRED, JURISDICTION_BLOCKED, US_PERSON, NOT_PROFESSIONAL, BELOW_MIN_TICKET). 최소 투자 하한은 assessDepositEligibility에서 게이트 안으로 옮겼다(검사 하나이고 BELOW_MIN_TICKET으로 드러난다). 새 admin 엔드포인트 PATCH /users/{id}/qualification 이 PROFESSIONAL을 부여/회수하고 감사 흔적을 남긴다. 국가 정책 임계값은 뼈대 설정(country-eligibility-policy.ts)에 있고 값은 플레이스홀더로 BD/법무 승인 대기 다. FE(web + admin-web)는 새 필드 이름으로 갱신했고, 풀 필터와 설정과 투자 사이드바를 Status/Ticket으로 다시 짰다. SumSub이 investor_status를 자동 채우는 건 2단계 항목이다(v1은 admin 수동).

문서: 14-decisions v3-74, 11-db-schema, 03-kyc-identity, 04-pool-models.

📅 2026년 7월 13일 — FE가 비율 설정을 percent로 보여 준다. 저장은 bps 그대로 (v3-73)

🔢 v3-73 — FE percent 표시(저장은 bps)

규칙 7은 모든 비율 값을 끝에서 끝까지 bps로 두고(FE 포함) percent↔bps 브리지를 금지했다. 이제 투자자에게 "1000 bps" 대신 percent를 보여 준다. FE가 reserve_bps / redemption_gating_bps / penalty_rate_bps를 표시 전용으로 percent로 렌더한다. 단방향 formatBpsAsPercent() 헬퍼를 쓴다(1000 → "10%", 250 → "2.5%"). 저장, 입력, API, 람다, 컨트랙트는 bps 그대로다. 마이그레이션 없고 ABI 변경 없고 계산도 안 건드렸다. admin 운영자 입력은 bps로 남는다(숫자 + isBps). 생성/수정에 실시간 "= X%" 힌트와 percent 리뷰 요약이 붙는다(admin-web FE. FE-UI-v3로 추적). 여전히 금지: 저장 / 입력 / 전송 계층의 양방향 percent↔bps 변환. 읽기 경로 포매터만 허용한다(정밀도는 무손실이다. percent 소수 2자리 상한 ↔ 정수 bps). 수수료 요율(*_pct)은 이미 percent였고 영향 없다. apps/web(위험 등급, 조기 패널티, 포트폴리오 배지, 투자 사이드바, 위험 고지, 개요)과 admin 리뷰 요약에 적용했다. CLAUDE.md 규칙 7 문구를 다시 썼다.

문서: 14-decisions v3-73, CLAUDE.md 규칙 7, Notion FE-UI-v3.

📅 2026년 7월 13일 — DPD/NPL 모델과 위험 배지의 NPL 신호 (v3-72)

📉 v3-72 — DPD/NPL 데이터 모델과 위험 배지

인턴의 동남아/OJK 조사로 Aset이 연체 데이터를 어떻게 받아들일지 확정했다. NPL과 누적 손실은 lifecycle 단계 둘이다. npl_threshold_days(펀드 보고, 기본 90)를 넘긴 대출은 부실이지만 아직 장부에 있고(선행, 미실현 신호다), 펀드의 write_off_policy(재량. 동남아 P2P는 약 180 DPD)를 넘기면 상각 돼서 실현 cumulative_loss 가 되고 NAV에 반영된다. DPD 구간은 동적 이다(펀드가 임계값을 보고한다). 위험 배지 는 이제 서로 다른 신호 셋을 쓴다. nav_per_token(실현), npl_ratio(선행, 신규/S2: 5% 초과 High, 2~5% Medium), 그리고 구조적 신호다. 앞서 적었던 "NPL은 NAV에 반영됨" 메모를 정정한다. cumulative_loss는 표시 전용이다. Aset은 대손 비율을 계산하지 않는다. 펀드 데이터: npl_threshold_dayswrite_off_policy를 더하고 sector_breakdown을 표시한다. dpd_bucket_standard / dpd_denominator / OJK collectibility_categories는 뺀다. DB(CH. 마이그레이션 0073 추가형 + 0074 cumulative_impairmentcumulative_loss rename. 0071/0072는 이미 쓰였다): 새 스냅샷 시계열 컬럼과 external_pool_dpd_buckets 자식 테이블과 pools 정적 설정. 문서 정정: 04-pool-models, 06-writedown-nav.

문서: 14-decisions v3-72, 04-pool-models, 06-writedown-nav.

📅 2026년 7월 13일 — 필드 거버넌스 표를 컨트랙트에 대고 정정 (v3-71)

🔎 v3-71 — 필드 거버넌스 표 정정

24-field-governance를 컨트랙트에 대고 훑으니 잘못 분류된 게 여럿 나왔다. min_investmentcapacity는 온체인에서 강제된다(오프체인 정책이 아니다). minInvestment에는 세터가 없고(불변 → §5), capacity/hardCap은 타임락 없이 올리는 것만 된다(§2). 그래서 기존 §6 C-lock은 이제 비었다. accepted_currencies는 불변이 아니다. addStablecoin/removeStablecoin은 풀에 LP가 없을 때만 되고 그 뒤엔 얼어붙는다(향후 정책: 추가만). "오라클 / 주변 주소" 행은 제거했다. 오라클은 좁은 범위에 갇힌 즉시 부여 가능한 역할 이고(자금을 못 옮긴다) 타임락 걸린 필드가 아니다. "주변"은 KYC 구현체를 가리켰다. perf_fee_pct는 제거했다(성과 수수료 미구현). 수익 청구 수령인 메모는 다시 썼다. 수령인 필드는 존재하지 않고 claimYield는 언제나 호출자 자신에게 지급한다. 낡은 kyc_level_required 행은 제거했다. 풀 단위 KYC/KYB 레벨 게이팅은 마이그레이션 0063에서 이미 빠졌다(풀은 관할권만 으로 게이트한다). accredited/QP(investor_tier) 게이팅은 별개의 백엔드 쪽 계획이다(KYC/KYB & investor-tier 명세 참조). 함께 문서화한 것: reserve_bps에는 방향 제약이 없다(7일 타임락으로 올리거나 내린다). 승인 대기(아직 코드에 없음): KYC 구현체 업그레이드를 즉시로 만드는 것(⚠️ 유일하게 남은 업그레이드 타임락을 없애므로 보안 승인이 필요하다), 그리고 NAV 24시간 인하 지연이 법적 요구인지 확인. 정정 자체는 문서만이다. 낡은 KYC 게이팅 문서(03-kyc-identity + 04-pool-models)도 맞춰 동기화했다. 레벨 게이팅 제거(kyc_level_required / requiresInstitutional / KYC_ONLY·KYB_ONLY·EITHER 전부 삭제), SBT의 미국인 여부는 국가에서 유도(별도 증명 플래그 없음), 그리고 실제 관할권 필드(enforce_jurisdiction / allows_us_persons)를 문서화했다. CLAUDE.mdkyc_level enum 값도 고쳤다(KYC/KYB가 아니라 INDIVIDUAL/INSTITUTION).

문서: 14-decisions v3-71, 24-field-governance, 03-kyc-identity, 04-pool-models.

📅 2026년 7월 13일 — DB와 컨트랙트의 필드 이름 다섯 개를 맞췄다 (값은 그대로, 이름만)

🔗 DB ⇄ 컨트랙트 이름 정렬

개념 다섯 개를 DB 컬럼, Solidity 구조체 필드, 앱 camelCase에서 이름 하나로 통일했다. 값과 의미는 안 바뀌고 이름만 바뀐다.

컨트랙트 구조체 필드 rename(구조체 멤버이지 함수/이벤트/에러가 아니므로 함수 셀렉터나 이벤트 토픽 변경 없음. ABI 재생성):

  • PoolConfig.hardCapcapacity(DB capacity와 맞춤). ⚠️ raiseHardCap / setHardCap 거버넌스 액션과 HardCapNotRaised 에러는 그대로 다. 설정 필드만 이름이 바뀌었다.
  • RedemptionConfig.flatFeeAmountpenaltyFeeAmount(DB penalty_fee_amount와 맞춤).
  • RedemptionConfig.noticePeriodDaysstandardRedemptionDays(DB standard_redemption_days와 맞춤).

DB 컬럼 rename(마이그레이션 0070. 컨트랙트는 이미 목표 이름을 쓰고 있었다):

  • pools.redemption_epoch_daysepoch_duration_days(온체인 epochDurationDays와 맞춤. 앱의 redemptionEpochDays/epochDaysepochDurationDays).
  • pools.kyc_jurisdiction_whitelistjurisdiction_whitelist(온체인 jurisdictionWhitelist와 맞춤. 앱의 kycJurisdictionWhitelistjurisdictionWhitelist).

타입이나 의미 변경 없고, 테이블/enum 수 변경도 없다.

문서: 11-db-schema, 08-smart-contracts, 04-pool-models, 07-redemption, 14-decisions.

📅 2026년 7월 13일 — 컨트랙트 `RedemptionConfig` 필드 `earlyRedemptionPenaltyBps` → `penaltyRateBps` rename

🔗 컨트랙트 필드 이름 통일

온체인 RedemptionConfig 구조체 필드 earlyRedemptionPenaltyBpspenaltyRateBps로 이름 바꿨다. 컨트랙트 필드 이름이 DB 컬럼 penalty_rate_bps와 맞도록 하는 이름 통일이고 값은 이미 bps였다. ABI를 재생성했다. 함수나 이벤트가 아니라 구조체 멤버라서 함수 셀렉터나 이벤트 토픽 변경은 없다.

문서: 08-smart-contracts, 14-decisions, 11-db-schema.

📅 2026년 7월 13일 — 조기 패널티 bps 컬럼 둘을 `penalty_rate_bps`로 통합 (마이그레이션 0069)

🔗 조기 패널티 컬럼 통합

중복이던 early_redemption_penalty_bpspenalty_rate_bps로 통합했다(마이그레이션 0069). early_redemption_penalty_bps는 제거했고, penalty_rate_bps가 이제 유일한 조기 상환 패널티 필드 이며 백엔드가 모든 패널티 타입에 대해 그것으로 컨트랙트의 온체인 RedemptionConfig.earlyRedemptionPenaltyBps를 채운다. 별도 admin 조기 패널티 입력은 뺐다. 온체인 변경 없고(컨트랙트 구조체 필드는 그대로) 테이블/enum 수 변경도 없다.

문서: 11-db-schema, 08-smart-contracts, 14-decisions.

📅 2026년 7월 13일 — 풀 비율 필드를 정수 basis point로 통일 (마이그레이션 0068)

🔢 bps 통일 — reserve_bps / redemption_gating_bps / penalty_rate_bps

pools의 비율 컬럼 셋을 percent와 분수가 섞인 NUMERIC에서 정수 basis point(bps) 로 이름과 타입을 바꿨다. 그러면 온체인 관례(amount × bps / 10000)와 맞아서 변환 다리가 필요 없다. 1% = 100 bps, 100% = 10000 bps.

  • reserve_percentage(percent, 예 10) → reserve_bps(INTEGER NOT NULL DEFAULT 1000, CHECK 0..10000). 옛 값은 ×100으로 이관.
  • redemption_gating_pct(percent) → redemption_gating_bps(INTEGER, nullable, CHECK null 또는 0..10000). 옛 값은 ×100으로 이관.
  • penalty_rate(0–1 분수, 예 0.50) → penalty_rate_bps(INTEGER, nullable, CHECK null 또는 0..10000, 예 5000). 옛 값은 ×10000으로 이관.

공식은 reserve = deposit × reserve_bps / 10000, penalty = base × penalty_rate_bps / 10000, 게이팅 상한 = redemption_gating_bps × totalDeposited / 10000이 된다. pool_governance_changes.change_type enum 값(RESERVE_BPS, REDEMPTION_GATING)은 그대로 이고 new_value만 이제 bps다. penalty_rate_bpsearly_redemption_penalty_bps는 이제 단위가 중복인데(마이그레이션 0069에서 통합했다. 위 항목 참조. early_redemption_penalty_bps를 제거하고 penalty_rate_bps가 단일 필드가 됐다) 여기서는 그대로 뒀다. 마이그레이션 0068_unify_ratio_fields_to_bps.sql. 온체인 변경 없음(컨트랙트는 이미 bps를 썼다).

문서: 11-db-schema, 04-pool-models, 24-field-governance, 07-redemption, 08-smart-contracts, 23-money-path.

📅 2026년 7월 10일 — money-path 명명 통일과 정본 23-money-path 문서 (v3-70)

🗺 v3-70 — money-path 명명 통일과 정본 문서

지갑과 은행계좌 용어를 자본 대 수수료 축 하나로 통일하고, 지갑과 그 법정통화 출구가 접두어를 공유하게 했다. 투자자 자본 = fund_walletfund_bank_account. FM Pool-mgmt 수수료 = fund_fee_wallet(v3-69, 대기) → fund_fee_bank_account. Aset 수수료 = treasury_wallet(정본이고 느슨하게 쓰던 "treasury"를 대체한다). 리저브는 지갑도 컨트랙트도 아니다. PlatformPool 안에 들어 있는 reserveBalance다("리저브 컨트랙트"는 없다. PlatformEscrow는 v3-11에서 제거됐다). pool_wallet은 폐기다(죽은 레거시 AS_POOL 컬럼). DB 마이그레이션도 온체인 변경도 필요 없도록 골랐다. 살아 있는 식별자가 이미 맞고, 펀드 데이터 시트(pool_bank_accountfund_bank_account, 옛 fund_bank_accountfund_fee_bank_account)와 문서 산문만 맞췄다. 새 정본 문서 23-money-path 가 흐름 셋과 온체인 ↔ 플랫폼 밖(OTC/법정통화) 경계를 모으고, 상환이 고정 주소 없이 역할로 게이트된다 는 것을 확인한다. 04와 09a에서 상호 링크를 걸었고 사이드바 Protocol 아래에 항목을 넣었다.

24-field-governance 도 추가한다. 거버넌스와 관련된 펀드/풀 필드마다 저장 위치(DB/온체인), 수정 가능성 분류, 수정 주체, 타임락을 한눈에 모은 내부 표다(값은 08 타임락 단일 출처를 상호 참조하고 복제하지 않는다).

문서: 14-decisions v3-70, 23-money-path, 24-field-governance, 04-pool-models, 09a-custody.

📅 2026년 7월 9일 — 수수료 구조. 수령인 셋, Pool mgmt는 분배 시 fund_wallet로 (v3-69)

💰 v3-69 — 수령인별 수수료 분할

BD가 내년 상장 상품을 위한 향후 수수료 설계를 확정했다(현재 FJO Aset 수수료는 0). 이제 수수료 수령인이 셋 이다. ① Aset(treasury) ← 플랫폼 yield-take + SPC mgmt(+ 성과). ② 펀드(별도 수수료 지갑)Pool mgmt(펀드매니저 수수료. 분배 시 별도 fund_fee_wallet 으로 바로 보낸다. 입금 money-path의 fund_wallet과 다르다. 딜별 요율은 DB pool_mgmt_pct에 있고, 수수료 금액은 지금처럼 오프체인(람다)에서 계산하며, 요율과 수수료 지갑 변경은 타임락 없이 즉시 적용된다. 수수료는 대기 없이 자동 분배되므로 입금 fund_wallet만 타임락을 유지한다). ③ 운영(오프체인. 투자자 수수료 아님) ← 투자자 소싱(유통 파트너)과 온/오프 램핑(Aset → 딜별 OTC). 온체인에서 필요한 변경은 withdrawFeesPool mgmt를 두 번째 고정 목적지, 즉 별도 fund_fee_wallet로 보내는 것 하나뿐이다(≠ 입금 fund_wallet). 수수료 금액은 오프체인(람다)에 남는다. 아직 미구현(CH 핸드오프). 입금 money-path(90/10, 불변)는 그대로이고 분배 단계만 바뀐다. DB: net_yield_fee_configadmin_fee_pctplatform_yield_take_pct로 바꾸고 mgmt를 spc_mgmt_pct(→Aset)와 pool_mgmt_pct(→ 별도 fund_fee_wallet)로 쪼갠다. 옛 진입 수수료 선취 설계는 폐기한다(소싱 = 운영).

문서: 14-decisions v3-69, 04-pool-models.

📅 2026년 7월 9일 — NAV 가드레일(서킷 브레이커 / 편차 상한 / 낡음 검사)은 운영용이고 투자자 UI가 없다 (v3-68)

🛡 v3-68 — NAV 가드레일에는 투자자 대상 표시가 없다

가드 커버리지 감사(#12)가 투자자 앱이 온체인 서킷 브레이커 / NAV 편차 상한 / 낡음 가드를 보여 줘야 하는지 물었다. 결정: 이것들은 운영용 이지 투자자 대상이 아니다. 온체인 NavLib._checkNavBound가 모든 updateNAV에서 서킷 브레이커와 양방향 편차 상한을 강제하고, RedemptionLib.executeEpoch가 정산 시점에 낡음을 강제한다. 백엔드는 이미 simulateUpdateNav(eth_call 드라이런)로 NAV 제안을 사전 점검하고 리버트를 깔끔한 4xx로 해석한다(가스 낭비 없고 DB 드리프트 없다. 상한과 낡음에는 온체인 게터가 없어서 시뮬레이트가 단일 출처다). 투자자 UI 없음: 브레이커 리셋과 상한 설정은 PAUSER/ADMIN 멀티시그 액션이고 투자자가 행동할 상태가 없다. 정산 낡음은 epoch 스케줄러의 epoch_circuit_breaker / epoch_execute_failed 운영 알림으로 드러난다. 투자자는 상각 배너와 NAV 이력으로 NAV 변동을 본다. 컨트랙트/DB/프론트 변경 없음. 이미 강제되던 불변식을 문서로 명확히 한 것이다.

문서: 14-decisions v3-68, 06-writedown-nav.

📅 2026년 7월 8일 — epoch 상환 게이팅 상한의 기준은 TVL이 아니라 `totalDeposited` (v3-67, 문서/주석 정정)

🚰 v3-67 — 게이팅 상한 기준 = totalDeposited(TVL 아님)

문서(04-pool-models, 07-redemption)와 컨트랙트 자신의 주석·테스트가 epoch 상환 게이팅 상한을 gatingBps × TVL로 적고 있었다. 실제 코드(RedemptionLib._epochFillRatio)는 gatingCap = totalDeposited × redemptionGatingBps / 10000을 쓴다. 라벨을 전부 고쳤다. 기준은 totalDeposited 이고, 그건 온체인 예치 자본 누적기(입금 + 재투자 수익 − 지급)이지 TVL(LP 공급량 × NAV)이 아니다. 상각이나 미분배 수익이 있으면 둘이 갈리므로, 게이팅은 시장가가 아니라 장부 자본을 잰다. 동작 변경 없음. 코드는 언제나 totalDeposited였고 문서와 주석과 테스트 주석만 고쳤다.

문서: 14-decisions v3-67, 04-pool-models, 07-redemption.

📅 2026년 7월 8일 — `NO_EARLY` = 락업 없음 (v3-66)

🔓 v3-66 — NO_EARLY는 락업 없음을 뜻한다

문서(04/05/07과 CLAUDE.md)가 penalty_type = NO_EARLY를 "조기 상환에 패널티가 없으므로 투자자가 락업 중에도 상환할 수 있다"로 설명했다. 패널티는 없지만 여전히 잠긴 구간이 있다는 뜻이 되고, NO_EARLY + LOCKED 배지 상태까지 나열했다. 정정한다. NO_EARLY ⟺ 락업 없음(lockup_days = 0). NO_EARLY 풀에는 LOCKED 상태가 없다. 투자자는 예치 직후부터 언제든 무패널티로 상환할 수 있다. 락업(lockup_days > 0)은 패널티 타입(FLAT_FEE / PRINCIPAL_BASED / YIELD_BASED)과만 짝을 이룬다. 기존 07-redemption의 예외 처리("lockup_days = 0이면 LOCKED 건너뜀")와 맞는다. 문서만 정정했고, 풀 생성/수정 검증에서 이 불변식을 강제하는 건 권고 후속이다.

문서: 14-decisions v3-66, 04-pool-models, 05-investment-lifecycle, 07-redemption.

📅 2026년 7월 7일 — Fund Data를 실제 컬럼에 붙였다. total_subscribed, 펀드 서비스 제공자, NPL 상각 (v3-65)

🗂 v3-65 — Fund Data를 실제 컬럼에 붙임

투자자 상세의 Fund Data를 리뷰하다가 뒷받침 컬럼이 없는 수치 하나와, Aset이 받지도 않는 전체 대출 장부 DPD 분포를 함의하는 연체 블록을 찾았다. 실제 데이터에 붙였다(마이그레이션 0054).

  • Total subscribedexternal_pool_data_snapshots.total_subscribed로 저장한다(펀드 보고 값이고 이미 펀드 데이터 요청 필드이자 summary.fund.totalSubscribed다). 동기화 시 스냅샷 행에 찍는다. 이력 API가 이걸 돌려준다.
  • 서비스 제공자 — 펀드 단위 새 테이블 fund_service_providers(감사인/관리인/수탁자/법률). funds POST/PATCH가 service_providers[]를 받고(전체 교체) GET /funds/{id}가 함께 돌려준다. admin 펀드 생성/수정에 제공자 편집기가 생기고, 투자자 Fund Data 탭이 읽기 전용으로 보여 준다(없으면 빈 상태).
  • 연체 = NPL/상각 — Fund Data의 "상각된 대출" 블록이 새 GET /pools/{id}/fund-data/writeoffsloan_writeoffs를 읽는다(전체 DPD 분포가 아니라 상각 처리된 NPL 건이다). 없으면 빈 상태다.
  • tsc -b 초록(infra/admin-web/web).

문서: 14-decisions v3-65, 11-db-schema.

📅 2026년 7월 6일 — 리프레시 토큰을 HttpOnly 쿠키로 옮겼다 (S-1)

🔒 S-1 — 리프레시 토큰을 HttpOnly 쿠키로(web + admin)

액세스와 리프레시 JWT가 localStorage에 있었으므로 XSS 하나로 30일짜리 리프레시 토큰을 훔쳐 세션을 탈취할 수 있었다. 액세스 토큰은 계속 Authorization 헤더를 쓰고, 리프레시 토큰은 이제 JS가 못 읽는 HttpOnly 쿠키에 산다.

  • 백엔드: /auth/verify(투자자)와 /auth/admin-login · /auth/admin-2fa/verify · /auth/admin-oauth(admin)가 리프레시 토큰을 바디가 아니라 Set-Cookie: refresh_token=…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=30d로 발급한다(lib/shared/auth/cookies.ts). /auth/refresh는 쿠키를 먼저 읽는다(배포 구간을 위해 바디 폴백을 둔다). 새 POST /auth/logout이 쿠키를 지우고(web + admin 로그아웃) admin/logout-all도 지운다.
  • CORS: 자격 증명 쿠키는 allowOrigins:'*'를 금지하므로, API Gateway CORS가 이제 web/admin 오리진 명시 허용 목록 + allowCredentials:true다(environment.ts corsAllowedOrigins). createResponse는 정적 '*'를 뺀다(HTTP API가 CORS를 소유한다).
  • 프론트엔드: 리프레시 토큰이 더는 localStorage에 없다. 비민감한 session_hint(만료 타임스탬프)가 리프레시 시도 여부를 가른다. withCredentials는 verify/refresh/logout에만 붙인다.
  • CSRF: 별도 토큰은 없다. SameSite=Lax가 교차 사이트 요청이 쿠키를 나르는 걸 막고, /auth/refresh 응답은 교차 오리진에서 읽을 수 없다.
  • 후속(별도 트랙): 리프레시 토큰 회전과 서버 회수, 액세스 토큰 만료 정렬, CSP 헤더.
📅 2026년 7월 4일 — 재투자는 같은 풀 전용. 다른 풀 재투자는 제거 (v3-64)

🔁 v3-64 — 재투자 = 같은 풀 전용(다른 풀 불가)

문서는 "다른 풀 재투자"를 미래 기능으로 적었고 포트폴리오 UI는 활성 풀을 전부 나열하는 "대상 풀" 드롭다운을 내보냈다. 풀 A의 수익을 풀 B에 재투자할 수 있다는 뜻이 된다. 구조적으로 불가능하다. 온체인 reinvest(uint256)은 그 풀의 accruedYield를 소비해 그 풀의 LP를 민팅한다(대상 파라미터가 없다). 핸들러도 주어진 풀 자신의 수익만 재투자한다.

  • 규칙: 재투자는 설계상 같은 풀 전용 이고 V2 로드맵 항목이 아니다. 수익을 다른 데로 옮기려면 claimYield()로 지갑에 받고 다른 풀에 deposit() 한다.
  • FE: 대상 풀 선택기와 다른 풀 갈래를 제거했다(portfolio.tsx). 재투자는 언제나 출처 풀을 대상으로 한다. tsc -b 초록.
  • 문서: 02-core-concepts / 05-investment-lifecycle 문구를 다시 썼다. 백엔드와 컨트랙트는 그대로다(이미 같은 풀 전용이었다).

문서: 14-decisions v3-64, 05-investment-lifecycle.

📅 2026년 7월 2일 — 리저브 회수를 홀더별이 아니라 풀 단위로 보여 준다 (v3-63)

📊 v3-63 — 회수율은 풀 단위다

투자자 상세의 리저브 회수 막대가 홀더별로 그려져 있었는데, wind-down에서는 같은 트랑슈의 모든 홀더가 같은 pro-rata 비율로 상환되므로 오해를 부른다. 풀 단위 회수 막대로 다시 짰다(nav_per_token 대 액면, reserve_percentage / nav_history.reserve_consumed). 여기에 보는 사람 자신의 예상 금액과 "모두 같은 비율로 회수합니다" 문구를 붙였다. 백엔드 변경 없음. ✅ 구현 완료(position-card.tsx, WIND_DOWN).

문서: 14-decisions v3-63, 07-redemption.

📅 2026년 7월 2일 — 숫자 / USD / 날짜 표시 관례 (v3-62)

🔢 v3-62 — 표시 서식 표준화

숫자는 tabular-nums에 소수 자릿수를 일관되게 두고 소수점을 정렬한다. USD는 카드에서 축약형($28.5M), 표·툴팁·상세에서는 전체($142,400)다. 날짜는 카운트다운이면 상대(15 days left), 고정 날짜면 절대(Jul 15, 2026)다. 공유 formatUsd()와 날짜 헬퍼를 SectionCard / StatCard 토큰과 함께 앱 전반에 적용했다. USD 전용(v3-24)은 유지한다. FE 표현만이고 스키마/컨트랙트 영향 없음.

문서: 14-decisions v3-62, 디자인 NOTES.md.

📅 2026년 7월 3일 — 투자자별 상한(`max_investment`)을 숨기고 컨트랙트 필드는 휴면으로 (v3-61)

🙈 v3-61 — max_investment 숨김(기본은 무제한)

max_investment(온체인 투자자별 누적 상한. 0이면 끔)를 생성/수정 폼에서 숨기고 언제나 0으로 보낸다. 폼에는 capacity만 남는다. 벤치마크 대비 비표준이고, 투자자별 통제는 KYC/적격성 게이팅의 몫이다. 컨트랙트 필드는 휴면으로 둔다(Clones는 불변이고 0은 no-op이라 재배포가 필요 없다). 나중에 고래 방지나 규제 한도가 필요하면 UI를 다시 붙이면 된다. 규제 적합성은 별도 법률 검토다.

문서: 14-decisions v3-61, 11-db-schema.

📅 2026년 7월 7일 — 국가 코드 정본 형식 = ISO alpha-3 (v3-60, alpha-2에서 뒤집음)

🌍 v3-60 — 정본 국가 코드 = alpha-3(뒤집음)

관할권 게이팅은 풀 허용 목록을 SBT countryCode와 비교한다. SumSub은 alpha-3(IDN/KOR)을 돌려주고 그대로 저장하므로, SBT와 온체인 허용 목록 검증은 이미 끝에서 끝까지 alpha-3다. 오프체인 admin UI와 kyc-gating.ts 비교와 컨트랙트 독스트링만 alpha-2를 가정하고 있어서, 게이팅을 켜면 조용히 전원을 거절했다. 결정(2026-07-07): ISO alpha-3를 정본으로 한다("KR"이 아니라 "KOR"). 7월 2일의 alpha-2 결정을 뒤집는다. alpha-2 경로는 끝내 안 나온 BE 정규화 계층이 필요했고, alpha-3가 이미 살아 있는 현실이다. BE 정규화는 안 넣는다. 대신 오프체인의 alpha-2 가정을 고친다. admin 허용 목록 UI 힌트를 alpha-3로, 생성/수정에 alpha-3 검증 추가, 08acountryCode 독스트링 정정, 남아 있는 레거시 alpha-2 행과 SBT 이관.

문서: 14-decisions v3-60, 04-pool-models, 03-kyc-identity, 08a-contract-reference.

📅 2026년 7월 3일 — DPD 연체 구간을 동적 모델로 일반화 (v3-59)

🗄️ v3-59 — DPD 구간을 동적으로(고정 30/60/90 → 관할권이 정의)

투자자 상세의 연체 수집이 30/60/90일 구간 을 하드코딩하고 있어서 다른 체계를 못 받았다(예: OJK 회수가능성 분류. 정상 / 요주의 / 고정 / 회수의문 / 추정손실을 90/120/180으로 나눈다). 펀드마다 자기 구간을 보고할 수 있게 데이터 모델을 일반화했다.

  • DB(안 A — 자식 테이블): pool_dpd_buckets(snapshot_id, lower_days, loan_count, exposure)로 구간마다 한 행. 위험 스냅샷에 dpd_bucket_standardnpl_threshold_days(기본 90)가 생기고 dpd_denominator는 선택이다.
  • FE: 변경 없음. 투자자 상세의 연체 렌더러가 이미 동적이라 구간 배열을 그대로 흡수한다.
  • 만들 이음매(🚧 To Do): 파트너 Google 시트("Data Mapping": 고정 객체 → 배열 + 새 필드 3개), DB 마이그레이션, 수집/API 매퍼(배열 수용, lower_days로 정렬, 검증). 온체인 영향 없음(DPD는 수집/표시 전용이고 NAV 파이프라인이 아니다). 인턴의 동남아/OJK 조사에 선행하는 작업이다.

문서: 14-decisions v3-59, 06-writedown-nav, 20-joob-pool-config, 11-db-schema.

📅 2026년 7월 2일 — 무권한 LP 전송. 허용 목록 게이트 제거 (v3-58)

🔓 v3-58 — LP 세컨더리 전송은 무권한이다

PlatformLPToken이 투자자 간 전송을 admin whitelist 매핑 뒤에 두고 있었다(양쪽이 허용 목록에 없으면 NotWhitelisted로 리버트). 그런데 어떤 백엔드도 setWhitelist를 부른 적이 없어서 매핑이 비어 있었고 세컨더리 전송이 사실상 전원 차단이었다. 5-상태 모델의 상태 C(전송으로 LP를 받은 미검증 홀더)와 모순이다. 게이트를 통째로 제거했다.

  • 검증은 가치 경계로 옮긴다(온체인에서 이미 그렇다). deposit() = requiresKYC, requestRedemption() / claimYield() = requiresRedeemableKyc. 지갑은 LP를 자유롭게 보유할 수 있지만(상태 C) KYC 전에는 입금/상환/청구를 못 한다(상태 A). "들어오는 가치는 게이트하되 나가는 가치는 절대 가두지 않는다"(v3-19).
  • 컨트랙트: whitelist / setWhitelist / NotWhitelisted / WhitelistUpdated_update의 검사 둘을 제거했다. _requireNotPaused()(admin이 세컨더리 전송을 여전히 긴급 동결할 수 있다)와 onLpTransfer 수익 정산은 유지했다.
  • 테스트: 291/291 초록(허용 목록 전용 테스트 3개 제거, 리버트 테스트 2개를 무권한 통과 테스트로 전환, 스위트 6개의 셋업 setWhitelist 제거). 백엔드나 앱에서 허용 목록을 참조하는 곳은 없었다.
  • 소급 안 됨: clone은 불변이라, 새로 만드는 풀에 적용하려면 새 LP 구현체와 팩토리 재배포가 필요하다.

문서: 14-decisions v3-58, 01-overview, 08-smart-contracts, 08a-contract-reference, 21-holder-verification, 05-investment-lifecycle.

📅 2026년 7월 2일 — Pool Updates(WO-6) RBAC 공식화와 Operator 옵트인 (v3-57)

📣 v3-57 — Pool Updates / 공지 권한

v3-22 / WO-6 공지 피드는 나갔는데(마이그레이션 0012. POST /pools/{id}/updates, GET /pool-updates, PATCH/DELETE /pool-updates/{id}) RBAC가 코드에만 있었다. 공식화하고 동작 하나를 바꿨다.

  • 생성 / 수정 / 삭제 = SUPER_ADMIN, ADMIN, FUND_MANAGER, 그리고 admin이 부여하는 pools 페이지 권한을 통한 OPERATOR 옵트인(전에는 Operator 완전 배제). pool-updates.post.create / .patch.update / .delete가 이제 OPERATORrequirePagePermission('pools')를 더한다.
  • FM 은 자기 펀드 풀만이고 수정/삭제는 자기 글만 이다. Admin / Operator / Super Admin은 아무 항목이나 된다.
  • 분류는 INFO(피드만) / IMPORTANT / MATERIAL_EVENT(발행 시 홀더에게 이메일)이다. FM의 중대 사건 발송 전에 admin 검토 게이트는 없다.
  • 삭제는 소프트만(deleted_at으로 보존하고 하드 삭제는 없다). MATERIAL_EVENT 수정은 pool_update_revisions에 스냅샷을 남긴다. DRAFT 풀에는 공지를 못 쓴다.
  • 문서: 09-rbac → Pool Updates, 14-decisions v3-57. infra tsc 초록.
📅 2026년 7월 2일 — 전액 기준 LP 민팅. 리저브 분할이 더는 투자자 청구권을 희석하지 않는다 (v3-56)

🪙 v3-56 — deposit()/reinvest()가 전액 기준으로 LP를 민팅한다

첫 컨트랙트(2026-02-03) 이후로 LP는 리저브를 뺀 금액 으로 민팅됐다((amount − reserve) / nav). 리저브 10%인 풀에 $100을 넣으면 90 LP만 민팅돼 투자자의 상환 청구권이 조용히 깎였고, 리저브가 개인별로 되돌아온 적은 없다(WIND_DOWN의 pro-rata뿐이었다). 이건 구현 관성이었지 기록된 결정이 아니었다. 이제 lpAmount = amount × NAV_PRECISION / nav이고 리저브와 LP는 독립 이다.

  • 자금 흐름은 그대로다. 리저브는 여전히 reserveBalance에 남고 잔액은 여전히 fund_wallet로 나간다(10/90 분할, v3-11). 바뀐 건 청구권 회계뿐이다.
  • 백엔드 미러도 맞췄다. calculateLpTokens()(입금 교차 확인과 재투자 DB 적립. 온체인과 일치해야 한다)를 같은 변경에서 갱신했다. 테스트 26개를 고쳐 295/295 초록.
  • 소급 안 됨: clone은 불변이라 이전 팩토리로 만든 풀은 리저브 제외 민팅을 유지한다. 옛 Sepolia 테스트 풀은 아카이브하거나 다시 만들어야 한다. 그 풀에 재투자하면 reserve_percentage만큼 과다 적립된다.
  • 2026-07-02 재배포: 새 PoolImpl 0x2dB0…19C2 + Factory 0x8D28…40a1(KYC 프록시와 LP 구현체는 재사용). apps/contract/sepolia.md 참조.

문서: 14-decisions v3-56, 05-investment-lifecycle(④단계 공식).

📅 2026년 7월 1일 — 문서와 코드 동기화 (결정 없음. 낡은 문서 정정)

🧹 문서 동기화 — 명세를 배포된 코드에 맞춘다

문서와 코드를 전면 감사했더니 명세가 구현보다 뒤처졌거나 미뤄 둔 작업을 현재형으로 설명한 문장들이 나왔다. 고쳤고 아키텍처 변경은 없다.

  • 01-overview 이관 현황 — "📋 명세만"에서 구현됨으로 뒤집었다(컨트랙트 ✅ 명세와 일치, DB ✅ 마이그레이션 0043까지, 람다 약 128개 ✅, FE 둘 다 ✅). 남은 건 배포와 QA다.
  • 11-db-schema 수치 — 머리말과 꼬리말을 테이블 40개 · enum 26개 · 최신 마이그레이션 0043 으로 고쳤다(각각 39/40 · 27 · 0040이었다). 2026-07-01 확인. 표 본문은 이미 정확했다.
  • 유령 함수 이름 — 존재하지 않는 redeem()(08a, 10)과 Pool.executeRedemption()(07) 참조를 뺐다. 실제 경로는 requestRedemption → 청구, fundRedemption 자동 완료, claimRedemptionFallback이다.
  • 낡은 "🚧 개발 중" 배너를 완화03-kyc-identity(SumSub / 풀별 게이팅 / 재사용 전부 구현됨)와 21-holder-verification(해석기와 출처는 구현됐고 상태 C 생산자는 아직 대기). 09-rbac의 읽기 권한은 구현됨으로 표시했다(resolveReadScope).
  • 04-pool-modelsis_emergency_frozen을 구현됨으로 표시했다. maturity_model 값은 OPEN_ENDED로 명확히 했다("REVOLVING"은 표시 전용이고 보내면 400이 난다).
  • 미뤄 둔 작업에 라벨 — 자동 DPD→OJK NAV 상각(v3-13)을 06-writedown-nav20-joob-pool-config에서 🚧 미뤄짐/미구현으로 표시했다(Joob의 대출별 데이터에 막혀 있고 지금 NAV는 수동이다). wind-down의 "60+30일"은 10-status-machines에서 명확히 했다. 강제되는 건 30일 온체인 타임락뿐이다.
  • CLAUDE.md 사용자 흐름 — 투자/재투자를 원자적 platform-issued LP로 고쳤고(Model A/B 없음), 상환을 단일 승인으로 고쳤으며(recommend/complete 없음), 재투자 엔드포인트를 POST /yield/reinvest로 고쳤다.
📅 2026년 7월 1일 — FM 지갑 서명 강화. fund_wallet 노출 + 클라이언트 서명 강제 + 바인딩된 지갑 표시 (v3-54)

🛄 v3-54 — FM 지갑 서명 강화(W8/W9)

dev의 fund_wallet == adminWallet shim을 제거하기 전에 v3-53이 남긴 빈틈 셋을 닫는다(Notion W8a/W8b/W9). 컨트랙트 변경 0, 마이그레이션 0.

  • W8a — FM에게 fund_wallet 노출: 펀드 지갑 전체 표시(주소 + 익스플로러 + 복사)가 Admin 전용(Controls 탭)이었다. 공유 FundWalletDisplay로 빼고 Overview의 "Pool Config Summary" 에 읽기 전용 행으로 넣었다(모든 역할). API 변경 없음.
  • W8b — 조용한 서버 키 폴백 없음: depositYield / fundRedemption 라우트가 이제 풀에 fund_wallet이 있으면 언제나 클라이언트 서명을 요구한다. 맞는 지갑이 연결돼 있지 않으면 명시적 오류로 액션을 막고 절대 서버 키를 쓰지 않는다. 계획했던 fund_wallet == adminWallet 분기보다 단순해졌다(admin-web에는 admin 지갑 주소가 없다). dev/admin의 서버 키 편의는 버린다. dev는 fund_wallet을 직접 연결한다. FE만.
  • W9 — 바인딩된 지갑 표시와 재바인딩:GET /admin/me/wallets(자기 지갑만. auth.sub로 범위). My Settings가 검증된 지갑을 나열하고 연결/재검증(재바인딩)을 제공한다. 실제 온체인 fund_wallet 변경은 admin 거버넌스(7일 타임락) 로 남는다. FM은 요청하라는 안내를 받고 FM이 직접 실행하는 거버넌스는 없다.

배포 순서는 그대로다. W8 병합 → dev shim 제거 → 실제 fund_wallet. 문서: 14-decisions v3-54, 15-api-reference.

2026년 6월 40 entries

📅 2026년 6월 30일 — 파트너 펀드 데이터 지표를 자산군 중립적인 이름으로 변경 (v3-55)

🏷 v3-55 — total_rni/total_npl → realized_income/cumulative_impairment

external_pool_data_snapshots의 파트너 펀드 데이터 지표 둘을 자산군 중립적인 이름으로 바꿨다. total_rni("Realized Net Income")는 신용 특화였고 "Net"이라는 라벨도 틀렸다(값이 수수료 차감 전 총액 이다) → realized_income. total_npl("Non-Performing Loans", 90일 연체 기준의 대출 프레이밍) → cumulative_impairment 로 바꿔서 비신용 펀드(부동산 / 매출채권 / 재간접)도 깔끔히 매핑되게 했다.

  • 호환성 유지: 수집과 표시 전용이고 NAV(DPD 기반, v3-13/14)나 온체인 호출에는 안 쓴다.
  • 파트너 영향 없음: Joob의 전송 필드 totalRni/totalNpl은 그대로다. 제공자 매퍼가 파트너 명명 → 우리 정본 컬럼을 잇는 이음매다.
  • DB 컬럼, 도메인 타입, API 응답, FE(라벨 "Realized Income" / "Cumulative Impairment")에 걸쳐 이름을 바꿨다. 마이그레이션 0043(RENAME COLUMN). 파트너 입력 시트 문구는 별도로 갱신했다("Net" 정정 포함).

문서: 14-decisions v3-55, 11-db-schema.

📅 2026년 6월 30일 — FM 지갑 서명. depositYield / fundRedemption을 클라이언트가 서명 (v3-53)

🛄 v3-53 — FM이 자기 지갑에서 money-in에 서명한다(비수탁)

컨트랙트가 파트너에게 게이트하는 온체인 함수 둘, 즉 depositYieldfundRedemption(YIELD_DEPOSITOR_ROLE)을 dev의 fund_wallet == adminWallet shim을 통해 Aset 서버 키가 서명하고 있었다(수탁이고 프로덕션에서 깨진다). v3-53은 둘을 FM 자기 지갑에서 하는 FM 클라이언트 서명 으로 옮긴다(모델 A: 연결된 지갑이 풀의 fundWallet이고 컨트랙트 변경 0).

  • FM 지갑 신원(B3): FM이 SIWE로 소유를 증명하고, 증명된 지갑은 새 admin_user_wallets에 저장된다(마이그레이션 0041. FM 하나에 지갑 N개). 인증의 단일 출처는 온체인에 남는다(msg.sender == pool.fund_wallet). 테이블은 사전 서명 가드이자 표시이자 감사 캐시다. 바인딩은 세션이 없다(POST /admin/wallet/nonce + /verify, JWT 없음). Google OAuth 세션은 안 건드린다.
  • 수익 분리: POST /yield-distributions에 새 deposit_tx_hash(마이그레이션 0042)를 넣으면 행이 PROCESSING으로 생성된다(안 쓰던 yield_status 값을 재사용하므로 enum 변경 없음). 새 POST /yield-distributions/{id}/distribute가 예치를 온체인에서 확인한 뒤 서버 키로 distributeYieldwithdrawFees를 돌린다(공유 run-distribution.ts). 레거시/dev 경로는 그대로다.
  • 상환 분리: FM이 fundRedemption에 서명하고, POST /redemption-requests/{id}/record-funding은 tx를 기록만 한다(funding_status='PENDING_FUND_CONFIRMATION'. 마이그레이션 없음). 정산은 인덱서가 RedemptionCompletedcomplete_redemption_atomic으로 한다(/complete 엔드포인트는 기각했다. 인덱서와 경쟁하게 된다).
  • FE(admin-web): wagmi/RainbowKit 프로바이더, SIWE 바인딩 UI, use-deposit-yield-flow/use-fund-redemption-flow(ERC20 approve→action 2단계 tx)와 use-network-guard, fund_wallet 전체 표시(익스플로러 + 복사), 풀 생성의 fund_wallet 입력, FM 가시성 게이트 제거.

1→3단계로 나눠서 한다. 컨트랙트: 변경 0. 배포: pnpm cdk:deploy와 admin-web 배포, 마이그레이션 0041/0042(dev 적용), 그리고 순서대로 fund_wallet==adminWallet dev shim 제거. 후속: Safe 비동기 실행(지금은 EOA 우선), 풀별 서명 딥링크.

문서: 14-decisions v3-53, fm-wallet-signing-spec.

📅 2026년 6월 29일 — 투자자 표시 이름 = KYC 법적 이름, 그리고 SumSub 백필 (v3-52)

🪪 v3-52 — 투자자 표시 이름(KYC 법적 이름, 수정 불가)과 백필

설정 페이지가 모두에게 "Anonymous Investor"를 보여 주고 있었다. 마이그레이션 0040이 users.first_name/last_name을 넣었고 apply-review(GREEN)가 이제 SumSub이 검증한 이름을 미러링한다. 결정(안 A): 표시 이름은 KYC 법적 이름이고 투자자가 수정할 수 없다. 별칭이나 display_name 컬럼은 없다. 근거: WO-4에 따라 펀드매니저가 KYC/AML을 위해 투자자 이름과 지갑을 보므로, 자기가 정한 별칭은 그 컴플라이언스 가치를 무력화한다. 설정은 읽기 전용으로 렌더한다.

  • 백필 — 기존 승인 투자자(0040 이전에 KYC한 사람)는 이름이 NULL이라 여전히 "Anonymous"다. kyc.scheduler.reconcile-sweepbackfillMissingNames()가 생긴다. APPROVED이고 sumsub_applicant_id가 있고 이름 필드가 전부 NULL이면 → getApplicantInfofirst_name/last_name을 채운다(KYB면 company_name). 재인증 없고 새 개인정보도 없다(SumSub이 이미 쥔 데이터를 미러링한다). 스스로 소진되고 실행당 25건이다.

새 마이그레이션 없음(0040에 얹는다). 배포: pnpm cdk:deploy와 마이그레이션 0040 적용. 그전까지는 설계대로 이름이 NULL로 남는다.

문서: 14-decisions v3-52.

📅 2026년 6월 29일 — Admin이 Admin을 관리할 수 있다 (v3-47 개정)

🔑 v3-47 개정 — ADMIN이 다른 ADMIN의 이름을 수정하고 삭제할 수 있다

PM이 확인한 완화다. 이제 ADMIN(SUPER_ADMIN만이 아니라)이 다른 ADMIN의 이름을 수정하고 삭제할 수 있고 Operator와 FM도 마찬가지다. SUPER_ADMIN은 계속 보호되고(절대 삭제 불가) 마지막 Admin 가드도 남는다. 원래 v3-47은 BE가 이걸 강제한다고 적었는데, DELETE / PATCH /admin-users/{id}가 대상 검사 없는 withRole('SUPER_ADMIN')이었다(ADMIN은 403을 받았다). 이제 withRole('SUPER_ADMIN','ADMIN')에 서버 쪽 대상 가드를 붙여서(SUPER_ADMIN 대상 차단, SUPER_ADMIN 역할 부여 차단, 마지막 Admin 차단) 완화된 FE 규칙을 API로 우회할 수 없다.

문서: 14-decisions v3-47.

📅 2026년 6월 29일 — 수익 대사기. 정확한 claimable_yield(미청구 + 적립) (v3-51)

🔁 v3-51 — 수익 대사기(claimable_yield = 온체인 pendingYield)

앱의 "청구 가능한 수익"이 portfolio_positions.accrued_yield(admin이 분배한 미러)만 읽고 있었는데, 온체인에서 직접 분배·청구하거나 LP를 전송하면 온체인 진실과 갈라진다. 컨트랙트는 이미 pendingYield(investor)(정산분 + 미정산분 합계)를 노출하는데 아무도 되읽지 않았다(#7).

  • portfolio_positions.claimable_yieldclaimable_yield_synced_at(마이그레이션 0038) — 대사된 온체인 합계이고 사람이 읽는 USD다. synced_at이 NULL이면 FE가 accrued_yield로 폴백한다.
  • yield.scheduler.reconcile(시간별) — 풀 홀더마다 pendingYield(holder)를 읽어 claimable_yield를 upsert한다. 온체인은 읽기만 하고 DB에만 쓴다.
  • YieldClaimed 인덱서(writeYieldClaimed) — 온체인에서 직접 청구한 경우 claimable을 갱신하고 yield_claims 원장을 정산한다.

BE와 인덱서만이고 컨트랙트 변경 없음. FE 표시 배선(claimable_yield ?? accrued_yield)은 후속이다. 배포: pnpm cdk:deploy(새 스케줄러와 인덱서 이벤트)와 마이그레이션 0038.

문서: 14-decisions v3-51.

📅 2026년 6월 29일 — 트랑슈 손실 워터폴. BE 기능(손실만) 구현과 생성 흐름 방식 A (v3-50)

🥪 v3-50 — 트랑슈 손실 워터폴(BE 기능)

v3-14v3-39가 트랑슈와 오프체인 신뢰 모델을 정의했는데 손실 워터폴 계산은 한 번도 안 만들어졌다. nav-changes.post.activate.ts대상 풀 자신의 NAV만 표시한다. 그룹 조회도, Junior 우선 흡수도, Junior→IMPAIRED도 없다. 그러니 트랑슈 풀은 "이름만 존재"했다(생성 시 그룹 제약 검증은 만들어져 있다. pools.post.create.ts). Senior 트랑슈 딜이 확정돼 오고 있어서 BE를 지금 끝내기로 했다. 컨트랙트 영향은 0 이라(람다가 풀별 NAV를 계산해 각각 updateNAV()를 부른다) 감사 임계 경로 밖이다.

  • 범위는 손실 워터폴만(Junior→Mezz→Senior)과 Junior→IMPAIRED(v3-12)다. 수익/이익 워터폴은 미룬다. Senior APY 우선이 필요한 딜이 나올 때까지.
  • 역할(tranche_role/tranche_group_id)은 풀 생성 때만 명시적으로 정하고 그 뒤엔 불변이다(지금은 수정 경로가 없다. 입금이 하나라도 있으면 완전히 얼어붙는다). Senior APY가 Junior APY 이상이면 부드럽게 경고한다.
  • 생성 흐름 = 방식 A(점진적): 풀 생성에 Standalone/Tranche 토글이 생기고 → 그룹(신규/기존)과 역할 라디오(이미 쓰인 역할은 비활성)와 그룹에서 잠긴 공유 필드가 붙는다. 한 번에 다 만드는 위저드 대신 이걸 골랐다. BE가 이미 풀별 생성과 검증을 지원하고 풀마다 자기 온체인 배포가 있기 때문이다(원자적 다중 배포는 취약하다).
  • 완결성 가드: 그룹은 방패(Senior + Junior 이상)가 갖춰지기 전까지 입금을 못 받는다. 미완성 그룹은 비-ACTIVE로 둔다.
  • Junior 리테일 게이팅은 FE에서 숨기는 것뿐이고 딜별이다. 온체인 허용 목록은 없다(컨트랙트 변경 0을 깨뜨린다).
  • 범위 밖: 수익 워터폴 · 온체인 허용 목록 · 풀 수정 검증. (특정 Junior 풀을 투자자 앱에서 숨기는 건 딜별로 남는다.)

구현: 순수 엔진 lib/shared/tranche/loss-waterfall.ts와 테스트 → 그룹 상각 진입점이 기존 풀별 제안→타임락→활성화→applyPendingNavOnChain 경로로 팬아웃 → IMPAIRED 트리거 → 가드. cdk:deploy가 필요하다(사용자). money-path는 QA로. 스마트 컨트랙트 변경 0.

FE(완료):

  • admin 생성 흐름 — 풀 생성 3단계에 Standalone/Tranche 토글 → 그룹(새 UUID 또는 기존 연결)과 역할 라디오(이미 쓰인 역할 비활성), 펀드/만기 자동 잠금, Senior APY ≥ Junior 부드러운 경고.
  • admin 풀 목록Tranche · {역할} 알약과 방패가 빠진 그룹에 대한 앰버색 "Incomplete · Senior/Junior 필요" 배지.
  • 투자자(#18/#20) — 풀 카드와 상세 헤더에 Senior/Junior/Mezz 알약, 위험 고지에 트랑슈 행, 그리고 위험 등급이 이제 v3-25에 따라 tranche_role을 읽는다(Junior→High. Senior와 Mezz→Medium. 독립 풀은 Low 유지).
  • 두 앱의 데이터 계층(PoolRow/PoolView/CreatePoolPayload)이 tranche_group_id/tranche_role을 나른다.

진짜로 딜별로 남은 항목은 하나다. 특정 Junior 풀을 투자자 앱에서 숨기는 것.

문서: 14-decisions v3-50.

📅 2026년 6월 26일 — FM 수익 분배 도래와 연체 알림 (v3-49)

🔔 v3-49 — 수익 분배 도래 / 연체 알림(FM/운영)

알림 목록에 yield_distributed(분배 후)는 있는데 예정된 분배가 도래했다는 알림이 없었고 연체 에스컬레이션도 없었다. 그래서 FM이 수동 트리거 주기(v3-20)를 조용히 놓칠 수 있었다. 기존 일일 pools.scheduler.yield-due 스윕이 만드는 이벤트 둘을 더했다(ACTIVE 풀만).

  • yield_distribution_duenext_yield_due → 연체 전이에서 한 번 발동한다("분배할 때다").
  • yield_distribution_overdue — 도래 후 3일 넘게 여전히 미기록이면 에스컬레이션한다. 재발송은 최대 7일 간격이다.

스키마 변경 없이 멱등성을 확보한다. dueyield_overdue의 false→true 전이에 얹는다(주기당 한 번). overdue는 최근 행 조회를 쓴다(related_entity_id가 UUID라 dedupByEntity를 쓰면 영원히 중복 제거된다). 수신자는 FM + ADMIN이다(recipient_id는 풀이고 FM은 자기 펀드로 범위가 잡힌다). 새 람다나 CDK 없이 기존 cron에 얹고, 배포 게이트는 v3-44/45와 같다. FM V1 알림 범위를 넓힌다. 문구 시트와 Notion Yield 표에 새 행 둘이 필요하다.

문서: 14-decisions v3-49.

📅 2026년 6월 26일 — BE-FE 갭 감사. 펀드 멤버 수정과 NAV 제안 배선, retry-lp-mint 비활성화 (v3-48)

🛠 v3-48 — BE-FE 갭 감사 마무리

v3-47의 후속으로 admin-web에서 "BE는 됐는데 FE가 없거나 닿을 수 없는" 빈틈을 감사했다(클라이언트 함수와 훅을 grep해서 컴포넌트 사용과 대조).

  • 펀드 멤버 수정(순수 FE) — PATCH /fund-members/{id}useUpdateFundMember가 안 쓰이고 있었다. fund-detail.tsx에 행별 수정 모달(이름 / 지갑 / 대표)을 넣었다.
  • NAV 수동 제안(순수 FE) — POST /nav-changesuseProposeNavChange가 안 쓰이고 있었다. NAV 패널에 "Propose NAV Change" 폼을 넣었다(source: admin_override, 인하 시 24시간 타임락 안내, yield 권한 게이트).
  • retry-lp-mint(✅ 제거) — deposits.tsx / dashboard.tsx의 재시도 버튼이 존재하지 않는 POST /deposits/{id}/retry-lp-mint를 불렀다(404). 재시도 UI와 retryLpMint 함수와 useRetryLpMint 훅을 제거했다. LP는 투자자 자신의 원자적 deposit() tx에서 민팅되므로(비수탁) admin 재민팅이 불가능하다. 진짜로 재시도할 수 있는 민팅은 SBT 경로다(kyc.post.mint-sbt. 유지). 실패한 입금은 View CTA와 함께 계속 보인다.

같이 표시했지만 안 고친 것: BE가 필요한 스텁과 죽은 코드(triggerAutoRedemption, 안 쓰는 createAdminUser/createFundMember). 추적: FE-UI-v3 "🔎 BE-FE 갭 감사".

문서: 14-decisions v3-48.

📅 2026년 6월 26일 — admin 사용자 수정(이름)과 삭제를 모든 역할에서 닿을 수 있게 (v3-47)

🛠 v3-47 — admin 사용자 관리. 모든 행에서 이름 수정과 삭제에 닿는다

QA가 admin-settings → Admin Users에서 빈틈 둘을 짚었다. (1) 이름 수정 UI가 없어서 초대된 admin이 빈 이름에 갇혔고, (2) 삭제가 Operator 행에서만 닿았다(삭제 버튼이 Operator 전용 권한 모달 안에 있었고 ADMIN / FUND_MANAGER 행에는 조작할 컨트롤이 없었다). 백엔드(PATCH / DELETE /admin-users/{id}, useUpdateAdminUser)는 이미 있었다. 순수 FE 빈틈이었고 FE-UI-v3에 따로 추적되지 않았다(#13/A-9는 완료로 표시돼 있었다).

  • 이제 모든 행이 "Manage User" 모달을 연다(전에는 Operator 전용).
  • 권한이 있으면 이름 수정 가능, 없으면 읽기 전용이다. 권한 = 삭제 규칙(안 A): Admin이 Operator/FM을 수정하고, Admin은 Super Admin만 수정할 수 있으며, Super Admin은 아무도 수정 못 한다.
  • 삭제를 같은 모달로 통합했다(더는 Operator 전용이 아니다).

문서: 14-decisions v3-47, 09-rbac → 권한 표.

📅 2026년 6월 26일 — 상환 모델(instant 대 epoch)은 게이팅 선택이고 만기와 직교임을 명확히 (v3-46)

🔁 v3-46 — instant 대 epoch는 게이팅 구조 선택이지 만기 속성이 아니다

설계 리뷰 질문 하나("FIXED_TERM 풀도 만기 전 조기 이탈로 뱅크런이 날 수 있는데 왜 epoch가 REVOLVING에 묶여 있나")가 v3-26 epoch 프레이밍이 상환 모델을 maturity_model에 과도하게 결합시켰다는 걸 드러냈다. 명확히 한다(스키마 변경 없음).

  • redemption_epoch_daysmaturity_model과 직교 이고 검증 결합이 없다(pools.post.create는 0–90 범위만 본다). 타입 기반 기본값(FIXED_TERM → 0, REVOLVING → epoch)은 미리 채우기이지 제약이 아니다. FIXED_TERM 풀도 > 0으로 만들 수 있다.
  • 선택 축은 뱅크런 위험("동시 이탈 수요가 가용 유동성을 불공정하게 넘을 수 있나")이지 풀에 만기가 있는지가 아니다. REVOLVING은 언제나 노출돼 있다(epoch가 기본선). FIXED_TERM은 수요가 계획된 만기에 몰리고 조기 이탈 꼬리는 드물고 패널티로 게이트된다(instant로 충분하다) → 집중되거나 뱅크런 위험이 있거나 상각 위험이 있는 풀만 epoch를 택한다.
  • 한계: 운영 중에는 여전히 불변이다(v3-38). 생성 때 고른다. epoch는 압박 상황에서 이탈을 공정하게 만들 뿐 뱅크런을 막지 않는다(유동성이 모자라면 pro-rata 부분 충당이다). 정지는 is_paused / IMPAIRED / is_emergency_frozen이고, 유출 속도 상한은 redemption_gating_pct다(지금은 epoch 전용).

문서: 14-decisions v3-46, 07 → 왜 instant가 아니라 epoch인가, 04 → redemption_epoch_days.

📅 2026년 6월 25일 — 투자자 이메일 등록과 인증(알림 이메일 채널) (v3-45)

📧 v3-45 — 투자자 이메일 수집과 인증

지갑 로그인 투자자는 가입 때 합성 이메일(<주소>@wallet.aset.io)을 받으므로 알림 이메일 채널이 동작하지 않았다(admin과 FM은 초대에서 실제 이메일을 받는다). 그런데 법적으로 요구되는 중대 통지(NAV 상각 / 부실 / 청산)에는 실제로 전달되는 채널이 필요하다. 인앱만으로는 법적 "통지"가 아니다. 그래서 투자자가 이메일을 등록하고 인증한다.

  • 마이그레이션 0033: users.email_verified_at / pending_email / email_verification_token / email_verification_expires_at.
  • GET /users/me — 투자자 자기 프로필(이메일과 인증 상태). FE는 이미 부르고 있었는데 BE 핸들러가 없었다(admin 전용 /users/{id}me를 거절했다).
  • POST /users/me/email — 대기 이메일과 24시간 토큰을 설정하고 SES로 인증 링크를 보낸다.
  • POST /auth/verify-email — 공개이고 토큰으로 게이트한다. 인증을 완료한다(email = pending_email, email_verified_at = now).
  • 발송 워커 가 합성 @wallet.aset.io 주소를 건너뛴다(인앱 행은 그대로 전달된다).
  • web: 설정에 "알림용 이메일" 절과 /verify-email 라우트.

⚠️ 법무에 달린 후속: (1) 투자 전에 인증된 이메일을 요구할 것인가? (2) 중대 통지에 증명 가능한 전달(SES 전달 이벤트/SNS)이 필요한가? 중대 통지의 기준과 시점에 대한 열린 질문과 묶어서 본다.

문서: 11-db-schema users, 14-decisions v3-45, 새 알림 시스템 레퍼런스(v3-44/45 전체 명세). Notion: Notification — UX/Design(§📧 투자자 이메일 채널).

📅 2026년 6월 24일 — 알림 시스템 배선. 문구 레지스트리 + 방향 B 이메일 템플릿 + 생산자 인프라 + 페이로드 스키마 (v3-44)

🔔 v3-44 — 알림 구현 트랙(문구 + 템플릿 + 생산자)

알림 PRD(Notion)가 서로 어긋나던 단일 출처 셋(문서 13의 이벤트 표 ↔ 실제 코드 생산자 ↔ FE 선호 UI)을 정리하고 결정(D1–D8, 채널, 발신자, 법적 사유의 수신 거부)을 확정했다. 이건 apps/infra첫 구현 조각 이다.

  • 문구 레지스트리 lib/shared/notifications/copy.ts — 이벤트 28개짜리 타입 있는 덱(인앱 제목/요약 + 이메일 제목/프리헤더/히어로/본문/상세/CTA + 딥링크 + 변수)이고 Notification Copy 시트의 코드 미러다. V1은 영어만(D7).
  • 방향 B 이메일 렌더러 lib/shared/email/notification-email.ts — 템플릿 하나에 이벤트별 슬롯. 심각도 강조색(info #6999fa / critical #ef5a3c), 히어로 + 2칸 상세 카드 + 브랜드 퍼플 CTA. 로고는 호스팅된 EMAIL_LOGO_URL PNG를 쓴다(Gmail이 SVG를 막는다).
  • 생산자 헬퍼 lib/shared/notifications/notify.tsqueueNotification / queueForPoolHolders. FE 딥링크를 위해 related_entity_type/id를 세운 notification_logs 행을 상태 SENDING으로 넣는다.
  • 스키마(마이그레이션 0032): notification_logs.payload(jsonb. 피드용 in_app 슬롯과 SES 워커용 발송 준비된 email 블롭)와 read_at(인앱 미읽음 배지)과 미읽음 인덱스.
  • 첫 생산자 배선: 인덱서 writers/freeze.tspool_frozen / pool_unfrozen / freeze_extended 홀더 알림. tsc 통과.

⚠️ 아직 열린 것: 나머지 생산자(입금/lp/상환/수익/NAV/부실/청산/KYC/펀드 관리 메타), SES 발송 워커(진짜 sendEmail과 재시도/백오프/스로틀), 인앱 읽기 엔드포인트(GET /notifications 등), 그리고 (c) 선호 저장과 POOL/ACCOUNT FE 분류. 0단계 SES 도메인 검증과 프로덕션 접근은 BE 의존이다.

문서: 11-db-schema notification_logs, 14-decisions v3-44. Notion: Notification — UX/Design(초안).

📅 2026년 6월 24일 — 풀 필드 수정 가능성을 단계별로(DRAFT 대 ACTIVE). capacity는 상향만, min_investment는 잠금 (v3-43)

🔧 v3-43 — 풀 필드 수정 가능성. 단계 인식과 capacity/min_investment 정정

수정 가능성은 이미 명세돼 있었는데(08 Field Sync v3-29의 Class A/B/C와 04 Editability After ACTIVE) admin 풀 생성의 초안 저장/수정 버그를 분류하다가 빈틈 둘이 드러났다.

  • capacitymin_investment가 Class B "온체인 불변"으로 잘못 분류돼 있었다. 실제로는 오프체인 이다(람다가 강제하고 온체인 상태 표에 없다). 새 C-lock 그룹으로 고쳤다. ACTIVE 이후 min_investment잠기고, capacity올리는 것만 된다(PATCH는 new ≥ current일 때만 받는다). 재배포 없음.
  • Class A를 열거했다(표에는 is_paused뿐이었다). is_emergency_frozen, reserve_percentage, fund_wallet, kyc_level_required, kyc_jurisdiction_whitelist, nav_per_token, lifecycle_status가 더해진다. 전부 전용 엔드포인트로만 바꾸고 PATCH로는 절대 안 된다.

수정 가능성 = 저장 위치 × lifecycle 단계. DRAFT에서는 모든 필드가 수정 가능하다(배포된 게 없다). 불변성은 DRAFT→ACTIVE에서 걸린다. 풀 수정 버그를 푼다. 결정이 빠진 게 아니었다. 수정 UI가 단계를 알아야 한다(DRAFT는 만기 필드를 보여 주고, ACTIVE는 잠긴 필드를 숨기지 말고 이유와 함께 읽기 전용으로 렌더한다).

문서: 14-decisions v3-43, 08-smart-contracts, 04-pool-models.

📅 2026년 6월 24일 — 펀드매니저 활동 접근. 펀드 범위 운영 뷰, 컴플라이언스 감사 로그는 admin 전용 유지 (v3-42)

👤 v3-42 — FM은 펀드 범위 "Activity"를 보고 컴플라이언스 감사는 admin 전용으로 남는다

대시보드의 "Recent Activity" 위젯과 감사 로그 페이지는 같은 GET /activity-events 엔드포인트 다(창만 다르다). 그래서 FM 질문은 사실 "엔드포인트에 펀드 범위를 걸 것인가"였다. 페이지가 아니라 무엇인지 로 나눴다.

  • 운영 활동 → FM에게, 펀드 범위로. 입금/상환/수익/NAV와 FM 풀에 대한 풀 관리 액션이다(audit_feed를 FM 펀드의 풀에 속한 pool로 필터). 컴플라이언스 이벤트(PII_ACCESS/KYC_*)는 사용자 대상이라 자동으로 빠진다.
  • 컴플라이언스 감사 로그와 CSV 내보내기 → admin 전용. FM은 처리자이지 데이터 관리자가 아니므로(09-rbac L206) 컴플라이언스 기록은 그들 것이 아니다.
  • 같은 /audit-log 라우트를 역할에 따라 다르게.activity PageKey가 FM에게 열어 주되 "Activity" 로 렌더하고(admin은 "Audit Log") 내보내기는 숨긴다. 09-rbac L227과 L184/L231의 모순(엔드포인트 범위 대 컴플라이언스 페이지 전체 접근)을 정리한다.

FM의 Recent Activity 위젯이 []를 돌려주던 걸 고친다. jy/product에서 만들었고 마이그레이션 없음.

문서: 14-decisions v3-42, 09-rbac.

📅 2026년 6월 23일 — 감사 로그 = 두 스트림을 합친 피드(사람 행동 + 온체인 미러), 5년 보존 (v3-41)

🗂️ v3-41 — 활동/감사 로그. 두 스트림을 합친 피드

activity_events가 "단일 감사 출처"로 선언돼 있었는데 실제로 쓰이는 건 PII_ACCESS뿐이다. 한편 온체인 인덱서는 이미 모든 컨트랙트 이벤트(입금/상환/수익/NAV/freeze/LP 전송)를 tx_hash 출처와 함께 전용 테이블에 미러링한다. 결정: 감사 피드는 읽을 때 합치는 두 스트림 이다.

  • 온체인 이벤트 → 인덱서 테이블에서 읽는다(UNION 뷰). 다시 INSERT하지 않는다(이중 쓰기와 두 번째 단일 출처가 생긴다).
  • 사람의 특권 행동 → activity_events에 쓴다. admin-web→API 경계에서 쓰고(actor_id는 JWT에서) 대상은 KYC 승인/회수, freeze/pause, 부실/청산, 상환 승인/거절/보류, 수수료 인출, 거버넌스 제안, PII 접근이다.

보존 2년 → 5년(MAS PSN02 / 태국 AMLA §22. "2년"은 핫 내보내기 창이었지 하한이 아니었다). 메인넷 블로커는 아니다(감사 흔적이 이미 온체인과 인덱서에 있다). 합친 읽기 뷰와 CSV와 아카이브는 P1이고, 사람 행위자 라이터는 P0-soft 다(AML은 직원 귀속을 기대한다).

문서: 14-decisions v3-41, 13-operations.

📅 2026년 6월 23일 — 미리보기(쇼케이스) 풀 등급과 관심 등록 CTA (v3-40)

🪟 v3-40 — 미리보기 풀 등급(마케팅 쇼케이스)

preview 풀 등급을 넣는다. 보이지만 Aset에서 절대 투자할 수 없다. 파트너의 다른 펀드(예: Joob/FJL 2–4)를 보여 주고 다음 라운드(FJL 5) 관심을 끌기 위한 것이다. 출처: 6/23 Grab×JOOB 마무리(안 B).

  • Overview만(개요 프로필과 딜 구조). Performance와 Fund Data는 숨긴다(지금 CLOSED는 성과 전체를 보여 주는데 여기서는 그걸 원치 않아서 새 등급을 뒀다).
  • 관심 등록 CTA(알림 신청 → Aset으로 리드). CLOSED 풀에도 붙인다(같은 펀드가 다시 돌 수 있다). 알림 트랙과 이어진다.
  • 명세는 필드 수준이 아니라 탭 수준("Overview만")으로 한다(FE는 바뀔 수 있다).
  • 표시 등급(Preview / Display / Full) 이 어느 파트너 API 필드가 필수인지를 정한다. preview는 정적 프로필만 필요하다(Required가 넓다). 진행/위험/시계열은 필요 없다.
  • FE-UI #19("표시 전용 풀")를 다듬는다. FE 작업이고 컨트랙트 영향 없음.

문서: 14-decisions v3-40, 04-pool-models, 10-status-machines.

📅 2026년 6월 23일 — 트랑슈 first-loss는 v1에서 오프체인(람다) 신뢰 모델. 온체인 워터폴은 미룸 (v3-39)

🥪 v3-39 — 트랑슈 first-loss. 신뢰 모델(v1), 온체인 워터폴은 나중에

트랑슈 손실/수익 워터폴은 v3-14에 따라 오프체인(Aset 람다) 에 남는다. Junior와 Senior는 별개 풀이고 람다가 Junior 풀의 NAV를 먼저 내린다("스마트 컨트랙트 영향: 0"). 그러니 후순위 구조는 컨트랙트가 강제하는 게 아니라 신뢰 모델 이다.

  • v1은 그걸 받아들인다. Junior는 파트너가 제공하는 first-loss 이고 자기 풀을 가지며 리테일에 게이트된다(UI에서 숨기고 KYB). 두께는 플랫폼 상수가 아니라 딜별 설정 이다.
  • 신뢰에 한계를 둔다. v3-32(NAV 편차 상한 / 브레이커 / 낡음)가 람다의 NAV 움직임을 제한하고, money-path는 불변이다(v3-27). 최악은 잘못된 표시이지 절도가 아니다. 오프체인 평가는 업계 전반이 그렇다(Centrifuge V2/V3 오라클, Maple/Goldfinch 매니저).
  • 혼합 자본: 펀드 단위 "전체의 %" 완충은 어차피 한 조각에서 온체인으로 강제할 수 없다. 람다는 우리 풀에만 워터폴을 적용한다.
  • 미래(비파괴적): 기관 Senior 투자자가 신뢰 없는 후순위 구조를 요구하면 온체인 워터폴 풀 타입(Centrifuge/TrueFi 방식)을 옆에 더한다. 마이그레이션 없이 된다.

문서: 14-decisions v3-39, 04-pool-models.

📅 2026년 6월 23일 — epoch 기간은 v1에서 생성 전용. 운영 중 변경은 v2 타임락 거버넌스로 미룸 (v3-38)

🔁 v3-38 — epoch 기간은 풀 생성 때만 설정한다(운영 중 변경 없음)

epoch 리팩터가 setEpochDurationDaysDEFAULT_ADMIN_ROLE 세터 로 냈다(범위도 이벤트도 타임락도 운영 중 가드도 없이). 그런데 epoch_days투자자가 언제 어떻게 이탈하는지 를 통제하므로 reserve%(7일 타임락)보다 민감하고, 맨 운영 중 세터는 money-path 불변성과 충돌한다.

  • v1: epoch_days풀 생성 때만 설정한다(배포 멀티콜에 접어 넣고 타입별로 미리 채운다. REVOLVING → 7 / FIXED_TERM → 0). 운영 중 변경 UI는 없고 세터는 비상용 admin 전용으로 남는다. 살아 있는 투자자의 상환 조건이 절대 안 바뀌므로 타임락/수탁/공시 문제를 비껴간다.
  • 이유: 운영 중인 풀의 유동성을 조이는 건 공시 안 된 상환 게이트다(투자자 보호와 규제상 위험 신호). 상한 없는 N은 자금을 가두는 뒷문이라 비수탁 이탈 보장(v3-31)을 깨뜨린다.
  • 컨트랙트 강화 — ✅ 구현하고 테스트했다(메인넷 배포와 감사 대기). setEpochDurationDays가 이제 MAX_EPOCH_DURATION_DAYS = 90을 강제하고(revert EpochDurationTooLong) EpochDurationChanged를 내보내며 생성 전용 이다(totalLPSupply > 0이면 revert EpochImmutableAfterDeposit). PlatformPoolEpoch.t.sol이 덮는다(setEpochDuration 케이스 4개. epoch 스위트 23/23 초록). Sepolia(6/22)는 강화 이전 빌드라 재배포가 필요하다.
  • v2(감사 필요): 운영 중 변경이 필요해지면 fund_wallet/reserve%/KYC와 같은 7일 타임락 거버넌스를 태우되 방향을 인식하게 한다(조이면 통지 + 이탈 창, 풀면 통지를 줄인다).
  • FE: epoch_days가 Class-B 운영 중 불변 집합에 들어간다 → admin 풀 수정에서 잠긴 상태와 짧은 이유로 렌더한다(FE-UI-v3 작업).

문서: 14-decisions v3-38, 04-pool-models, 07-redemption, 08-smart-contracts.

📅 2026년 6월 23일 — 콜드 키 멀티시그 채택(Safe). Admin 3-of-5 + 별도 Pauser 2-of-3 (v3-37)

🔐 v3-37 — 콜드 역할에 Gnosis Safe 멀티시그(설정만이고 컨트랙트 변경 없음)

2026-06-23 외부 보안 리뷰 이후다. 비수탁 자체가 멀티시그를 요구한 적은 없지만(v3-32의 불변 money-path) 다층 방어로 채택한다.

  • DEFAULT_ADMIN → 콜드 Safe 3-of-5(거버넌스 / lifecycle / 역할 부여. 이미 타임락이 걸려 있다). "콜드 키 하나가 스스로 역할을 부여한다"는 우려를 닫는다.
  • PAUSER별도 콜드 Safe 2-of-3(긴급 동결을 더 빨리 할 수 있다. freeze는 정지 전용이고 v3-28에 따라 자동 만료되므로 악의적인 Pauser도 괴롭힘까지이고 절도는 못 한다).
  • ORACLE 은 핫 람다 키로 남는다(자동화이고 고정 목적지와 v3-32로 범위가 잡힌다). YIELD_DEPOSITOR 는 파트너 fund_wallet Safe로 남는다.
  • 새 역할 없고 재배포 없다. Admin과 Pauser는 이미 별개 역할이라 각각을 Safe에 부여하면 된다(hasRole은 EOA인지 Safe인지 신경 안 쓴다). 새 서명자 조합 계층 이 필요하면 새 역할과 재배포가 필요한데, 그건 명시적으로 안 한다.
  • 임계값은 잠정이다(3-of-5 / 2-of-3). 비용과 가용성에 따라 바뀔 수 있다. 서명자는 물리적으로 분리하고 1-of-1은 피한다. 감사 후속: Zellic Korea.

문서: 14-decisions v3-37, 09a-custody, 09-rbac.

📅 2026년 6월 22일 — FM/admin 대시보드 통계. 펀드별 드릴다운과 부족액 KPI (v3-36)

📊 v3-36 — 펀드별 통계와 부족액(파트너 펀딩 대기) KPI

읽기 권한 계층 위에 얹는다(FM/admin GET 엔드포인트가 서버에서 펀드 범위로 잡힌다).

  • 펀드별 통계 드릴다운: GET /dashboard/stats?fund_id=(admin은 아무 펀드나, FM은 자기 펀드만. 서버가 검증하고 아니면 403). 펀드 상세의 "Fund Stats" 절 로 드러난다(TVL / 수익 / 투자자 / 대기 상환 / 펀딩 대기 / 대기 수익). 대시보드 자체는 집계로 남는다(admin은 플랫폼, FM은 자기 펀드). 기존 라우트를 재사용하므로 새 인프라가 없다.
  • 부족액 KPI(건수): PENDING_RESERVE 상환(승인됐는데 리저브가 못 덮어서 파트너가 fundRedemption을 해야 한다)을 alerts.pending_reserve로 드러내고, 대시보드의 "Awaiting Partner Funding" 조치 항목과 펀드 상세의 "Awaiting Funding" 카드(0보다 크면 앰버)로 보여 준다. 건수만이다. 금액은 백엔드가 온체인 부족액(RedemptionPendingReserve / EpochFundingNeeded. 이미 리저브 차감 후 부족분이다)을 휴면 컬럼 redemption_requests.funding_shortfall에 저장한 뒤로 미룬다. 그러면 금액은 단순 SUM이다. (백엔드 개발자에게 요청했다. Notion v3-26 §15 참조.)
  • FM 읽기 전용 가드를 같은 날 넣었다. FM에게는 "Create Distribution"(수익)을 숨기고 Configuration 탭을 읽기 전용으로 한다.

FE는 병합됐다(jy/product). 백엔드(dashboard.get.stats.ts)는 pnpm cdk:deploy가 필요하다.

📅 2026년 6월 22일 — v3-28 / v3-32가 컨트랙트에 착지 (ch/product 병합)

🛠 v3-28 + v3-32 — 이제 구현됨(병합 후 코드로 확인)

ch/product 컨트랙트 커밋(비수탁 v3: KYC UUPS, epoch, EIP-170 라이브러리)이 안전 항목 둘을 함께 냈다. 문서를 구현됨 으로 갱신했다.

  • v3-28 시간 제한 freeze: emergencyFreeze()freezeStartedAt을 기록한다. 비대칭 게이트다(capital-IN은 freeze 내내 막히고 value-OUT은 FREEZE_EXIT_WINDOW = 72h 뒤 자동으로 풀린다). freeze 전체는 FREEZE_MAX_DURATION = 7d에 자동 만료된다. proposeFreezeExtend 거버넌스 경로가 있다.
  • v3-32 updateNAV 코어 범위: updateNAV≤ 1.0 클램프 뒤에 _checkNavBound(서킷 브레이커와 양방향 편차 상한)를 부른다. 핫 키 쓰기가 코어에서 범위가 잡힌다. executeEpoch에 낡음 게이트가 붙는다. v3-29의 열린 항목을 닫는다.

참고: 같은 날 앞서 한 감사(병합 전 브랜치 상태 기준)는 둘 다 미구현으로 표시했는데, 병합이 그걸 대체했다. 앱 코드 변경 없음. 문서(08-smart-contracts, 14-decisions)를 병합된 컨트랙트에 맞췄다.

📅 2026년 6월 22일 — PlatformPool EIP-170 맞추기. RedemptionLib 분리

🧱 RedemptionLib — 크기 축소(동작 보존)

PlatformPool이 epoch 상환과 비수탁 강화 뒤 EIP-170 24,576 B 한도를 넘어서(27,877 B) 배포가 안 됐다. 상환 로직 전체(instant 요청/승인/지급/거절, epoch 정산, instant 폴백)를 delegatecall되는 외부 RedemptionLib 으로 빼고 공유 헬퍼는 PoolCommonLib에 뒀으며, 풀 상태 전부를 단일 AppStorage Layout 구조체(PlatformPoolStorage.sol)로 옮겼다. 라이브러리는 배포 시점에 링크된다(불변이고 업그레이드 경로가 없다. money-path는 v3-27에 따라 불변으로 남는다). 결과: 27,877 → 21,311 B(여유 +3,265 B). 동작을 보존했고(forge 테스트 277/277 초록) ABI도 그대로다(셀렉터/topic0/게터 튜플 순서를 보존해서 백엔드 람다는 코드 변경이 필요 없다). Sepolia에 재배포했다(새 PoolImpl + Factory + KYC UUPS 프록시. apps/contract/sepolia.md 참조). 상세: 08 → 구현 라이브러리.

📅 2026년 6월 18일 — collateral_type enum 폐기 (v3-35, 소프트. v3-07을 마무리)

🏷️ v3-35 — collateral_type enum 제거

collateral_type enum(Fully / Partially / Unsecured)을 소프트 전환으로 폐기한다(2026-06-23 빌드). collateral_description에 백필했고 어느 UI도 더는 안 읽는다. 다만 컬럼은 유지하고, 아무도 안 읽는 게 확인되면 나중 마이그레이션에서 제거한다. 담보는 collateral_description(자유 텍스트)과 collateral_ratio(NUMERIC, 표시 전용, 100%를 넘을 수 있다) 다. 근거: v3-25가 이미 위험 신호를 자동 계산되는 위험 등급 배지로 옮겼으므로, 경직된 enum과 그 비율 정합성 검증은 더할 게 없다. 이걸로 풀 생성 4단계 버그 3/4(엄격한 Partially=0–100% / Unsecured=0)가 무의미해진다. 작업(소프트, 완료): 마이그레이션 0029(설명 백필. enum과 컬럼 유지) · admin 생성/수정의 select→설명 · admin 목록/상세의 배지→설명 · 투자자 위험 고지/보유/포트폴리오 → 설명 · VALID_COLLATERAL_TYPES API 검사 제거. 미룬 것(하드 정리): enum과 컬럼 제거 마이그레이션 · 투자자 담보 필터 제거(아직 enum을 쓴다). (FE-UI #32)

📅 2026년 6월 18일 — 비수탁 강화. 표적 모듈화 + 이탈 폴백 + NAV 범위 (v3-30 / v3-31 / v3-32)

🔐 v3-30 / v3-31 / v3-32 — 비수탁 결정 셋

  • v3-30 — 표적 모듈화: 핵심 money-path는 불변 으로 남는다. 메인넷 전에 KYC 컨트랙트만 업그레이드 가능한 프록시(UUPS)와 타임락 으로 만든다. 스테이블코인은 기존 세터를 쓴다. 오라클/수수료/검증은 오프체인이다. updateNAV 범위는 코어에 남는다. 주변부는 적격성을 게이트할 수 있어도 자금은 절대 못 옮긴다. ⚠️ KYC가 REVOKED 상환을 게이트하므로(AML) #5에 가벼운 단서가 붙는데 타임락으로 완화한다. 법무와 감사가 확인한다.
  • v3-31 — 이탈권 폴백(#5): 무권한 타임락 claimRedemption 을 넣는다. N일 동안 미정산이고 리저브가 있고 AML 플래그가 없으면, 투자자 누구든 마지막 NAV로 pro-rata를 스스로 실행한다. ORACLE 승인이 필요 없다. approveRedemption은 이탈 게이트가 아니라 "속도 + AML 홀드"로 다시 규정한다. WIND_DOWN 전용은 부족했다(순환이다). 벤치마크: Maple / Goldfinch.
  • v3-32 — 코어의 NAV 범위와 역할 관리자 수용: updateNAV편차 상한 + 서킷 브레이커 + 낡음 을 불변 코어에 구현한다(문서는 있다고 주장했는데 코드에 없었다. v3-29의 열린 항목을 닫는다). 역할 관리자의 괴롭힘은 콜드 키 하나와 타임락으로 수용하고 멀티시그는 안 쓴다(절도는 불변 money-path가 막고, 괴롭힘은 상한으로 제한되며, 확대는 타임락과 v3-31 이탈로 보인다). 2-of-3 콜드 멀티시그는 선택적 다층 방어다.
  • v3-33 — 관리형 지갑 허용, Aset 지분은 0: 일반 투자자를 겨냥하므로 자가 수탁과 함께 내장/관리형 지갑 옵션을 허용한다(이메일/소셜, 시드 없음). 제약은 Aset이 키 지분을 하나도 안 갖는 것 이다(제3자 비수탁 제공자를 쓰고 사용자가 클라이언트에서 서명하므로 여전히 비-VASP다). ⚠️ Aset이 지분을 저장하거나 재공유하는 변형은 수탁이 되므로 재검토와 라이선스가 필요하다.

14-decisions(v3-30/31/32/33), 09-rbac → 키 수탁(낡은 부분 정정: request.investor, 코드에 없던 NAV 상한), 09a-custody를 갱신했다. 컨트랙트 작업: KYC 프록시(v3-30), claimRedemption 폴백(v3-31), updateNAV 범위(v3-32). 전부 메인넷 전이다. 체크리스트: Non-Custodial(Notion).

📅 2026년 6월 18일 — FM_ACCEPTED 제거 확정 (v3-34)과 unclaimedYield 범위

🧹 v3-34와 정리

  • v3-34 — FM_ACCEPTED 제거(v3-04 마무리): 상환 흐름은 REQUESTED → RECOMMENDED → APPROVED → …이고 FM 사전 확인 단계가 없다. 정리(dev): enum 값 · /fm-accept 엔드포인트 · 시드 · 상태 배지 · CLAUDE.md 흐름 · 07/15/18 문서.
  • unclaimedYield 범위 결정: withdrawFees수수료 몫에만 한정된다. 투자자의 미청구 수익이나 원금이나 리저브에는 절대 안 닿는다. 컨트랙트 변수는 개발자가 확인한다(더 명확하면 unclaimedFees로 rename). (09a §6)
📅 2026년 6월 18일 — DB와 온체인 필드 동기화, 그리고 PATCH 강제 (v3-29)

🔗 v3-29 — 온체인 값 동기화(Class A/B/C)

지금 PATCH는 DB만 갱신하므로 DB와 온체인이 갈릴 수 있다. 풀 필드를 분류하고 강제 규칙을 정한다.

  • A is_paused → PATCH가 온체인 pause()/unpause()를 불러야 한다(DB만 바꾸면 deposit() 직접 호출로 보안이 우회된다).
  • B 온체인 불변 필드(capacity / 패널티 / min·max / redemption_type / 통지 / 락업 / 만기 / 통화) → 배포 후 PATCH를 거절하고 DRAFT에서는 수정 가능 하다(컨트랙트 변경 없고 Editability-After-ACTIVE와 맞는다).
  • C 오프체인 전용 필드는 DB만으로 남는다. investment_blocked은 폐기한다. 이제 배선된 온체인 is_paused와 중복이라 투자 적격성에서 뺐고 DB 컬럼도 폐기했다.

08 → Field Sync14-decisions v3-29 참조. 컨트랙트 변경: 없음(백엔드 배선과 PATCH 가드). 구현 명세: ch의 ONCHAIN_VALUE_SYNC_DECISION.md.

📅 2026년 6월 17일 — 문서 통합. 수탁 허브(09a)와 타임락 정본 표

🧹 통합 — 새 결정 없음

수탁과 타임락 내용이 04/08/09에 흩어져 있어 "한눈에" 보기 어려웠다. 단일 출처 하나로 모았다.

  • 09a-custody — 비수탁 기준 6개 · 키 모델(콜드/핫) · money-path 불변식(v3-27) · 업그레이드 거버넌스 · freeze 이탈권(v3-28) · 서비스 키 범위 · 청산 회수 · 규제 근거. (기준 6개는 Notion에서 vitepress 단일 출처로 옮겼다.)
  • 08-smart-contracts → 타임락 설정 이 타임락 값의 정본 표다(업그레이드와 freeze 행을 더했다). 다른 문서는 여길 상호 참조한다.
  • 04/09는 자기 기능 맥락을 유지하고 09a로 상호 참조한다.

기존 결정(v3-26/27/28)을 재배치했을 뿐 스키마/컨트랙트/결정 변경은 없다.

📅 2026년 6월 17일 — 비수탁 키 거버넌스 결정 (v3-27 / v3-28)

🔐 v3-27 / v3-28 — 콜드 키 하나로도 비수탁이고, 빈틈 둘을 닫는다

  • v3-27 — money-path는 업그레이드 불가: 사용자 자금 경로(상환 지급은 msg.sender로, 수익은 현재 홀더에게, withdrawFees는 적립된 수수료에 한정돼 고정 treasury로)는 업그레이드 범위에서 제외 한다. 주변부(오라클 / 수수료)만 타임락 뒤에서 업그레이드 가능하다. 현재 컨트랙트는 이미 업그레이드 불가라(Clones가 구현체를 고정한다) 이건 설계를 추인하는 것이다. 멀티시그는 필요 없다(콜드 키 하나와 타임락으로 충분하다).
  • v3-28 — 긴급 freeze의 이탈권: 비상시 즉시 freeze는 유지하되 무기한은 절대 아니다. 이탈을 막는 전면 freeze는 최대 72시간 이고 그 뒤 상환이 자동 허용된다. 그 밖의 freeze 창은 7일 이다. 연장과 unpause는 거버넌스와 타임락으로만 한다(비대칭이고 Compound 방식의 "이탈은 절대 막지 않는다").

09-rbac → 키 수탁을 갱신했다(열림 → 결정됨). 컨트랙트 작업 항목은 Claude-Plan/Active/non-custodial-money-path-implementation.md에 있다. v3-28에는 컨트랙트 변경이 필요하다(freeze 최대 기간과 폴백). v3-27은 변경이 필요 없다(이미 불변이다).

📅 2026년 6월 16일 — 모든 풀에 fund_id 필수. Aset 직접 운영 풀 제거 (v3-26)

🏦 v3-26 — 모든 풀은 펀드에 연결된다(Aset 수탁 직접 풀 없음)

모든 풀에 fund_id(파트너 펀드)가 있어야 한다. Aset 직접 운영 풀 설정(fund_wallet = Aset treasury, fund_id 없음)은 제거한다. 직접 풀에서는 입금의 90%가 Aset treasury로 가므로 Aset이 투자자 자금을 보유하게 되고, 수탁 이 되어 비수탁/VASP 회피 자세를 깬다. 목표는 Aset을 통한 투자(완전 온체인) 이지 Joob의 읽기 전용 하이브리드가 아니다.

  • 코드: pools.post.create가 이제 fund_id 누락을 거절한다. admin 풀 생성(step-basic-info와 1단계 검증)이 펀드 선택을 요구한다.
  • DB: pools.fund_id NOT NULL은 미룬다(fund_id 없는 기존 행을 먼저 정리한다). 지금은 앱 계층에서 강제한다.
  • 갱신한 문서: 04-pool-models(fund_wallet/fund_id), 02-core-concepts("직접 풀은 Aset treasury" 삭제), 11-db-schema.
  • ⚠️ 이게 비수탁 보장은 아니다. 그건 컨트랙트 계층(불변 money-path, 독립 멀티시그와 타임락, fund_wallet ≠ treasury)과 법무의 몫이다. 14-decisions v3-26 참조. fund_id 필수는 제품 수준 제약이다.

컨트랙트 영향: 없음(fund_id는 오프체인 전용이다).

📅 2026년 6월 16일 — 문서 정리. 낡은 D+7 환불 / Receipt NFT / Escrow 잔재 제거

🧹 정리 — 새 결정 없음

이미 나간 결정(v3-03 환불 제거, v3-11 Escrow 흡수)과 모순되는 낡은 참조를 뺐다.

  • D+7 자동 환불(v3-03): "환불 타이머" 트리거(12-triggers), "D+7 자동 환불 안전망" 런북(13-operations), 그리고 deposits.scheduler.auto-refundPOST /deposits/{id}/refund 항목(15-api-reference)을 지웠다. 코드에 환불 람다나 스케줄러가 없다.
  • Receipt NFT(v3-03): RECEIPT_BURN_FAILEDREFUNDED 행, 그리고 → REFUNDED 입금 상태 흐름을 뺐다(18-failure-types).
  • PlatformEscrow(v3-11): 컨트랙트 목록이 이제 제거/흡수로 표시한다("검토 중"이었다). 활성 컨트랙트 수를 4 로 고쳤다(01-overview).

통합(드리프트 방지): 패널티 타입 열거를 12-triggers에서 덜어 내고 정본 04로 몰았다. lifecycle_status의 상태별 상세와 전이 블록도 04에서 덜어 내고 정본 10-status-machines로 몰았다. 각도가 다른 내용(07의 계산, 20의 Joob 예제, 02의 도입, 03의 KYC 흐름)은 그대로 뒀다. 진짜 중복이 아니다.

⚠️ 안 건드린 것(폐기됐지만 코드에 살아 있음): redemption_status.FM_ACCEPTED(enum과 /redemption-requests/{id}/fm-accept 엔드포인트와 시드), 그리고 escrow_status 컬럼. 별도 정리 결정으로 표시해 뒀다.

문서만이고 스키마/컨트랙트/결정 변경은 없다.

📅 2026년 6월 16일 — 문서 명확화. "ACTIVE 이후 수정 가능성"을 한데 모은 뷰

🔧 명확화 — 새 결정 없음

04-pool-models → 가변성 표"ACTIVE 이후 수정 가능성" 하위 절을 넣었다. 기존 가변성 표는 필드를 변경 장치 별로 묶어서(불변 / 타임락 / 즉시) "풀이 ACTIVE가 되면 무엇이 잠기나"를 알려면 카테고리 표 여럿을 상호 참조해야 했다.

  • DRAFT → ACTIVE 축으로 정리한 새 표와 빠르게 읽는 상자를 넣었다. lockup_days, maturity_days, penalty_type, fx_rate_source가 DRAFT에서는 수정 가능하지만 배포가 아니라 ACTIVE 전이에서 잠긴다 는 걸 부각한다.
  • 🟡 타임락 필드(fund_wallet, reserve_percentage, kyc_level_required, kyc_jurisdiction_whitelist, redemption_gating_pct)와 🟢 언제나 수정 가능한 필드(yield_frequency, net_yield_fee_config, collateral_*, allow_rollover, is_paused, is_emergency_frozen)를 ACTIVE 관점에서 다시 적었다.

기존 가변성 규칙을 알아보기 쉽게 재배치했을 뿐 스키마/컨트랙트/결정 변경은 없다.

📅 2026년 6월 16일 — 풀 위험 등급 배지(Low/Med/High), 매출채권 우선 (v3-25)

🟢 v3-25 — 풀 카드 위험 등급(collateral_type 배지를 대체)

폐기된 collateral_type 배지(v3-07)를 풀 카드와 상세(투자자와 admin)의 자동 계산되는 Low / Med / High 위험 등급 으로 바꾼다. 매출채권 우선 모델이라 담보 비율이 아니라 구조(트랑슈)와 포트폴리오 건전성 으로 등급을 매긴다(Maple / Goldfinch / Centrifuge와 같다). 환율은 제외한다(운영자가 관리한다). NPL은 직접 안 쓴다(이미 nav_per_token에 반영돼 있다).

  • 임계값(확정): NPL <2 / 2–5 / >5% · 리저브 기준선 10% · NAV <0.90 / 0.90–0.999(낮을수록 나쁘다). 우선순위는 High → Med → Low다. 주기는 lifecycle/freeze/NAV는 이벤트 기반, NPL/DPD는 월간이고, 깜빡임을 막으려고 히스테리시스를 둔다.
  • 1단계(구현됨, FE): lifecycle_status + is_emergency_frozen + nav_per_token + reserve_percentage. RiskTierBadgeCollateralBadge를 대체하고 LIST_SELECTis_emergency_frozenreserve_percentage가 붙는다.
  • 2단계(⏳ 대기): tranche_role(v3-14. FE 미배선)과 파트너 NPL/DPD. Joob API 2차 데이터 요청(DPD 비율 분모. 집중도/만기/사용률은 선택)과 total_rni의 총액/순액 확인에 달려 있다. Equity Buffer(운영자의 NPL≤5% first-loss)는 DB 필드가 없다. 제대로 지원하려면 온체인 운영자 first-loss가 필요하고(다음 주기) 그전까지는 표시 전용이다.

04-pool-models → 위험 등급14-decisions v3-25 참조. 스키마/컨트랙트 변경 없음(1단계는 기존 컬럼을 쓴다).

📅 2026년 6월 16일 — 문서 명확화. USD 기반 스테이블코인 여러 개를 지원한다(하나를 권장)

🪙 명확화 — 허용 스테이블코인(새 결정 없음)

04-pool-models → accepted_currencies를 현재 코드에 맞췄다. currency enum은 USDC / USDT / DAI(USD 기반만) 를 지원하고, 풀의 accepted_currencies 배열에 여럿을 넣을 수 있다. USDC가 기본이자 권장이고 풀당 스테이블코인 하나를 여전히 권장하지만, 다중 스테이블코인 지원은 실제로 있다(USDC 전용이 아니다). 브리지 변형(예: USDC.e)은 계속 제외한다(디페그 위험).

기존 동작을 문서화한 것이고 스키마/컨트랙트/결정 변경은 없다.

📅 2026년 6월 15일 — 투자자 대상 환율 고지 없음. operating_currency는 백엔드 전용 (v3-24)

💱 v3-24 — 환율 고지 폐기(USD 입금 / USD 출금)

투자자는 USD(USDC) 로 입금하고 상환하며 현지 통화를 들고 있는 일이 없으므로 직접적인 환율 노출이 없다. 투자자 대상 환율 위험 경고와 배너를 뺀다.

  • operating_currency / fx_rate_source백엔드 NAV 환산 데이터로만 유지한다. 비USD 펀드(예: Joob = IDR)의 NAV를 표시용 USD로 바꾸는 데 쓴다. 투자자 대상 고지가 아니다.
  • 풀 위험 고지 상자에서 "환율 위험" 행을 뺐고 04-pool-modelsoperating_currency 명세를 명확히 했다.
  • FE: 환율 배너 작업 항목은 폐기 다(전에는 "API 먼저"로 세워 뒀다).

스키마 변경도 컨트랙트 영향도 없다. 표시/사용성 결정이다.

📅 2026년 6월 12일 — 읽기 계층 권한을 WO-4에서 분리 (v3-23)

🔐 v3-23 — 읽기 엔드포인트에 백엔드 권한이 없다. 별도 명세로 뗀다

WO-4(FM 패널)를 구현하다가 어느 GET 엔드포인트에도 백엔드 권한이 없다는 게 드러났다. API Gateway 권한자가 없고 목록/상세/대시보드 핸들러가 withAuth/withRole로 감싸여 있지 않아서, 민감한 데이터(/deposits, /redemption-requests, /yield-distributions 등)가 사실상 전 세계에 읽히고 있었다. 게다가 이 핸들러들은 투자자 웹 앱과 공유 된다.

  • 결정: 이건 FM보다 크고 투자자 앱까지 건드리므로, 읽기 계층 권한을 WO-4에서 떼어 독립 백엔드 명세(read-authz-backend-spec.md)로 만든다. 읽기 엔드포인트에 적용하는 3역할 데이터 모델이다(투자자 = 자기 것, FM = 펀드 범위, admin = 전부).
  • WO-4에 남는 것: 쓰기 경계(이미 가드가 있다)와 이메일 키 멤버십 강화, 그리고 FM 프론트엔드 작업 전부(권한, 라우트 가드, 읽기 전용 UI, PII 숨김).
  • 읽기 권한 명세가 나오기 전까지 FM 읽기 범위는 프론트엔드에만 있다(진짜 경계가 아니다). FM에게 서버 쪽에서 investor_email을 빼는 것도 그 명세로 옮긴다. 09-rbac → 펀드매니저 패널 범위 참조.

스키마 변경도 컨트랙트 영향도 없다. 읽기 전용 권한 계층이다.

📅 2026년 6월 12일 — 풀 공지 피드 기획 (v3-22)

📣 v3-22 — 풀별 공지 피드(기획 — WO-6)

FM과 Admin이 사람이 쓴 안내(상태 업데이트, 중대 사건, 분배 통지)를 올리고 그 풀의 투자자가 보도록 풀별 공지 피드 를 넣기로 했다. RWA 플랫폼 관례에 근거한다. Centrifuge는 구조화된 풀 공시와 거버넌스 포럼의 발행사 공지 를 쓰고(예: "곧 있을 풀 파라미터 개편 안내"), Maple은 부실과 회수 진행에 "프로토콜 업데이트"를 쓴다. 지금 Aset에는 pool_documents(파일), notification_logs(이메일), activity_events(시스템 로그)만 있고 매니저가 직접 쓰는 정제된 피드가 없다.

  • 새 테이블 pool_updates(pool_id, 작성자, 제목, 본문, category INFO/IMPORTANT/MATERIAL_EVENT, published_at). activity_events(시스템 로그)와 별개다.
  • 권한: Admin은 모든 풀. FM은 자기 펀드 풀만(requireFundAccess, WO-4에 따라). 투자자는 자기 풀의 발행된 공지를 읽는다.
  • 선택: IMPORTANT/MATERIAL_EVENT면 그 풀의 투자자에게 알리기, 그리고 수동 게시와 별도로 lifecycle/중대 사건(부실, 청산, NAV 상각, 수익 펀딩. v3-20과 이어진다)에 항목 자동 생성.
  • 영어 전용이고 개인정보는 안 담는다. WO-6 로 추적한다(명세만 세워 뒀고 착수 안 함).

갱신(2026-07-02): 배포됨. 마이그레이션 0012(pool_updatespool_update_revisions)와 엔드포인트 넷으로 만들었다. RBAC 모델(Operator 옵트인, FM은 자기 펀드/자기 글, 소프트 삭제 보존, MATERIAL_EVENT 개정 감사)은 v3-57에 정리했다.

📅 2026년 6월 12일 — FM 패널 범위(WO-4) 결정 확정. PII 규칙, 라우트 가드, 백엔드 범위 (v3-21)

🟣 v3-21 — 펀드매니저 범위(WO-4)의 열린 항목 해결

FM 패널 작업 지시(WO-4)에 대해 v3-19가 열어 둔 후속을 마무리한다. 페이지별 전체 명세: RBAC → 펀드매니저 패널 범위.

  • PII 규칙(법무 확인): FM은 투자자의 이름과 지갑 주소 를 본다. 이메일은 모든 FM 페이지에서 숨긴다(상환 표 / CSV 내보내기 / 상세). 데이터 최소화와 알 필요 원칙(GDPR 5조 1항 c호, 개인정보보호법 16조)과 관리자/처리자의 "본질적 수단"(EDPB 07/2020)에 근거한다. FM이 처리자이자 범위가 잡힌 역할 임을 확인했으므로(자체 KYC/AML 의무가 없다) 규칙이 확고히 선다. UI만이 아니라 서버 쪽에서 강제한다(FM 응답에서 필드를 뺀다). 지갑 주소는 개인정보이되 수신자에 따라 상대적이다(EDPB 02/2025, Breyer, EDPS v SRB).
  • 라우트 가드는 중앙화하고 맵을 공유한다. route→pageKey 맵을 사이드바에서 빼서 공유 모듈로 옮기고, 사이드바(내비)와 protected-layout(URL 가드)이 함께 쓴다. 파라미터 라우트(/pools/:id)는 접두어 매칭을 쓴다.
  • 백엔드 펀드 범위는 WO-4 안에 있다(진짜 경계다). requireFundAccess()는 있는데 여러 핸들러에 빠져 있어서 추가한다. pools 생성/수정/삭제, nav-changes 제안/활성화/취소, pools의 freeze/impairment/wind-down/거버넌스, 상환 생성, yield-claims다.
  • poolType(AS_POOL/FUND_POOL) 정리는 별도 리팩터 작업 지시로 뺀다(WO-4 아님). 이 diff를 좁게 유지하려는 것이다. fund_id 유무 검사로 대체한다.
  • 펀드 멤버십은 이메일 키를 유지한다(admin_user_id FK 없음. 이메일 초대 온보딩을 유지한다). 문서를 고쳤다(admin_user_id라고 잘못 적고 있었다). 강화 규칙 둘을 더했다. (a) 모든 fund_members/admin_users 쓰기와 requireFundAccess에서 이메일을 lower(trim())으로 정규화한다. (b) FM이 떠나면 그 이메일의 fund_members 행을 비활성화해서, 재사용된 이메일이 펀드 접근을 자동으로 물려받지 못하게 한다.

스키마 변경도 컨트랙트 영향도 없다. RBAC와 범위와 UI뿐이다. ⚠️ 대기: 개인정보보호법 적용에 대한 한국 법률 검토, AML/CDD·FATCA/CRS 최소 PII 확인.

📅 2026년 6월 12일 — 수익은 수동 전용. AUTO 제거와 D-n 카운트다운 (v3-20)

⏱ v3-20 — 수익 트리거 제거(청구 기반 / 수동만), 분배일은 D-n으로 표시

v3.0은 이미 수익을 청구 기반 / 수동 트리거 로 통일했는데(01-overview) AUTO 잔재가 admin UI와 DB에 남아 있었다. 그걸 정리하고 날짜 표시를 개선한다.

  • admin UI에서 yield_trigger 필드를 뺐다(풀 생성/수정 폼, 풀 상세의 설정과 수익 설정 탭, 수익 분배의 "Trigger" 컬럼). AUTO/MANUAL 선택이 없다. 모든 풀이 수동 청구다. FE는 레거시 AUTO 행을 MANUAL로 강제한다.
  • 수익 분배일 표시(admin과 FM) — 풀 목록, 풀 상세 수익 탭, 수익 연체 바에서 getDDay / <DDayBadge>를 쓴다.
    • 30일 이하D-n 카운트다운(당일은 D-DAY. 7일 안이면 앰버). 30일 초과 → 절대 날짜(먼 카운트다운은 읽기 어렵다). 연체 → 빨간 D+n.
    • 예상 대 확정(설계. 주식 배당 캘린더를 본떴다): 예상 다음 날짜는 흐리게 ~로 렌더하고 확정 날짜는 진하게 렌더한다. <DDayBadge confirmed>가 이걸 지원하는데 데이터가 아직 안 배선됐다(아래 TODO 참조).
    • ⚠️ 백엔드 TODO — 데이터 배선(아래 결정 둘은 2026-06-12에 정리됐고 예상 수익 금액 공식만 열려 있다):
      1. 예상 기준점 = last_distribution_date + interval(start_date가 아니다. 실제 주기 편차를 반영한다. 30일 풀이 27일 만에 지급할 수 있다). start_date + interval은 분배 기록이 하나도 없을 때의 첫 예상에만 쓴다. 백엔드가 계산한다(FE는 분배 기록에 못 닿는다). last_distribution_date(= MAX(yield_distributions.distributed_at))도 노출한다.
      2. 확정 장치 = Estimated → Funded(2상태이고 완전 자동. 2026-06-12 결정). Estimated(흐림/~. 자동으로 last_distribution + interval) → Funded(진함. 파트너의 그 기간 depositYield가 온체인에 착지하면 자동). 수동 "Scheduled" 단계는 없다(미룬다). 필요한 백엔드 신호: 다가오는 기간이 펀딩됐는지 여부. (예상 배치 수익 금액 공식은 별개의 나중 트랙이고 지금은 미정이다.)
  • FM이 수정할 수 있는 수익 필드(v3-19에서)는 이제 yield_frequency와 사용자 지정 간격과 다음 분배 메모뿐이다. yield_trigger는 더는 수정 가능한 필드가 아니다.
  • DB 정리: 마이그레이션 0010_drop_yield_triggerpools.yield_trigger를 제거하고 풀 생성/수정 람다도 더는 참조하지 않는다. ⚠️ 적용 대기. 백엔드가 0010을 돌리고 람다를 함께 재배포한다. yield_distributions.distribution_type은 이미 'MANUAL' 기본값이라 그대로 뒀다. 11-db-schema 참조.

FE만 바뀌었고 백엔드 마이그레이션은 TODO다. 컨트랙트 영향 없음(컨트랙트에는 트리거나 주기 필드가 있었던 적이 없다. 수익은 depositYielddistributeYieldclaimYield다).

📅 2026년 6월 12일 — 펀드매니저 패널은 역할로 범위를 잡는다 (v3-19)

🟣 v3-19 — FM은 별도 /fund-admin이 아니라 역할로 범위가 잡힌 단일 패널이다

펀드매니저 화면에 대한 문서와 코드의 괴리를 정리한다. 결정: admin 앱 하나를 유지하고 역할로 범위를 잡는다(별도 /fund-admin 패널은 만든 적도 없고 만들지도 않는다). 페이지별 전체 명세는 RBAC → 펀드매니저 패널 범위 참조.

  • 패널: FM은 같은 admin 앱 에 로그인한다. 사이드바는 역할로 필터되고 모든 데이터는 서버에서 FM의 펀드로 범위가 잡힌다(fund_memberspools.fund_id). 앞선 "패널: /fund-admin" 줄은 대체된다.
  • FM 권한 키(목표): dashboard, deposits, redemptions, yield, pools, fund_managers. kyc는 제외 다. (기존 코드 기본값인 redemptions/yield/fund_managers를 넓혀 펀드 범위 대시보드와 읽기 전용 풀/입금을 더한다.)
  • FM에게 읽기 전용: 풀 목록, 풀 상세(수정/삭제와 온체인 Operations 탭 숨김), 입금(재시도 버튼 숨김).
  • 그룹 2 결정: 입금은 보이고 펀드 범위이며 읽기 전용 이다. 풀 수정은 수익 설정 필드만(yield_frequency, yield_trigger, 사용자 지정 간격, 다음 분배 메모)이고 나머지는 전부 Admin 전용이다.
  • PII: 상환에서 FM에게는 투자자 이메일을 숨긴다.
  • FM에게 숨기고 라우트 가드까지: KYC, Admin Settings, Audit Log.
  • 보안 후속: 지금은 사이드바로만 숨기고 있으므로 라우트 수준 hasPermission 가드를 넣는다(URL 우회 수정). 백엔드의 엔드포인트별 펀드 범위가 권위 있는 경계다(목록 엔드포인트를 필터하고 범위 밖 id는 403).

스키마 변경도 컨트랙트 영향도 없다. RBAC와 범위뿐이다.

📅 2026년 6월 1–2일 — v3.0 정련 (v3-11부터 v3-18까지)

🔧 v3-11 ~ v3-18 — 아키텍처 정련과 대체

5월 29일 v3.0 초기 설계 뒤의 후속 결정이다. 일부는 앞선 차원을 대체하고, 일부는 구현 계획에서 발견한 빈틈을 메운다.

  • v3-11: PlatformEscrowPlatformPool에 흡수했다. 10/90 분할을 인라인으로 넣고 컨트랙트 수는 5 → 4
  • v3-12: 청산을 NAV 장치와 통합했다. claimWindDown()을 없애고 표준 redeem()이 둘 다 처리한다. ACTIVE와 WIND_DOWN 사이에 새 IMPAIRED lifecycle 상태를 넣었다(Maple 패턴)
  • v3-13: Joob DPD API를 통한 자동 NAV 상각. OJK 일정(DPD 구간별 5/15/50/100%)과 joob-dpd-sync 람다 일일 cron이고 Joob 정책 확인 대기다
  • v3-14(v3-09를 대체): 연결된 SINGLE 풀들로 트랑슈를 만든다. tranche_group_id UUID와 tranche_role ENUM(SENIOR/MEZZANINE/JUNIOR) 컬럼이다. 그룹당 최대 3개다. Aset 람다가 NAV 계산 때 손실 워터폴을 적용한다. 스마트 컨트랙트 영향: 0(각 풀은 표준 SINGLE로 남는다)
  • v3-15(v3-08을 대체): nav_data_source 차원을 제거했다. 모든 풀이 Aset 오라클 람다를 거친다. 파트너 API는 원자료를 주고 Aset이 언제나 처리한다(트랑슈 워터폴과 상각 일정 강제에 필요하다)
  • v3-16: 리저브 소모를 updateNAV(newNav, reserveConsumed)에 통합했다. Aset 람다에서 원자적으로 부른다. 리저브는 손실 흡수(완충 안이면 NAV는 $1.00 유지)와 상환 지급에서 줄어든다. 경쟁 없는 단일 이벤트 감사 흔적이다. ⚠️ 손실 흡수 절반은 R8(v3-109)로 대체됐다. 리저브는 손실을 흡수하지 않고 reserveConsumed는 언제나 0이며 컨트랙트가 그 외의 값에 리버트한다. 원자성 근거와 ABI는 유효하고 리저브는 여전히 상환 지급에서 움직인다
  • v3-17: VASP 간 KYC 전략. SumSub 재사용 KYC(트랙 1. 파트너가 SumSub을 쓰는 경우) 또는 재방문 사용자 흐름을 붙인 빠른 재KYC(트랙 2. 폴백)다. 수신자 수준 검사는 규제 준수를 위해 언제나 다시 돈다. Travel Rule(Notabene/Sygna, IVMS101)은 별도의 트랜잭션 수준 연동으로 다룬다
  • v3-18: 온체인과 오프체인 데이터 경계. 최소 온체인 설정(정산 상태 + reservePct + fundWallet + kycContract)이다. 규제 우회를 막으려고 kycLevelRequiredkycJurisdictionWhitelist를 온체인에 더한다. 조기 이탈 패널티(RedemptionConfig)도 온체인이다. 컨트랙트가 계산하고 적용한다(패널티 단일 출처는 온체인). 나머지 설정(트랑슈, lifecycle, 마케팅)은 DB에 남고 람다가 강제한다

스키마 추가: tranche_group_id UUID와 tranche_role ENUM(v3-14), loan_writeoffs 테이블(v3-13), IMPAIRED lifecycle 상태 값(v3-12)

스키마 제거: nav_data_source ENUM(v3-15), tranche_structure ENUM(v3-14), escrow_address 컬럼(v3-11)

스마트 컨트랙트 영향: PlatformEscrow.sol 삭제(v3-11). PlatformPool에 proposeImpairment()/executeImpairment()/cancelImpairment() 추가(v3-12), deposit()에 10/90 분할 인라인(v3-11), updateNAV(newNav, reserveConsumed) 시그니처 변경(v3-16), _validateKYC()에 KYC 레벨/관할권 강제(v3-10 + v3-18). claimWindDown() 제거. 표준 redeem()이 청산을 처리한다(v3-12).

번호 관례: D-v3-XXv3-XX(더 짧게)

2026년 5월 2 entries

📅 2026년 5월 29일 — v3.0 차원 모델 (초기 설계)

🧩 설정으로 굴리는 풀. AS_POOL/FUND_POOL 바이너리 폐기

핵심 전환: pool_type enum을 직교하는 차원 약 24개(fund_wallet, reserve_percentage, maturity_model, kyc_level_required 등)로 대체한다. 모든 파트너가 같은 Pool 컨트랙트와 같은 흐름을 쓰고 설정만 다르다.

결정 v3-01부터 v3-10까지(14-decisions):

  • v3-01: pool_type 바이너리 → 차원 약 24개
  • v3-02: LP는 언제나 platform-issued(FUND_ISSUED 폐기)
  • v3-03: PlatformReceiptNFT 제거. 입금이 원자적이다(USDC + LP 민팅 + fund_wallet 분할이 한 tx에)
  • v3-04: 상환 흐름에서 PENDING_RESERVEFM_ACCEPTED를 대체
  • v3-05: 2단계 pause. is_paused(소프트. 입금만)와 is_emergency_frozen(하드. 모든 활동)
  • v3-06: 긴급 청산(60일 무응답 트리거 + 30일 타임락). 온체인으로 회수 가능한 건 Pool 리저브(약 10%)뿐
  • v3-07: collateral_type enum → collateral_description 텍스트와 collateral_ratio 숫자
  • v3-08 nav_data_source 차원 — v3-15가 대체(차원 제거)
  • v3-09 tranche_structure 차원 — v3-14가 대체(연결된 SINGLE 풀로 트랑슈)
  • v3-10: 풀별 KYC. kyc_level_required(KYC/KYB)와 kyc_jurisdiction_whitelist(ISO 코드)

스키마 변경(11-db-schema): 풀 컬럼 16개 추가. maturity_model enum 추가. 폐기: pool_type, escrow_model, yield_distribution_model, lp_issuance_model, risk_tier, collateral_type. Receipt/FM 컬럼은 폐기 표시.

스마트 컨트랙트: PlatformReceiptNFT 제거. PlatformPool에 proposeWindDown() / executeWindDown() / fundRedemption() / setEmergencyFreeze() 추가. PlatformPoolFactory를 온체인 레지스트리와 통합.

Joob 매핑(20-joob-pool-config): 시나리오 둘을 문서화했다. 활성 투자자 풀(이더리움 메인넷, USDC, nav_data_source=ASET_INTERNAL)과 레거시 표시 전용 풀(Kaia, 기존 RPS)이다.

모든 풀에 통일된 것:

  • 수익: 청구 기반이고 yield_trigger = MANUAL만(자동 분배 제거)
  • 에스크로: 언제나 SMART_CONTRACT(레거시 변형 제거)
  • 리저브: 풀별 reserve_percentage이고 Junior 트랑슈와 별개
  • 위험: risk_tier enum 없음. 투명한 데이터 포인트로 대체
📅 2026년 5월 31일 — 2.5단계. 범용 홀더 검증(5-상태 모델)

🪪 5-상태 홀더 모델. 읽기 전용/실제 투자/세컨더리에 두루 통한다

결정: 시나리오 셋을 상태 기계 하나로 묶는 5-상태 홀더 모델(A/B/C/D/E)을 도입한다. 읽기 전용 표시(Joob 레거시), 실제 투자(Aset 직접/Joob 신규), 세컨더리 시장(Tokocrypto)이다. 같은 모델이고 진입/이탈 장치만 다르다.

상태(21-holder-verification):

  • A. 검증된 투자자 — SBT가 민팅됐고 Aset 입금으로 만든 활성 포지션이 있다
  • B. 검증됐고 이력 없음 — SBT는 민팅됐는데 현재 포지션이 없다(아직 없거나 더는 없다)
  • C. 미검증 홀더 — 온체인에 LP/RPS를 들고 있는데 SBT가 없다(예: 다른 지갑에서 전송받았다)
  • D. 신원만 — KYC를 제출했고 아직 승인 전이거나 SBT 민팅 대기다
  • E. 미등록 방문자 — KYC도 포지션도 없이 둘러보는 중이다

3계층 구조:

  • L1 소유 — 온체인 balanceOf()(LP 토큰, RPS, 앞으로의 트랑슈 토큰)
  • L2 신원PlatformKYCSoulbound SBT 속성(레벨, 관할권, 만료)
  • L3 자격 — 지갑 → 풀 → 유효 잔액을 잇는 portfolio_positions의 DB 행

스키마 영향: portfolio_positionssource ENUM(DEPOSIT/TRANSFER_IN/SECONDARY_PURCHASE/LEGACY_SEEDED), entry_price, entry_tx_hash, last_reconciled_at가 생긴다.

시나리오 셋에 모델 하나를 쓰는 이유: "Joob 읽기 전용" 대 "실제 Aset 입금" 대 "Tokocrypto 세컨더리"의 코드 경로가 갈라지는 걸 막는다. 상태 C가 "지갑은 토큰을 들고 있는데 Aset 기록이 없다"를 전부 똑같이 처리한다. 레거시 RPS 전송이든 플랫폼 밖 세컨더리 매매든 복구된 지갑이든 마찬가지다.

2026년 3월 5 entries

📅 2026년 3월 20일 — 풀별 스테이블코인 설정

💱 풀별 스테이블코인 선택과 문서 정정

풀별 스테이블코인: 풀마다 어떤 USD 기반 스테이블코인을 받을지 정한다(USDC, USDT, DAI). pools.accepted_currencies(currency[] 배열, 기본 '{USDC}')에 저장한다. admin이 풀 생성 때 고르고 ACTIVE가 되면 불변이다.

문서 수정: 스키마 문서의 pool_accepted_currencies 조인 테이블은 틀렸다. 실제 구현은 pools.accepted_currencies 배열 컬럼을 쓴다. 문서를 DB에 맞췄다.

투자 흐름: 풀이 여러 개를 받으면 투자자가 스테이블코인을 고른다. 입금은 쓴 통화를 기록한다. 컨트랙트 주소는 체인별이지 풀별이 아니다.

admin UI: 플랫폼 수준 스테이블코인 설정을 제거했다(localStorage에만 있었고 백엔드에 연결된 적이 없다). 풀 생성 위저드의 풀별 선택으로 대체했다.

스마트 컨트랙트: 온체인 스테이블코인 관리를 위해 addStablecoin() / removeStablecoin()을 문서화했다.

📅 2026년 3월 17일 — 스키마 v2.17 R18

🔄 compound() → reinvest() rename과 재투자 V1 정책

Rename: 컨트랙트와 API와 문서 전반에서 compound()reinvest()로 바꿨다. /yield/compound/{distributionId}/yield/reinvest.

BD5 결정: 수동 재투자 V1. 투자자가 "Reinvest" CTA를 누르면 온체인 pool.reinvest(totalYield)를 부르고 새 LP가 민팅된다. 전액만이고(부분 없음) 같은 풀만이며 락업은 면제다.

새 필드: pools에 min_reinvest_amount NUMERIC DEFAULT 50, deposits에 is_reinvestment BOOLEAN DEFAULT false.

📅 2026년 3월 14일 — 스키마 v2.15 R17

🗂️ 풀 분류를 동적 테이블로

pool_category enum을 폐기하고 category TEXT → FK pool_categories(name)로 바꿨다. Notion 스타일 select UI다. pool_categories 테이블과 GET/POST /pool-categories 엔드포인트를 넣었다.

📅 2026년 3월 12일 — 스키마 v2.14 R16

🏦 비수탁 구조와 빈틈 결정 10개 해결

구조: 모든 자금이 Pool 스마트 컨트랙트를 거친다. Aset은 수탁자가 아니라 중개자다. VASP 라이선스가 필요 없다.

폐기: escrow_model(언제나 SMART_CONTRACT), yield_distribution_model(언제나 MANUAL_CLAIM), risk_tier(데이터 포인트 방식으로 대체).

새 필드: collateral_type, collateral_ratio, yield_frequency, yield_trigger, allow_rollover, next_yield_due, yield_overdue.

컨트랙트: PlatformEscrow를 PlatformPool에 합쳤다. 새 함수: claimYield(), reinvest(), mintLP(), fundRedemption(), distributeYield().

브랜드: EF_POOL → AS_POOL, EMERGEFI_ISSUED → PLATFORM_ISSUED, EMERGEFI → PLATFORM(transfer_source).

빈틈 결정: 위험 투명성(빈틈 1), 풀 생성 필드(빈틈 2), 청구 기반 수익(빈틈 3), 에스크로 릴리스 = LP 민팅(빈틈 4), KYC RETRY/FINAL(빈틈 5), FM 온보딩(빈틈 6), 투자자별 조기 패널티(빈틈 7), TVL 상한(빈틈 8), 알림 표(빈틈 9), 감사 보존(빈틈 10).

📅 2026년 3월 — 스키마 v2.13 R15 (admin 플랫폼 릴리스)

📦 admin 플랫폼을 위한 스키마 전면 재구축

v2.13 R15 — 상세 변경 내역

테이블 rename

  • issuersfunds
  • issuer_adminsfund_members
  • pending_receiptsdeposits

컬럼 rename

  • pool_statuslifecycle_status(6상태 enum)
  • fund_walletpool_wallet
  • issuer_idfund_id
  • investment_blocked — 새 의미로 되살림(NAV가 아니라 TVL 상한)

새 테이블 11개

  • admin_users, admin_user_permissions, admin_sessions
  • fund_pool_assignments, pool_accepted_currencies, pool_tvl_history
  • yield_distribution_investors
  • notification_logs, notification_preferences
  • platform_config, activity_events

새 enum 13개(기존 6개에 추가)

  • pool_type, lifecycle_status, escrow_model, yield_distribution_model, lp_issuance_model
  • penalty_type, yield_status, sbt_status, reject_type
  • notification_channel, notification_status, notification_failure_type, transfer_source

제거된 테이블 3개

  • platform_adminsadmin_users에 병합
  • fund_manager_assignmentsfund_members로 대체
  • kyc_submissions — KYC 필드를 users 테이블에 병합

호환성 깨는 변경

  • LP 발행: AUTO/MANUAL → FUND_ISSUED/PLATFORM_ISSUED
  • 상환: 2단계 admin 승인 → pool_type별 흐름(변형 4가지)
  • 상환 상태: 레거시 APPROVED 제거 → 단일 단계(Admin 승인), FM_ACCEPTED/FAILED 추가
  • 펀드 상태: 3상태(ACTIVE/PAUSED/OFFBOARDED) → 2상태(ACTIVE/INACTIVE)
  • 입금: receipt_status → deposit_status(5상태)
  • pools에 investment_blocked BOOLEAN DEFAULT false 재추가. 더는 NAV 기반이 아니다. 이제 TVL이 capacity 이상이면 자동으로 true가 된다. is_paused를 통한 수동 admin 토글은 별개로 남는다.

2026년 2월 1 entry

📅 2026년 2월 — 갱신 5건

✅ 2026-02-20 — PD1–PD4 결정과 빈틈 보고서 수정(v6)

오라클 결정을 확정했다. PD1은 PlatformPool에 통합, PD2는 ORACLE_ROLE, PD3은 Aset이 운영, PD4는 폴백 없음(비상시 admin이 풀을 pause). 컨트랙트에 ORACLE_ROLE을 넣었다. 빠져 있던 DB 필드 13개를 넣었다. yield_distributions 자리표시 테이블을 넣었다. NAV 갱신 상태 기계를 넣었다. 계획된 API 엔드포인트 10개 이상. NAV 긴급 오버라이드를 제거했다. 투자 lifecycle에 3단계 상환을 넣었다.

🔄 2026-02-19 — 제품 명세 동기화와 안전 범위 단순화

PD6(NAV 안전 범위)을 제거했다. 하한도 없고 tx당 한도도 없으며 NAV 상한 $1.0만 있다. PD1 권고를 갱신했다. 별도 오라클 컨트랙트보다 PlatformPool 통합을 권장한다. GitHub 저장소에서 기술 스택을 확인했다. API 엔드포인트는 15개 구현, 나머지는 계획이다.

🔄 2026-02-19 — 결정 시스템 개편(배치 5)

DECISIONS 데이터 모델을 BD1–BD6 + PD1–PD5로 올리고 dependsOn, blocks, referencedIn 스키마를 넣었다. 비즈니스 결정과 제품 결정을 통합 Decisions 페이지로 합쳤다. 사이드 패널이 이제 의존 그래프를 보여 준다.

🗂️ 2026-02-11 — 문서 사이트 재구성(배치 4)

가로 탭 내비게이션을 사이드바 내비게이션 문서 사이트로 바꿨다. 개요와 구조, 핵심 개념, 스마트 컨트랙트, API 레퍼런스, 결정, 타임라인, 체인지로그 절을 넣었다. 상각과 상환을 별개 절 둘로 나눴다. 슬라이드 아웃 패널이 붙는 결정 배지 체계를 넣었다.

📐 2026-02-15 — NAV 모델 이관(v5)

원금 계수(PF) 승수를 단일 손실 지표인 NAV(nav_per_token)로 대체했다. NAV < $1.00일 때 투자가 자동으로 막히지 않는다. 새 투자자는 공정한 현재 가격을 낸다. 상각 공식을 전반적으로 갱신했다. NAV 변동은 더는 투자를 자동으로 막지 않는다. 새 입금을 막는 건 is_paused(수동)와 investment_blocked(TVL 상한)뿐이다.

2026년 1월 1 entry

📅 2026년 1월 — 갱신 2건

👥 2026년 1월 — RBAC 갱신. 펀드매니저 역할 추가(v4)

펀드매니저를 세 번째 역할로 넣었다(활성 펀드 범위 운영). Admin과 Operator 구분을 명확히 했다. 권한 표를 갱신했다.

⏳ 2026년 3월 — 단일 단계 상환(v4)

상환을 단일 단계로 단순화했다. Admin이 승인하고 리저브를 확인하고 지급한다. Operator 추천 단계는 제거해서 approveRedemption에 합쳤다. FIFO 큐를 강제한다. NAV 스냅샷은 요청 시점에 고정된다.

2025년 12월 1 entry

📅 2025년 12월 — 갱신 1건

🚀 2025년 12월 — 백엔드 로직 맵 최초 공개(v1)

투자 lifecycle, 상태 기계, 데이터베이스 스키마, 비즈니스 결정을 담은 첫 판이다. GitHub 연동으로 Vercel에 배포했다.