데이터베이스 스키마 개요
Aset 데이터베이스는 RLS(Row-Level Security)를 쓰는 Supabase(PostgreSQL 15+)다. v2.17 스키마 위에 v3.0 차원 모델을 얹었다.
✅ 배포된 Supabase DB와 동기화됨
테이블 51개 · 뷰 8개 · enum 28개 · 함수 8개 · 마이그레이션 0205까지이고, dev 인스턴스의 information_schema / pg_type / pg_proc에 대해 2026-08-21에 셌다(0205까지 적용되고 기록됐으며 2026-08-21에 information_schema와 pg_indexes, 그리고 마이그레이션 행을 정확한 name으로 대조해 확인했다). 아래 테이블별 절 뒤에 있는 객체 단위 검증은 2026-08-11에 0174 시점으로 했다. 그 뒤의 순증감은 이렇다. 0197이 external_pool_* 테이블 넷과 external_pool_dpd_latest 뷰를 삭제했고, 0195가 그것들을 대체한 report_* 테이블 셋을 추가했다. 이제 public이 데이터베이스 전부다. money-flow 리팩터링 6단계가 deposits, redemption_requests, yield_claims, redemption_fills를 삭제했고 원장과 그 파생 뷰가 그것들을 대체했다(0165). 리셋 이전 스냅샷이던 legacy 스키마도 라이브 풀 이력을 옮긴 뒤 0174에서 함께 사라졌다. 그 이동에서 살아남지 못한 것은 마이그레이션에 적혀 있고, 적은 양이 아니다. 최근 마이그레이션은 아래 표에 있고, 마이그레이션별 전체 로그는 그 아래에 접혀 있으며, 날짜별 서술은 17-changelog에 있다.
개념 모델(24개 차원)은 풀 모델 참조.
최근 마이그레이션
| 마이그레이션 | 적용(dev) | 변경 | Δ 스키마 |
|---|---|---|---|
0218 | dev 적용 대기 | pools.accrual_rate_bps가 nullable이 되고 DEFAULT 0도 없어진다. 0216이 연 v3-154 배타의 나머지 절반이다 — TARGET 풀은 수익이 실적을 따르므로 요율을 약정하지 않고, 애초에 가질 수도 없다(PlatformPoolTarget에는 온체인 accrualRateBps가 없고 packages/money-contract가 그 경계를 고정한다). 🔴 그래서 이 NULL은 장식이 아니다 — resolveYieldMode가 정확히 이 컬럼을 읽어 createFixedPool/createTargetPool을 고르고, 풀은 Clones라 그 선택이 영구다. 모드 컬럼을 따로 두지 않은 이유는 마스터의 금지 항목이자 이 회차가 반복해 겪은 것 — 같은 사실의 두 번째 사본은 첫 번째와 갈라질 자유가 있다. 🔴 NULL ≠ 0(0은 *"아무것도 안 준다"*고 약정한 FIXED 풀이고, 배포되고 revert도 안 한다). DEFAULT 0을 같이 없앤 건 미지정을 조용히 *"FIXED 0%"*로 바꾸면 모호함이 그대로 돌아오기 때문이다 | NOT NULL·DEFAULT 해제 |
0217 | dev 적용 대기 | pool_governance_changes.change_type에 RESERVE_WALLET 추가. 🔴 컨트랙트가 먼저 할 수 있었다 — proposeReserveWalletChange/execute/cancel과 7일 타임락이 00943f68에, ReserveWalletChanged 미러가 0215에 이미 있었고, 빠진 것은 어드민이 요청할 방법뿐이었다(타입 유니온·검증기·온체인 디스패치·이 CHECK가 전부 5종만 알고 있었다). 그래서 리저브 지갑을 잘못 넣고 배포한 풀은 제품 경로로는 못 고치는데 **화면은 "생성 시 확정"이라고 말하고 있었다 — 제품에는 참이고 컨트랙트에는 거짓인 문장이다. 타임락·propose/execute 쌍·취소 창은 전부 컨트랙트 것이고 이 행은 의도의 기록일 뿐이다. 백필 없음(쓸 수 없던 값을 가진 행은 없다) | CHECK 값 +1 |
0216 | dev 적용 대기 | pools.apy_rate가 nullable이 된다. 2026-08-27 결정(v3-154)이 *"발생 모드에 따라 accrual_rate_bps와 배타적"*이라고 정해놓고 마이그레이션이 없던 자리다. apy_rate는 화면에 크게 찍히는 표시용 수치이고 FIXED 드라이버가 청구하는 기준은 accrual_rate_bps다. 두 모드가 서로 다른 요율을 약정하므로(TARGET 풀은 온체인에 accrualRateBps가 아예 없다) 둘 다 요구하면 하나는 지어내야 한다. 🔴 NULL은 0이 아니다 — 0%는 풀이 *"아무것도 안 준다"*고 주장하는 것이고 NULL은 *"표시 요율을 공표하지 않았다"*다. 읽는 쪽이 먼저 들어갔다(566ce775, formatApyRate가 —를 낸다). pools.post.create의 필수 목록에서도 빠진다 | NOT NULL 해제 |
0209 | dev 적용 대기 | 주석만 바꾼다. 2026-08-24에 컬럼 주석 둘이 낡았다. pools.start_date가 수익 스케줄을 여기서부터 센다고 적고 있었고, pools.subscription_end_date는 자신을 모집 마감일 그 이상으로 설명하지 않았다. 마감일은 이제 풀 타임라인의 원점이다. 만기, pool-wide 락업, 수익 격자가 전부 거기서부터 센다(v3-145). 그래서 그것을 옮기면 셋이 함께 옮겨 가고, FIXED_TERM 풀은 마감일 없이 발행할 수 없다. DDL도 없고 건드린 행도 없다. | 없음 |
0205 | 2026-08-21 적용(SQL 에디터) · 추적 행 기록됨 | pools.pool_implementation과 pools.pool_factory. 풀이 어느 컨트랙트 세대에 고정돼 있는지다(v3-142). 풀은 구현체가 immutable인 Clones 프록시라 규칙이 생성 시점에 얼어붙는데 그것을 기록하는 것이 아무것도 없었다. 답을 알려면 프록시 바이트코드를 읽는 수밖에 없었다. pools.worker.deploy가 factory.poolImplementation()에서 읽어 쓴다. 기존 행은 의도적으로 NULL로 둔다. 이보다 앞선 모든 풀은 2026-08-21의 reopen 구현체보다도 앞서므로, NULL이 이미 "reopen 불가"를 뜻한다. | +컬럼 2, +인덱스 1 |
0204 | 2026-08-20 수동 적용(SQL 에디터) · 추적 행은 2026-08-21 기록 | pools.subscription_end_date. 모집 마감일이 만기와 컬럼을 공유하지 않게 됐다(v3-141). ⚠️ 적용할 때는 번호가 0194였고, ch/product에 0194~0203이 있다는 것이 밝혀지면서 번호를 다시 매겼다. 수동 적용이라 하루 동안 supabase_migrations.schema_migrations 밖에 있었고 도구는 미적용으로 읽었다. 2026-08-21에 0205와 함께 행을 추가했다. ⚠️ 행이 있다고 말하는 확인은 그 조건식만큼만 믿을 만하다. 이 줄의 이전 판은 행이 이미 기록됐다고 주장했는데, 그 근거가 된 쿼리의 version LIKE '%0204%'가 2월 타임스탬프에 매칭됐다. 번호가 아니라 name으로 매칭할 것. | +컬럼 1, +제약 1, +인덱스 1 |
0203 | 2026-08-20 적용 | pools.is_display_only → is_showcase. 컬럼 하나가 두 뜻을 담고 있었다. 원래 명세는 커스터디 수준이었고(지금은 custody_mode, 0199) 프론트엔드가 그 위에 v3-40의 마케팅 등급을 얹었다. UI가 구현한 의미로 이름을 바꿨다. preview는 B5의 초안 미리보기 토큰이 쓰고 있어서 showcase로 했다. 이를 설정한 풀이 하나도 없었으므로(8개 중 0개) 바뀐 화면도 없다 | 이름 변경 |
0202 | 2026-08-20 적용 | 데이터만 바꾼다. 워크플로 행이 없던 시드 DEPOSITED 이벤트마다 money_deposit_workflow 행을 만들었다(4건). money_positions가 운영 평면에 없는 홀더를 갖고 있어서, 어드민 풀 상세가 빈 탭 위에 투자자 2명을 표시했다. risk_ack는 NULL로 뒀다. 기존 시드 행들이 지어낸 동의 기록을 담고 있다 | +행 4 |
0201 | 2026-08-20 적용 | nav_history.attested_off_chain. MIRROR 풀의 NAV는 온체인 updateNAV 없이 기록되고(D6), APPLIED 행이 체인이 뒷받침하는 행과 헷갈리면 안 된다. 새 source 값이 아니라 자기 컬럼으로 둔 이유는 투자자 Performance 탭이 source = 'oracle'로 분기하기 때문이다 | +컬럼 1 |
0200 | 2026-08-19 적용 | funds.external_provider와 external_fund_id 삭제. 0028이 매핑을 풀 단위로 옮긴 뒤로 읽는 곳이 0이었다. ⚠️ pools.fx_rate와 fx_rate_source도 이 목록에 있었는데 남긴다. 앞선 조사가 admin-web을 놓쳤고, 거기서 그 값들을 렌더하고 수정한다 | −컬럼 2 |
0199 | 2026-08-19 적용 | pools.custody_mode(PLATFORM | MIRROR). 미러 풀의 자본 경로는 양쪽 방향 모두 닫히지만 읽기 모델은 동일하게 유지된다. 당시 이름이 is_display_only였던 것이 아니다. 그쪽의 프론트엔드 의미는 Fund Data 탭을 숨기는 것이다(0203 참조) | +컬럼 1, +체크 1 |
0198 | 2026-08-19 적용 | money_events.origin과 origin_ref, 그리고 파생 이벤트용 부분 유니크 인덱스. 체인이 아닌 행 25건을 SEED로 백필했다. 앞선 두 번의 표시가 서로 달랐다(6건 대 25건) | +컬럼 2, +인덱스 1 |
0197 | 2026-08-19 적용 | 🔴 최신값 upsert 방식의 파트너 저장소를 폐기했다. external_pool_data_snapshots·_data_cache·_dpd_buckets·_nonperforming과 _dpd_latest 뷰를 삭제했다. report 평면이 대체한다. loan_writeoffs는 의도적으로 남긴다 | −테이블 4, −뷰 1 |
0196 | 2026-08-19 적용 | report_events가 속성도 담는다. value를 nullable로 바꾸고 text_value와 둘 중 하나 CHECK를 추가했으며 unit에 TEXT와 TIMESTAMP를 더했다. 읽기 페이로드가 이름과 날짜를 보여 주는데 둘 다 측정값이 아니다 | +컬럼 2, +체크 2 |
0195 | 2026-08-19 적용 | report 평면이다. report_fetches(성공과 실패를 포함한 모든 API 호출) · report_events(append-only 관측) · report_pool_latest(프로젝션)다. 제공자가 이력을 다시 쓰므로, upsert된 행은 재작성이 대체하는 것을 파괴한다 | +테이블 3, +인덱스 4 |
0194 | 2026-08-19 적용 | guest fund-data API의 마스터 표면이다. pools.external_entity_type, 스냅샷의 nullable fund_value/realized_income와 snapshot_trigger/composition_version, DPD 구간의 pct_of_outstanding, external_pool_nonperforming(폐기된 bucket=90), 그리고 external_pool_dpd_latest 뷰다 | +테이블 1, +뷰 1, +컬럼 5, −NOT NULL 2 |
0193 | 2026-08-18 적용 | 완료된 상환이, 뷰가 지급액을 읽지 못할 때 0을 주장하지 않게 됐다(v3-140 정리) | 뷰만 |
0192 | 2026-08-18 적용 | pools.epoch_cycle_mode를 읽지 않는 것으로 표시했다. 주석만 바꿨고 DDL도 행 변경도 없다(v3-140) | 주석만 |
0191 | 2026-08-18 적용 | 🔴 security_invoker를 잃었던 뷰 둘에 그것을 복원했다. 펀딩된 회차가 상환 행의 키가 되지 않게 했다. audit_feed가 NULL 때문에 문장을 통째로 잃지 않게 했다 | 뷰만 |
0190 | 2026-08-17 적용 | redemption_epochs.funding_date. 체인이 실제로 받은 날짜라 유도된 날짜와 구분된다(v3-133) | +컬럼 1 |
0189 | 2026-08-17 적용 | pools의 상환 계획이다. redemption_term_epochs · epoch_date_basis · epoch_roll_day(v3-132) | +컬럼 3 |
0188 | 2026-08-18 적용(0191이 함께 반영) | money_redemption_list.payout_amount가 정산 전에도 유도된다. forward pricing 요청은 NULL이다 | 뷰만 |
0183 | 2026-08-13 | hold-back 버킷이 그 레버와 함께 삭제됐다. 원장 kind 둘은 제거하는 대신 쓸 수 없게 만들었다 | −컬럼 1, +체크 1 |
0182 | 2026-08-13 | money_redemption_list에서 PENDING_RESERVE가 뒤집혀 있던 것을 바로잡고 is_held를 NULL 없이 추가했다 | 뷰만 |
0181 | 2026-08-12 | money_positions가 money_pool_state에 이미 있던 음수 금지 CHECK를 갖게 됐다 | +체크 1 |
0180 | 2026-08-12 | stablecoin_registry.decimals가 DEFAULT 6을 잃었다. 입금 목록이 스케일을 추측하지 않게 됐다 | −기본값 1, 뷰 |
0179 | 2026-08-12 | 표시 전용 풀의 포지션을 행이 아니라 원장 이벤트로 다시 시드했다 | 데이터만 |
0178 | 2026-08-12 | portfolio_positions 삭제. 포지션은 프로젝션에서 읽는다 | −테이블 1, +컬럼 1 |
0177 | 2026-08-12 | 프로젝션이 수익 누적값을 저장해, 청구 가능 수익을 정지 상태에서도 유도할 수 있게 됐다 | +컬럼 2 |
0176 | 2026-08-12 | pools.deployed_block이 그것을 추가한 이유였던 재생과 함께 삭제됐다 | −컬럼 1 |
0175 | 2026-08-12 | 분배가 원장에 합류했다. yield_distributions를 삭제하고 워크플로 행과 뷰로 대체했다 | −테이블 1, +뷰 1 |
0173 | 2026-08-11 | 풀 하드 삭제가 0172에서 삭제된 테이블을 더 이상 지목하지 않고 pool_tvl_history를 정리한다 | 함수 1 |
0172 | 2026-08-11 | money_tvl_daily 삭제. TVL 이력은 일일 스냅샷으로 남는다 | −테이블 1 |
0171 | 2026-08-11 | 상환 실패 알림이 revert된 제출을 세게 됐고, 볼 수 있는 것에 맞게 이름이 바뀌었다 | 뷰만 |
0170 | 2026-08-10 | 검증 재생용 그림자 스키마 삭제 | −스키마 1 |
0169 | 2026-08-10 | pools의 죽은 컬럼 셋. 풀별 누적 수익은 대신 유도한다 | −컬럼 3 |
0174 | 2026-08-11 | 라이브 풀 이력을 옮긴 뒤 legacy 스키마 삭제 | −스키마 1 |
0168 | 2026-08-10 | legacy를 유지한다. 0174가 대체했다 | 주석만 |
0167 | 2026-08-10 | 0166의 삭제 둘을, 실제 시그니처로 다시 수행 | 함수 2 삭제 |
0166 | 2026-08-10 | complete_deposit_atomic / process_deposit_with_tvl(시그니처가 틀렸다. 0167 참조) | 무효 |
0165 | 2026-08-10 | 원장이 대체한 테이블 넷과, 잔액을 쓰던 RPC 다섯 | −테이블 4, 함수 5 |
0164 | 2026-08-10 | 하드 삭제 가드와 회차 요약이 파생 목록을 읽는다 | 함수 2 |
0163 | 2026-08-10 | dashboard_alert_counts가 원장을 읽는다. 대기 큐가 0을 보여 주고 있었다 | 뷰만 |
0162 | 2026-08-10 | 취소된 상환은 FAILED가 아니라 REJECTED다 | 뷰만 |
0161 | 2026-08-10 | pools.maturity_date. 만기는 각 보유분이 아니라 풀에 속한다 | +컬럼 1 |
0160 | 2026-08-10 | money_pool_state.nav_per_token을 nullable로. 가격이 없는 것은 par 가격이 아니다 | nullable |
0159 | 2026-08-10 | 활동 피드의 CLAIM 분기가 원장을 읽는다 | 뷰만 |
0158 | 2026-08-10 | 재투자가 자기 축을 달고 입금 목록에 돌아왔다 | 뷰만 |
0157 | 2026-08-10 | yield_claim_list | +뷰 1 |
0156 | 2026-08-10 | 활동 피드의 회차 체결이 원장에서 온다 | 뷰만 |
0155 | 2026-08-10 | 상환 목록이 회차 체결을 유도한다 | 뷰만 |
0154 | 2026-08-10 | money_redemption_workflow.epoch_id. 원장이 재구축할 수 없는 유일한 사실이다 | +컬럼 1 |
0153 | 2026-08-10 | 상환 목록의 amount는 LP이고 절대 null이 아니다 | 뷰만 |
0126 | 2026-08-06 | API 입금 경로가 출처를 기록하고, 도달 불가 함수 둘이 삭제됐다 | 함수 2 삭제 |
0125 | 2026-08-06 | portfolio_positions.entry_price에 취득원가 | +컬럼 1 |
0124 | 2026-08-04 | nav_proposals.escalate_flag 주석 | 주석만 |
0123 | 2026-08-04 | pools.is_hidden | +컬럼 1 |
0122 | 2026-08-04 | 수동 NAV 경로가 손실을 받으면서 버퍼가 풀 하나만의 기능이 아니게 됐다 | +컬럼 2 |
0121 | 2026-08-04 | pools에 equity_buffer_prose_requires_layer CHECK | CHECK만 |
0120 | 2026-08-04 | nav_proposals.recovery_flag 삭제 | −컬럼 1 |
0119 | 2026-08-04 | pools에 buffer_requires_external_mapping CHECK | CHECK만 |
0118 | 2026-08-04 | pools의 파트너 리포트 설정 | +컬럼 6 |
0117 | 2026-08-04 | pools.lp_total_supply | +컬럼 1 |
0116 | 2026-08-04 | pools.junior_depleted_at | +컬럼 1 |
0115 | 2026-08-04 | epoch_window_subscriptions | +테이블 1 |
0114 | 2026-08-04 | dashboard_alert_counts.stalled_yield가 이제 deposit_tx_hash IS NOT NULL을 요구한다 | 뷰만 |
0113 | 2026-08-03 | yield_distributions.status는 절대 PENDING일 수 없다 | 뷰 + CHECK |
0112 | 2026-08-03 | redemption_epochs 라벨 정정 | −컬럼 1 |
Δ 스키마는 마이그레이션 SQL 자체에서 센 값이다. 이 구간에서 테이블 수를 움직인 것은 0115뿐이므로 51/27 헤더가 열다섯 건 전체에 대해 성립한다.
마이그레이션별 전체 로그: 0001~0126, 원문 그대로
의도적으로 온전히 남겨 둔다. 이 중 19건은 문서의 다른 어디에도 기록돼 있지 않아 여기가 유일한 자리다. 최신 순이다.
⚠️ 이 로그는 0152에서 멈춰 있고 저장소는 0180에 있다(2026-08-12 확인). 0153~0179는 money-flow 리팩터링 중에 들어왔고 여기 서술돼 있지 않다. 그중 여럿이 이 페이지가 아직 문서화하고 있는 테이블을 삭제했다(
portfolio_positions는 0178,deposits와redemption_requests는 0174). 아래 테이블 목록은 현행인apps/infra/db/schema.sql과 일치하는 범위에서만 정확한 것으로 다룰 것. 둘을 다시 맞추는 것은 다음 스키마 변경의 부산물이 아니라 그 자체로 하나의 작업이다.0191 (2026-08-17 작성, 아직 미적용): 🔴 이것부터 적용할 것.
WITH절 없는CREATE OR REPLACE VIEW <name> AS는 뷰의 reloptions를 비워서security_invoker를 끄고 뷰가 소유자(BYPASSRLS를 가진postgres)로 실행되게 만든다.0180이money_deposit_list에 그렇게 했고(0158에서 플래그를 달고 만들어졌다)0182가money_redemption_list에 그렇게 했다. 실패한 것도 없고 바뀐 컬럼도 없이 둘은 그때부터 SECURITY DEFINER 뷰였고, Supabase 린터가 이를 ERROR로 보고한다. 브라우저 번들에 실려 나가는 publishable key로 dev에서 측정했다./rest/v1/money_redemption_list가 9행 중 9행,money_deposit_list가 7행 중 7행(서로 다른 홀더 4명),investor_activity가 16행 중 16행(자기는 플래그가 있지만 앞의 둘을 읽는다),audit_feed가 248행 중 18행(입금과 상환 분기)을 반환했다.money_distribution_list는 0행을 반환하는데 다른 것들도 그래야 한다. 즉 공개 키를 가진 누구에게나user_id,investor_address, 금액, 지급액, tx 해시가 나간다. 플래그를 복원하면anon과authenticated에게 0행이 반환된다. 모든 기반 테이블이 RLS ON에 정책 0개이기 때문이고, 그것이 의도한 자세다. API는 서비스 role(BYPASSRLS)로 Lambda를 통해 읽고, 어떤 클라이언트도 데이터를 위해 PostgREST를 쓰지 않는다. admin-web의 Supabase 클라이언트는 Google OAuth 브로커이고.from()을 호출하지 않는다.0188도 같은 누락을 안고 있었고 같은WITH절을 받았으므로 적용 순서는 어느 쪽이든 안전하다.apps/infra/db/schema.sql은 처음부터 이를 맞게 갖고 있었는데, 그 파일이 문서가 아니라 도구라는 점의 유용한 절반이 그것이다.0191 계속: 이 정리가 실제로 찾던 결함 둘이고 주제가 하나다. 뷰가 없는 것을 있는 것의 모양으로 내보내는 것이다. (1)
money_redemption_list가 펀딩된 모든 회차에서 상환을 지어내고 있었다.fundRedemption의 회차 분기는 넘겨받은 요청 id를 무시하고epochFundTopUp[currentEpochId]를 크레딧하므로(RedemptionLib.sol:561-582) 제품이 의도적으로0을 넘기는데, id가 존재하는지만 필터링하던keyedCTE가 그것으로 키를 만들었다. dev에서의 결과:QA Epoch 260812에 실제 홀더 옆으로 유령 행이 하나 붙었고, 투자자도 금액도 없고created_at이 NULL이라 어드민 큐 맨 위로 정렬됐다.WHEN fund.id IS NOT NULL이 회차 분기보다 두 단계 위에 있어서PROCESSING으로 읽혔는데, 그게 더 나쁜 절반이다. 파트너가 실제 id로 회차를 펀딩하면(막는 것이 없다) 진짜 홀더의 QUEUED 요청이 움직인다. 양쪽 다 필요하다. CTE는requestId = '0'만 제외하고(redemptionRequestCounter가 선증가하므로 실제 id일 수 없다,RedemptionLib.sol:275-276) kind 목록에REDEMPTION_FUNDED를 유지한다. 요청 이벤트가 도달하지 못한 즉시 요청은 펀딩만으로 키가 잡히기 때문이다. 상태 분기는0182가 고쳐 둔 위치를 유지하면서 즉시 풀로 범위를 좁힌다. 🔴 살아 있는 id를 넘기는 것은 해법이 아니다. 회차 규모의 이체를 임의의 대기 요청 하나에 붙이게 된다. (2)audit_feed가'text' || NULL때문에 문장을 통째로 잃고 있었다. DEPOSIT과 YIELD 분기가 nullable numeric을 이어 붙이는데(money_deposit_list.amount는 처리 중이거나0180에 따라 미등록 스테이블코인일 때 NULL이고,money_distribution_list.total_amount는 nullable 소스 둘을 COALESCE한다), 그래서 행이 id와 행위자와 메타데이터는 유지하고 사람이 읽을 수 있는 유일한 필드를 잃었다. 게다가 목록 검색은description만으로 매칭하므로 그 행을 아예 찾을 수 없었다. 이제 둘 다 NULL로 분기해 어떤 부재인지 말한다. dev에 대해 읽기 전용으로 확인했다. 유령 행 제거는 정확히 1행이고 추가는 0행이며 정상 행의 상태는 하나도 건드리지 않았다.audit_feed재작성은 현재 데이터의 248행 17컬럼 전체에 대해 비트 단위로 동일하다(오늘 NULL description을 가진 행이 0개라 그 절반은 잠재적이다. 입금 사례는 본질적으로 일시적이라 눈에 띈 적이 없었다).0188 (2026-08-14 작성, 아직 미적용):
money_redemption_list.payout_amount가 펀딩을 기다리는 모든 요청에서 NULL이었다. 소스 둘이 다 정산 이벤트였고(REDEMPTION_COMPLETED/_FALLBACK_CLAIMED, 그다음REDEMPTION_CLAIMED) 펀딩을 기다리는 요청에는 둘 다 없기 때문이다. 빈 칸으로 끝나지 않았다. 어드민 펀딩 플로우가 그 값을 쓰지도 않는 top-up 계산 전에 그것을 가드로 검사해서, 온체인 게이트가 전부 열린 풀에YIELD_DEPOSITOR_ROLE을 가진 펀드매니저가 "Redemption payout amount is missing or zero"로 거부당했다.0182와 같은 계보이고 컬럼 하나 옆이다. 삭제된redemption_requests에서 참이던 것을 뷰가 물려받았는데, 거기서는payout_amount가 요청 시점에 쓰이는 컬럼이었다. 세 번째COALESCE분기가 요청 이벤트에서 이를 유도한다. 그 이벤트는 옆 컬럼들이 읽는 세 항을 이미 담고 있다.lp x nav - penalty다(v3-84가 모든 패널티를 원금 쪽에 둔다). 추론이 아니라 체인에 대조해 확인했다. NAV 1.000000에서 LP 5개에 패널티 250000을 빼면 4.75이고 그게 컨트랙트에 저장된stablecoinAmount다. dev에서 정산된 한 행은 기록된 1200에 대해 1200을 유도한다. ⚠️ 이 분기가 피해 가도록 설계된 함정이 둘이다.penaltyAmount는 한 이름 아래 축이 둘이다. 18자리 총액의 몫으로 저장되지만(RedemptionLib:413)_denormalize를 거쳐 발생하므로 페이로드는 raw 스테이블코인이고, 그래서 1e18이 아니라 토큰의 소수 자릿수로 나눈다. 그리고navAtRequest가 0이면 분기가 0이 아니라 NULL을 낸다. 회차 요청은 forward pricing이라 가격 없이 발생하므로, 그대로 곱하면 LP 5개를 가진 홀더에게 $0 지급액을 제시하게 된다.e145fa2c가 고친 결함이자0160이 par 대신 NULL을 고른 이유다. 분기 순서가 중요하다. 이 분기가 마지막이라 부분 체결이나 완료가 원래 견적을 덮어쓴다.
0180 (2026-08-12 작성, 아직 미적용):
money_redemption_list뷰에 대한 수정 둘이고, 둘 다 삭제된redemption_requests에서는 참이고money_redemption_workflow에서는 거짓인 가정에서 나왔다. (1)status에서 "파트너 펀딩 대기"와 "파트너가 이미 지급함"이 뒤바뀌어 있었다. 분기가REDEMPTION_FUNDED를 키로 삼았는데, 그것은fundRedemption()이 발생시키고 즉시 경로에서는 요청이 이미 온체인 PENDING_RESERVE일 때만 받아들여진다. 그래서 그 이벤트는 대기가 끝난 순간을 표시했다. 대기 상태에는 원장 이벤트가 아예 없고funding_shortfall만 있으므로 이제 뷰가 그것을 읽는다.PROCESSING이 처음으로 도달 가능해졌다. (2) 새is_held컬럼이고COALESCE(funding_status = 'HELD', false)다. 이동 과정에서funding_status가DEFAULT 'FUNDED'를 잃어서, 목록 엔드포인트의funding_status <> 'HELD'필터가 평범한 요청마다 NULL로 평가됐고 회차 Open·Rollover 큐가 아무것도 반환하지 않았다. 🔴 Lambda를 배포하기 전에 이것을 적용할 것. Lambda가is_held로 필터링하는데 뷰에 없는 컬럼에 대해 PostgREST가 400을 낸다.
✅ 0206은 2026-08-21 dev에 적용됐다. money_positions.accrued_yield, money_positions.yield_debt, money_pool_state.acc_yield_per_share가 라이터 없이 DEPRECATED이고, col_description을 되읽어 확인했다. 이들은 컨트랙트의 지분당 누적값을 미러링해 홀더의 청구권을 오프체인에서 재계산할 수 있게 했는데, v3-131 (3)이 그 메커니즘을 없앴고 읽기 경로는 풀에 묻는다. 🔴 삭제가 아니라 주석 처리이고, 그 주석이 보호의 전부다. 아무도 쓰지 않는 컬럼도 SELECT에는 답한다. 라이터가 멈춘 시점의 값으로 답하는데, 이 스키마가 만들어 낼 수 있는 가장 그럴듯한 틀린 숫자다. DROP은 실제 정산이 한 번 지나간 뒤의 별도 변경이다. money_positions.yield_claimed는 남는다. YieldClaimed에서 fold되고 getPosition(investor).yieldClaimed의 실제 미러이며, 여전히 어긋날 수 있는 유일한 수익 수치이자 yield.scheduler.reconcile이 이제 비교하는 값이다.
✅ 0208은 2026-08-21 dev에 적용됐다. pools.exit_window_anchor이고 information_schema에 대조해 확인했다(timestamp with time zone, nullable, 주석 있음). 개수는 그대로다. 컬럼 하나이고 타입도 테이블도 늘지 않는다.
freeze_started_at은 두 창을 다 담을 수 없었다. emergencyFreeze()가 호출할 때마다 이를 다시 찍는데, DEPOSIT 중단에는 맞는 동작이다(새 긴급 상황에는 새 중단이 맞다). 하지만 EXIT 차단까지 다시 찍으면 그것이 갱신 가능해진다. PAUSER_ROLE 키 하나가 71시간마다 호출하면 가치 유출이 영원히 닫히고 claimRedemptionFallback도 포함되는데, 그것이 v3-31 이탈권의 유일한 온체인 구현이다. 체인에서는 이탈 앵커가 두 창이 다 지나야만 전진하고(LifecyclePolicy.armExitWindow), 인덱서와 pools.post.freeze가 business/freeze-state.ts의 armExitWindowAnchor로 같은 규칙을 적용한다. 재동결 시 다시 찍지 않고 해제 시 지우지도 않는다. 지우면 동결 → 해제 → 동결로 즉시 재무장할 수 있다. NULL은 미러링되지 않았다는 뜻이고, deriveFreezeState가 freeze_started_at으로 폴백하는데 exitWindowAnchor가 없는 구현체에 고정된 풀에서는 그것이 곧 체인의 동작이다.
✅ 0207은 2026-08-21 dev에 적용됐다. pools.accrual_rate_bps이고 information_schema에 대조해 확인했다(integer, NOT NULL DEFAULT 0, CHECK 0..10000, 주석 있음). 테이블과 enum 개수는 그대로다. 컬럼 하나를 더하고 타입도 테이블도 늘지 않는다.
⚠️ 0204는 아직 기록되지 않았다. 그 파일 헤더가 이유를 설명한다. 번호가 0194이던 동안 SQL 에디터로 dev에 수동 적용됐고, 그래서 supabase_migrations.schema_migrations에 행이 없고 도구가 미적용으로 읽는다. pools.subscription_end_date는 dev에 실제로 존재하고 그 설명과 맞다. 그 파일의 모든 구문이 재실행 가능한 것은 정상 경로로 적용하면 행이 기록되게 하기 위해서다. 건너뛰면 적용된 마이그레이션이 다음 사람에게 미적용으로 보인다. 🔴 그래서 0205가 0204보다 먼저 기록됐다. 스키마가 아니라 추적 테이블의 실제 순서 공백이고, 0204를 실행하는 순간 닫힌다.
이 페이지는 배포된 Supabase 스키마를 반영한다(테이블 51개 · enum 28개 · 뷰 8개. 0187까지의 개수는 2026-08-13에 확인했고, 0194~0197 (모두 2026-08-19 적용) 이 report_* 테이블 셋을 더하고 external_pool_* 테이블 넷과 external_pool_dpd_latest 뷰를 삭제했으며 적용할 때마다 information_schema에 대조해 확인했다. 0188~0193은 기록됐지만 전부 재검증하지는 않았다).
- 0187 (2026-08-13 dev 적용): 죽어 있던
money_event_kind라벨 셋이 사라졌다.HELD_FROM_PARTNER/HOLDBACK_RELEASED(hold-back 레버, 0183)와FUNDING_RETURNED(0184, 한 시간 만에 대체됨)는 도달 불가였지만 타입에 남아 있었다. Postgres에 DROP VALUE가 없어서, 하나를 제거하려면 타입을 다시 만들고kind를 읽는 모든 뷰를 다시 만들어야 하기 때문이다. 0183과 0184가 둘 다 이를 미룬 이유는 재구축 자체가 아니라 전사였다. 21KB짜리 뷰 SQL을 손으로 옮기면 조용히 틀린 사본이 마이그레이션에 들어간다. 0187은pg_get_viewdef로 정의를 캡처하고pg_depend로 삭제와 재생성 순서를 유도하므로, 파일 안에서 뷰의 내용을 직접 적는 것이 없다. reloptions와 주석과 GRANT도 캡처해 복원한다. 다시 만든 뷰는 그중 아무것도 유지하지 않고, 잃어버린 GRANT는 오류가 아니라 404로 읽히기 때문이다. 0183이 제거 대신 넣었던money_events_no_holdback_kindsCHECK도 함께 사라졌다. - 0186 (2026-08-13 dev 적용):
stablecoin_registry.contract_address를 소문자로 저장하고 CHECK로 그것을 유지한다. 이 코드베이스가 비교하는 모든 주소는 경계에서 소문자로 바뀌는데 이 컬럼만 예외라, 읽는 쪽마다 자기 우회책을 갖고 있었다(뷰 넷의lower(),findStablecoin의ILIKE). - 0185 (2026-08-13 dev 적용):
money_distribution_list.status가 이벤트의 부재를 읽는 것을 멈췄다.PROCESSING이 폴백이었는데("실패도 없고 이벤트도 없으니 아직 진행 중일 것") 부재에는 두 가지 뜻이 있다. 자격 있는 LP가 없어 아무에게도 크레딧하지 않는 정산도 수수료 항목은 지급하므로, 트랜잭션이 성공하고 돈이 움직였는데YieldDistributed는 발생하지 않았다. 그래서 행이 영원히PROCESSING에 앉아 있었다. 단방향distribution_started_at선점 때문에 재시도도 안 됐고, 24시간 뒤 "운영자가 아직 실행해야 함"으로 쓸려 나갔다. 수정은 온체인이다. 이제 모든 정산에서YieldDistributed가 발생하므로 아무에게도 크레딧하지 않는 것이 침묵이 아니라(0, 0)이고, 시그니처가 그대로라 재인덱싱도 없다. 이 뷰는 그것을 새SETTLED_NO_HOLDERS로 읽는다. 그 이벤트에서 오는 모든 컬럼이 같은 조건으로 게이팅된다. 크레딧이 0인 정산에는 트랜잭션은 있고 분배 일자는 없기 때문이다. - 0151/0152 (2026-08-10 dev 적용): 활동 피드와 감사 피드가 상환을 원장에서 읽는다.
public.redemption_requests는 리셋 이후 비어 있어서 두 분기 다 아무것도 반환하지 않고 있었다. 이제money_redemption_list가 담은 것을 노출한다. 과거의nav_at_request와payout_amount는 6단계까지legacy.redemption_requests에 남는다. 재배포 이전 컨트랙트가 둘 다 발생시키지 않았고, 그것이 둘을RedemptionRequested에 추가한 이유다. - 0150 (2026-08-10 dev 적용):
money_redemption_workflow가(pool_id, onchain_request_id)에 UNIQUE를 갖는다. 그 쌍이 요청·펀딩·정산에 걸쳐 상환 하나를 행 하나로 만드는 것이고money_redemption_list가 그것으로 조인한다. 제약이 없으면 중복 행을 막는 것이 없었고, 그러면 목록의 모든 상환이 배로 늘어났을 것이다. - 0149 (2026-08-10 dev 적용):
complete_redemption_atomic이 잔액 쓰기를 멈춘다. 살아 있는 두 번째 라이터였다.portfolio_positions와pools.tvl은 입금 슬라이스 이후로money_events에서 유도되는데, 이 함수가 완료된 상환마다 같은 컬럼을 계속 증가시키고 있어서 나중에 실행된 쪽이 이겼다. 앞선 감사에서 살아남은 이유는 그 감사가 TypeScript에서.from('portfolio_positions')를 grep했는데 이 쓰기는 저장 프로시저 안에 있었기 때문이다. RPC로 도달하는 라이터는 테이블 검색에 보이지 않는다. 이제 잔액 파라미터는 무시되고 시그니처는 의도적으로 그대로다. 데이터베이스와 Lambda를 같은 순간에 배포할 수 없기 때문이다. - 0147/0148 (2026-08-10 dev 적용):
money_redemption_list가 raw 투자자 필드와 pool 객체를 담는다. 상환 목록은 role로 PII를 게이팅하는데(viewInvestor가 운영자에게는kyc_status를, FUND_MANAGER에게는 null을 반환한다) 그것은 누가 묻느냐에 달려 있고, 서비스 키로 실행되는 SQL은 그것을 알 수 없다. 이름을 SQL에서 조합하는 방식은(그런 게이트가 없는 입금 목록에는 맞다)investor_kyc_status를 모두에게 null로 남기면서 잘 동작하는 것처럼 보였을 것이다.pools가 객체인 것은 입금 목록이 객체를 만드는 것과 같은 이유다. PostgREST는 뷰에서 조인을 임베드할 수 없다. - 0146 (2026-08-10 dev 적용):
money_redemption_list.money_events와money_redemption_workflow에서 유도한 상환이고 입금 목록의 짝이다. 요청 이벤트만이 아니라 모든 상환 이벤트에 걸친 DISTINCT(pool, chain, requestId)로 구동한다. 영수증 기반 백필이 옛 시스템이 해시를 기록해 둔 트랜잭션만 잡았기 때문에 일부 상환이 이력의 절반만 갖고 들어왔고, 요청으로 구동하면 완료 넷을 조용히 떨어뜨렸을 것이다. 백필된 행의nav_at_request와penalty_amount가 NULL인 이유는 옛 컨트랙트의RedemptionRequested가 둘 다 담지 않았기 때문이고, 그래서 둘을 이벤트에 추가했다. - 0145 (2026-08-10 dev 적용):
audit_feed와investor_activity가deposits테이블이 아니라 입금 목록을 읽는다.process_deposit_atomic을 제거하면서 그 테이블에 행이 늘지 않게 됐는데 뷰 셋이 여전히 그것을 읽고 있었다. 새 입금이 투자자와 어드민의 활동 피드에서 그냥 사라졌을 것이고, 오류도 빈 상태 표시도 없었을 것이다. - 0143 (2026-08-10 dev 적용):
money_deposit_list.amount가 DOUBLE PRECISION이었다.power()가 float를 반환하기 때문이다. 화면 둘이 읽는 컬럼에서 돈이 이진 부동소수점을 통과하고 있었다.10::numeric ^ n은 정확하다. - 0141/0142 (2026-08-10 dev 적용):
money_deposit_list.money_deposit_workflow와money_events에서 유도한 입금 목록이고, 어드민 화면이 이미 읽는 모양이라 검색·정렬·필터·페이지네이션이 관계 하나에 대한 PostgREST 조작으로 계속 동작한다.status는 저장되지 않고 유도된다. 입금이 COMPLETED인 것은 원장이 그 이벤트를 갖고 있기 때문이다. 중첩된wallets/pools/users객체는 SQL에서 만든다. PostgREST가 뷰에서 조인을 임베드할 수 없기 때문이다. - 0139/0140 (2026-08-10 dev 적용):
money_deposit_workflow를 트랜잭션으로 키잉했다. 그래서 원장 이벤트보다 행이 먼저 존재할 수 있다. 그 구간, 즉 온체인에서는 확정됐고 아직 반입되지 않은 구간이 PENDING 상태이고, 투자자의 리스크 동의를 기록할 수 있는 유일한 순간이기도 하다. 0140이 인덱스를 부분 인덱스가 아니게 만들었다.ON CONFLICT가 부분 인덱스를 추론할 수 없어서 모든 upsert가 런타임에 실패하고 있었다. - 0138 (2026-08-10 dev 적용):
money_deposit_workflow.risk_ack을 BOOLEAN에서 JSONB로. 이것은deposits.risk_ack을 대체하는데 그쪽이 JSONB이고 투자자가 무엇에 동의했는지를 담는다. boolean은 무언가 동의됐다고 말할 수 있을 뿐 무엇인지는 말하지 못하는데, 그게 증거의 내용 전부다. - 0137 (2026-08-10 dev 적용): Base Sepolia USDC 등록. 레지스트리에 8453과 11155111은 있었는데 제품이 dev에서 실제로 도는 체인에 대한 행은 끝내 없었고,
assetDecimals는 등록되지 않은 자산에 예외를 던진다. 원장 반입이 첫 Base Sepolia 입금에서 실패했을 것이다. - 0134 (2026-08-10 dev 적용): 체인이 담지 않는 사실들.
money_deposit_workflow,money_redemption_workflow,money_distribution_workflow가 재생으로는 절대 만들어 낼 수 없는 것을 담는다. 투자자의 리스크 동의, 운영자의 결정과 그 사유, 파트너 펀딩 상태와 기한, 수수료 분해, 그리고 실패다(실패한 트랜잭션은 이벤트를 아예 발생시키지 않는다). 이들은money_events를 nullable FK로 가리키는데 그게 요점이다. 거부된 요청이나 실패한 입금에는 참조할 원장 행이 없고, 바로 그래서 집이 필요하다. 이 중 아무것이나money_events.payload에 넣으면 "모든 행은 체인 사실이다"가 거짓이 되고 재생이 드리프트 확인으로서 갖는 의미를 잃는다. 수수료 분해가 미묘하다. 총액에서 순액을 뺀 것처럼 보이지만 컨트랙트는 수수료 요율을 갖고 있지 않으므로(0067)fee_config_applied가 당시 설정을 스냅샷한다. 그러지 않으면 옛 분배를 오늘의 요율로만 다시 유도할 수 있다. - 0133 (2026-08-10 dev 적용): 프로젝션들.
money_positions,money_pool_state,money_tvl_daily다.portfolio_positions에 라이터를 더하는 대신 새 테이블로 만들었다. 그러지 않았다면 그 테이블의 죽은 컬럼을 물려받고, 정리는 영영 하지 않을 잡일로 남았을 것이다. 모든 컬럼이 파생값이다. 재생이 재구축할 수 없는 것은 워크플로 테이블에 속한다. 유도 불가능한 데이터를 담은 프로젝션은 "재생과 비교한다"를 드리프트 확인이 아니라 서로 다른 두 가지의 비교로 바꾸기 때문이다.rebuilt_from_event_id가 마지막으로 fold된 원장 행을 기록하므로 재생이 처음부터가 아니라 이어서 돌고 낡음도 관측된다. 뒷받침 불변식은 주석이 아니라 CHECK 제약이다. 풀이 실제로 가진 것보다 많이 약속하면 나중에 revert되는 주장으로 드러나는 대신 쓰기 시점에 실패한다. +테이블 6. - 0132 (2026-08-10 dev 적용): **money 원장인
money_events**와money_event_kindenum(값 20개)이다. append-only 테이블 하나이고, 모든 프로젝션 값을 이것을 재생해서 재계산할 수 있다는 것이 그 계약이다. 그래서 잔액이 증가되는 대신 유도되고, 그것이 드리프트를 탐지 가능하게(재생과 비교) 하고 고칠 수 있게(다시 재생) 만든다. 유형별로 테이블을 나누지 않고 하나로 둔 이유는, 나누면 단일 멱등성 키가 깨지고deposit()의 atomic 분리를 담은 트랜잭션 안에서의 순서가 깨지며 풀의 전체 자금 이력을 한 쿼리로 묻는 것도 불가능해지기 때문이다.(chain_id, block_number, log_index)로 정렬하고 절대id로 하지 않는다.id는 삽입 순서이고, API fastpath가 인덱서보다 먼저 삽입한 뒤 인덱서가 그 주변을 채우는 일이 일상적이다. kind는 인덱서의 감시 목록이 아니라 무엇이 돈을 움직이느냐에서 유도했다. 그 목록은 컨트랙트의 이벤트 73개 중 24개만 덮고ReleasedToPartner,FeesWithdrawn, hold-back 쌍,RedemptionFunded,Reinvested를 빠뜨리는데 전부 실제 가치를 움직이면서 현재 어디에도 기록되지 않는다. 반입이 붙기 전까지는 비어 있다. +테이블 1, +enum 1. - 0131 (2026-08-09 dev 적용):
legacy.pool_chain_deployments가 되돌려진 마이그레이션에서 물려받은 컬럼을 잃는다. 0127이 public 테이블에deployed_block을 더했고, 0128이LIKE로 그 모양을 복사했으며, 0129가 public에서만 다시 제거해서 legacy가 컬럼 하나만큼 넓게 남았다.reset_to_legacy.sql은INSERT INTO legacy.x SELECT * FROM public.x로 복사하는데 모양이 맞아야 한다. 그 테이블은 0행이라 조용히 통과했다가, 무언가 쓰이는 순간 리셋 트랜잭션 전체를 중단시켰을 것이다. 리셋을 돌려서가 아니라 두 스키마의 컬럼 시그니처를 비교해서 찾았다. 복사되는 14개 테이블이 이제 정확히 일치한다. - 0130 (2026-08-09 dev 적용): 0128이 집을 마련해 주지 않은 두 가지.
legacy.indexer_cursor는 옛 로그 리더가 실제로 어디까지 갔는지를 담고, 재생 커버리지 공백은 그것을 기준으로 잰다.legacy.pool_balances는 배포된 풀마다 TVL, NAV, LP 공급량, reserve를 담는다.pools자체는 옮기지 않는다. 리셋이 각 풀의 설정 행을 재사용해 다시 배포하므로 행은 살아 있고 잔액만 비워지는데, 그래서 그 네 수치가 스냅샷될 자리가 없었다.LIKE public.pools가 아니라 목적에 맞게 만들었다. 숫자 넷을 보존하려고 설정 컬럼 80개쯤을 복사하면 그 베이스라인이 풀 설정의 두 번째 기준으로 읽히게 된다. 신원 컬럼(주소, 체인, 배포 블록)이 함께 가는 이유는 리셋이 그것들을pools에서 지우기 때문이고,chain_id를 TEXT로 저장한 이유는 스냅샷이 나중에 값이 수정될 수 있는 enum에 의존하지 않게 하기 위해서다. legacy만 바뀌고 public 변경은 없다. - 0129 (2026-08-09 dev 적용):
pools.deployed_block, 그리고 0127 되돌림. 풀의createPool트랜잭션이 들어간 블록은 과거getLogs스캔이 필요로 하는 시작 블록이다.deployed_at은 타임스탬프라 그것을 시작할 수 없고, 인덱서가 단일 범위 호출의 폭을 제한하므로 0부터 스캔하는 것은 대안이 아니다. NULL은 제네시스가 아니라 미상이라는 뜻이다. 0127은 이를pool_chain_deployments에 뒀는데 그 테이블은 라이터가 없고 0행이다. 그래서 캡처 스크립트가 아무것도 순회하지 않고 성공으로 끝났을 것이다. 완전해 보이지만 아무것도 담지 않은 동결이다. money-flow 리셋 전에scripts/replay/capture-deployment-blocks.ts가deploy_tx_hash에서 백필한다. 풀 28개가 이를 기다리고 있고, 배포된 풀 하나는 복원할 tx 해시가 없다. - 0128 (2026-08-09 dev 적용): 재작업 이전의 money 행을 담을
legacy스키마. 검증 재생의 비교 기준이다. 구조만 만들었고 public의 money 테이블을 미러링하는 테이블 13개에snapshot_meta를 더한 것이다(0130이 둘을 더 추가해 16개가 된다). 행은 여기가 아니라 리셋 때 복사한다. 위의 테이블 총계에는 포함하지 않는다. 별개 스키마이기 때문이다.public에legacy_접두사를 붙이지 않고 별개 스키마로 둔 이유는, PostgREST가public을 노출하는데 모든 과거 포지션의 스냅샷이 API 표면에 있을 것이 아니고, 6단계 정리가DROP SCHEMA legacy CASCADE한 번으로 끝나기 때문이다. public 테이블과 enum 수에 변화 없음. - 0126 (2026-08-06 dev 적용): API 입금 경로가 출처를 기록하고, 도달 불가 함수 둘이 삭제된다.
process_deposit_atomic이 이제 insert 시portfolio_positions.source = 'DEPOSIT'을 설정한다. 인덱서의 입금 라이터는 늘 그렇게 하고 있었으므로, 평범한 입금은 "출처 미상"으로 읽히고 정합기가 먼저 만든 드문 경우만 "알려짐"이었다. insert 시에만 설정하고(추가 매수는 보유분을 어떻게 얻었는지를 바꾸지 않는다) 완료된 입금이 있으면서 자기 취득 tx가 없는 dev 포지션 29개를 백필했다. 이제 31개 전부가DEPOSIT으로 읽힌다. 삭제된 것:revert_reinvest_atomic(0005)은 DB 청구 뒤 온체인 민팅이 실패한 경우를 보상하던 것인데, 0086/H2가 온체인 검증을 DB 쓰기 앞으로 옮겨 그 창이 열릴 수 없게 됐고 호출자도 0이었다. 그리고 0086이 11인자 버전을 추가하면서 남은 8인자reinvest_yield_atomic오버로드인데, 유일한 호출자가 11개를 다 넘긴다. 이 오버로드가 중요했던 이유는 0125의 취득원가를 유지하지 않는 유일한 사본이라, 8인자로 호출하면 그것을 조용히 건너뛰기 때문이다. 둘 다 0005 / 0051에서 복원 가능하다. 함수와 데이터만 바뀌고 컬럼·테이블·enum 수 변화 없음. - 0125 (2026-08-06 dev 적용):
portfolio_positions.entry_price의 취득원가. 그래야 포지션별 손익을 계산할 수 있다. 홀더가 얼마를 냈는지를 기록하는 것이 없었다.effective_value는tokens × nav_per_token이고 포지션이 바뀔 때마다 덮어써지며(언제나 현재 평가액이다),entry_price는 전송이 포지션을 생성할 때만 설정됐고, 05-investment-lifecycle이 말하던nav_at_investment는 존재한 적이 없다. 이제process_deposit_atomic과reinvest_yield_atomic이금액 / 민팅된 토큰으로 가중평균을 유지한다. 체인이 실제로 부과한 가격이고, 하락이 24시간 타임락에 있는 동안 의도적으로p_nav_per_token이 아니다(v3-111). 인덱서의 입금·LP 전송 라이터도 같은 일을 하고, 전송 수령분은 온체인에서 대가를 관측할 수 없으므로 풀 NAV로 마킹한다. 취득원가를 모르면 다음 취득 가격을 채택하는 대신 NULL로 남으므로, 원가 0의 이익이 아니라 손익 없음으로 드러난다. 부분 상환은 이를 건드리지 않고 전량 이탈은 행을 삭제한다. 재구성이 명확한 경우에만 백필했다. 기록된 이탈이 없어야 하고(deposits에 행별 LP 수량이 없어서 부분 이탈이 있으면 틀린다) 그리고 원가가 토큰 이하여야 한다. NAV 상한이 1.0이라 그보다 높은 원가는 상환 행 없는 경로로 토큰이 빠져나갔다는 증거이기 때문이다(예: LP 전송 유출). dev에서 31개 중 20개를 채웠고(전부 정확히 1.000000) 10개는 NULL로 뒀다. 이탈이 있는 것 6개, 기록된 원가가 없는 것 2개, 1.0 이하 가드에 걸린 것 2개다(LP 9개에 $10 유입, 49.95에 $50인데 둘 다 볼 만하고 둘 다 재구성이 안전하지 않다). 함수와 데이터만 바뀌고 컬럼·테이블·enum 수 변화 없음. - 0124 (2026-08-04 dev 적용):
nav_proposals.escalate_flag주석이 에스컬레이션 조건인 전손을 한 번 명시한다(v3-110 C). 텍스트만 바뀌고 컬럼이나 값은 그대로다. 이미 적용된 0094를 고치는 대신 새COMMENT ON COLUMN으로 냈다. - 0123 (2026-08-04 dev 적용):
pools.is_hidden. 기본 어드민 목록에서 풀을 숨기고 기능 변화도, 온체인 구성요소도, 투자자에 대한 영향도 없다. 이것이 있는 이유는 아카이브(deleted_at)가 서로 무관한 두 일을 하고 있었기 때문이다. 끝난 풀을 정리하는 것과 콘솔을 깔끔히 하는 것인데, v3-110 A가 아카이브를 앞의 것으로 좁혔다.is_showcase는 두 가지 이유로 재사용할 수 없었다.IMMUTABLE_FIELDS에 있어서 PATCH가 모든 lifecycle 단계에서 거부하고, "커스터디 없는 외부 미러로 입금 비활성"을 뜻하므로 뒤집으면 입금이 부수적으로 꺼진다. 기본값이 있는 추가이고 개수 변화 없음. - 0122 (2026-08-04 dev 적용): 수동 NAV 경로가 손실을 받으면서 버퍼가 풀 하나만의 기능이 아니게 된다.
POST /nav-changes가 이제new_nav대신cumulative_loss를 받고, 제안 sweep이 쓰는 것과 같은nav-formula.ts로 가격을 유도한다. 이것이 중요한 이유는 sweep이 외부 매핑된 풀만 방문하기 때문이다. 단독 풀 13개 중 1개다. 그래서buffer_rate_bps에 독자가 정확히 하나뿐이었고 나머지에서는 무기력했다. 손으로 입력한 NAV는 계산한 사람이 이미 손실을 적용한 값이라, 어떤 first-loss 계층도 그 경로에 닿을 수 없었다.nav_history.loss_amount(버퍼를 지난 뒤의 미충당 손실이고, 총액이 아니라 실제로 가격을 움직인 값)와 **loss_as_of**를 추가한다. 직접 입력한 NAV에서는 둘 다 NULL이고, 그 자체가 산식이 아니라 사람이 가격을 매겼다는 신호다. 스펙 수준에 머물러 있던 하락 원인 메타데이터의 최소한의 쓸모 있는 형태다. 0119를 폐기한다. 그 제약은 매핑되지 않은 풀에서 0이 아닌 버퍼를 금지했는데, 그 논리는 다른 무엇도 그것을 읽을 수 없는 동안에만 성립했다. 값을 금지하는 규칙은 그 값에 독자가 생기기 전까지만 옳다. 더 좁은 둘로 대체한다.buffer_not_on_tranche_pools(트랜치 그룹은 이 산식을 돌리지 않는 워터폴 엔진으로 가격을 매긴다)와cadence_requires_external_mapping(리포트 주기는 파트너의 의무이고 피드가 없는 풀은 리포트를 받지 않는다)이다.equity_buffer_prose_requires_layer(0121)는 그대로이고 이제 1개가 아니라 13개 풀 전부에 물린다. - 0121 (2026-08-04 dev 적용):
pools에equity_buffer_prose_requires_layerCHECK.equity_buffer_rule은buffer_rate_bps > 0인 곳에만 설정할 수 있다. 0077이 구조화된 버퍼가 없던 시절에 그 컬럼을 자유 산문으로 추가했고, 투자자 풀 페이지가 그것을 **"Manager first-loss commitment"**로 렌더한다. 0118이 그 계층에 실제 표현을 주면서 문장과 설정이 자유롭게 어긋날 수 있게 됐고, 0119가 그 간극을 키웠다. 구조화된 버퍼는 sweep이 방문하지 않는 풀에서 거부되는데 문장은 어느 풀에나 PATCH할 수 있었다. 이 제약은 텍스트를 설정의 대체물이 아니라 설정에 대한 서술로 만든다. 빈 값은 미설정으로 친다. dev 풀 28개 중 값을 가진 것이 0개라 검증된 상태로 추가했고, 새 풀에는 요율이 없으므로 생성 핸들러가 이 필드를 아예 거부한다. - 0120 (2026-08-04 dev 적용):
nav_proposals.recovery_flag삭제. 0118이 추가한 지 하루 만이다. NAV를 올리는 제안을 표시하던 것이고, 상승은 타임락 없이 적용되므로 예고 기간에 잡을 수 없다는 논리였다. 그런데 그건 빠진 통제가 아니라 타임락 자체를 서술한 것이다. 제품의 판단은 NAV 상승에는 승인 버튼 말고는 필요 없다는 것이다. 읽히지 않는 채로 두지 않고 삭제했다. 생산자 하나와 독자 하나가 둘 다 사라졌고, 항상false인 컬럼은 나중 독자가 그것을 의미 있는 것으로 다루게 만든다. dev에 행 1개, 전부false, 배포된 적 없음. - 0119 (2026-08-04 dev 적용):
pools에buffer_requires_external_mappingCHECK.buffer_rate_bps > 0(그리고 NULL이 아닌fund_report_cadence_days)은external_fund_id IS NOT NULL이고 트랜치 그룹에 속하지 않은 풀에만 허용한다. 둘 다 NAV 제안 sweep만 소비하는데 그 sweep은 매핑되지 않은 풀을 건너뛰므로, 다른 곳에 설정된 버퍼는 조용히 무기력하다. 어드민은 어떤 계산도 적용하지 않을 first-loss 계층을 풀 페이지에서 보게 된다.pools.patch.update에도 반영해 API가 raw 23514 대신 메시지를 반환한다. - 0118 (2026-08-04 dev 적용):
pools의 파트너 리포트 설정(buffer_rate_bps,buffer_direction,buffer_basis,fund_report_cadence_days,apy_basis)이다. 각각이 NAV 산식이 아무도 읽지 않은 파트너 계약에 대해 세울 법한 가정 하나씩을 대체한다. 기본값이 현재의 계산을 정확히 재현하므로 무기력한 상태로 나가고, 계약상의 답이 나오면 산식 재작성이 아니라UPDATE가 된다. 전체 형태와, 의도적으로 만들지 않은 계획 컬럼 둘(buffer_cap,buffer_balance)은 아래 반영됨 — v3-109 참조. - 0117 (2026-08-04 dev 적용):
pools.lp_total_supply. R9가 분모로 필요로 하는 온체인 LP 공급량 미러다. 그 숫자가 나올 데가 없었다. 담는 컬럼도 없었고 읽는 코드도 없었는데, 그것이 실제로 그 결정을 막고 있었다. nullable 추가이고 개수 변화 없음. 같은 변경에서 산식을 여기로 바꾸지는 않았다. 미러가 실제로 돌고 있음이 확인되기 전에 분자를 DB에, 분모를 체인에 두면 인덱서 지연이 전부 NAV 점프가 되는데, 인덱서는 이달 초에 13일 동안 조용히 죽어 있었다. ⚠️ v3-109의 대부분은 마이그레이션이 전혀 필요 없었다. 기존 컬럼의 의미를 바꿨고(nav_history.reserve_consumed는 이제 항상 0이고nav_proposals.resolved_by IS NULL은 TTL 만료를 뜻한다) 테이블 둘에 라이터가 없음을 확인했다(pool_chain_deployments,loan_writeoffs). - 0116 (2026-08-04 dev 적용):
pools.junior_depleted_at. 운영자 배너가 impairment가 왜 생겼는지를 말할 수 있게 한다. Junior 소진은 요청마다 계산되고 버려지고 있었다(어떤 화면도 읽지 않는 POST 응답 필드 안에만 존재했다). 그래서 작성된 문구를 쓸 수 없었다.impairment_proposed_at은 트랜치와 무관하게 손으로 제안한 impairment도 설정하기 때문이다. nullable 컬럼 추가이고 개수 변화 없음. - 0115 (2026-08-04 dev 적용):
epoch_window_subscriptions(+테이블 1). 새epoch_request_window_open안내 뒤의 투자자 옵트인이다.pool_follows(0109)를 재사용하지 않은 것은 의도적이다. 그 목록은 입금이 열리는 것에 대한 동의이고 이쪽은 상환 요청 창이 열리는 것에 대한 동의인데, 두 안내가 모두 "알려 달라고 요청하셨습니다"라고 말한다. 목록 하나를 공유하면 각 안내 수신자의 절반에게 그 문장이 거짓이 된다. 새epochWindowSubscribers대상으로 해석하고pools.scheduler.epoch-window-open이 매시간 생산하는데, 그 스케줄러는 창 경계를 유도의 네 번째 사본을 만드는 대신 온체인에서 읽는다. - 0114 (2026-08-04 dev 적용):
dashboard_alert_counts.stalled_yield가 이제deposit_tx_hash IS NOT NULL을 요구한다. 배지가 새yield_distribution_stalled알림이 보내는 것과 정확히 같은 것을 세게 하기 위해서다(그 안내는 tx 해시를 출력하므로 해시 없는 행에서는 발동할 수 없다. 좁히지 않으면 배지가 상위 집합을 세고 드릴다운이 아무도 통지받지 않은 행을 나열한다). 조건식만 바꾸므로CREATE OR REPLACE VIEW가 뷰의 권한을 유지한다. 테이블과 enum 수 변화 없음. - 0113 (2026-08-03 dev 적용):
yield_distributions.status는 절대PENDING일 수 없다. 기본값을PROCESSING으로 옮기고 CHECK 제약을 더했다. PENDING은 제거된 레거시 서버키 생성 경로에서만 나왔고, insert와 정산 사이의 크래시가 어떤 정합기도 고칠 수 없는 고아를 남겼기 때문이다.yield_statusenum은 PENDING 값을 유지한다(yield_distribution_investors와 공유하고 거기서는 정당하다). 그래서 테이블과 enum 수 변화가 없다. 그리고dashboard_alert_counts.pending_yield는stalled_yield로 이름이 바뀌고 정체된PROCESSING행을 가리키게 됐다(v3-104). - 0112 (2026-08-03 dev 적용):
redemption_epochs라벨 정정.epoch_end_at→ **settlement_allowed_at**이다("회차가 끝난다"는 뜻이었던 적이 없다. 요청 마감 이후recall_lead_days가 지난 펀딩·청구 시점이다). 그리고epoch_start_at을 삭제했다(읽는 곳이 없고 직전 주기의settled_at과 중복이었다). 테이블과 enum 수 변화 없음.investor_activity뷰가 이름 변경을 자동으로 따라가고 마이그레이션이 그것을 확인한다(v3-107). - 0111 (2026-08-03 dev 적용): 펀딩일 출처 정보.
pools.next_funding_date_confirmed_at과next_funding_date_set_by이고, 투자자의 확정 / 예정 배지가 읽는 두 컬럼이다. 체인은 확정된 주기 날짜와 열린 실패로 유도된 날짜를 구분할 수 없기 때문이다(새 테이블이나 enum 없음)(v3-107). - 0100~0110은 dev에 적용됐다(0110은 2026-07-31, 나머지는 2026-07-30). 아래의 (dev 적용 대기) 표시는 그 배치보다 앞선 0090~0094에만 남아 있다.
- 0110 (2026-07-31 dev 적용): 알림 재구축.
notification_events/notifications/notification_deliveries와email_suppressions가notification_logs를 대체한다. 그 테이블은 인앱 수신함 항목과 이메일 발송 로그를 겸하는 행 하나였다.notification_status와recipient_type을 삭제하고delivery_status/recipient_kind/suppression_reason을 추가했다(+3 → 테이블 50개, +1 → enum 27개)(v3-108). - 0109 (2026-07-30 dev 적용):
pool_follows테이블 추가(+1 → 테이블 47개).pool_lifecycle_active알림 뒤의 투자자 옵트인 목록이다. 브로드캐스트가 아니라 옵트인이어야 하는 이유는 작성된 문구가 "팔로우하는 풀이 열렸습니다 / 열리면 알려 달라고 요청하셨습니다"라고 말하기 때문이다. 모든 투자자에게 보내면 그 문장이 모든 수신자에게 거짓이 되고 안내가 원치 않는 신규 풀 광고가 된다. 이 테이블이 그 이벤트를 드디어 낼 수 있게 한 이유다. 생산자가 없어서가 아니라 바로 이 이유로 V2에 묶여 있었다. 형태는(user_id, pool_id)가 곧 PK라 다시 팔로우하는 것이 멱등 upsert다. 상태 컬럼이 없는 이유는 언팔로우가 행을 지우고 "팔로우했었다" 이력은 읽을 사람이 없으며 있으면 거기에도 보내고 싶어질 뿐이기 때문이다. 두 FK에ON DELETE CASCADE를 걸었다(어느 한쪽이 사라지면 팔로우는 의미가 없고, 하드 풀 삭제(0090/0093)가 낡은 팔로우에 막히면 안 된다). 풀은 보통 소프트 삭제되고 CASCADE는 그것을 보지 못하므로, 생산자는 이미 순회 중인 풀로 필터링하고 목록 엔드포인트는 살아 있는 풀까지 조인한다.pool_id단독 인덱스를 추가했다. PK는user_id로 시작하는데 생산자의 접근 경로는 활성화 시점에 "이 풀을 누가 팔로우하나"이기 때문이다. 다른 Lambda 기록 테이블들처럼(0094의nav_proposals, 0103의redemption_fills) RLS를 켜고 정책은 두지 않는다. anon과 authenticated에 대한 GRANT가 없으므로 publishable key가 유출돼도 누가 무엇을 팔로우하는지 열거할 수 없다. +테이블 1, enum 변화 없음. - 0108 (2026-07-30 dev 적용): 투자자 알림 환경설정을
event_key로 다시 키잉했다. 0101이 어드민에 대해 한 일의 투자자 쪽이고, 투자자 환경설정 강제를 애초에 가능하게 만든 변경이다. 설정 페이지가 자기가 지어낸 분류 이름 둘을 쓰고 있었다.NAV_UPDATE는 이벤트 키도copy.ts분류도 아니고(가장 가까운 실제 이벤트인nav_change_proposed/_cancelled가 둘 다critical이라 키를 제대로 맞췄어도 끌 수 없었을 것이다),POOL_PERFORMANCE는 무기력한 것보다 나빴다. 주간 성과 이벤트라는 것이 레지스트리에도 문구 시트에도 없으므로 만들어진 적 없는 기능을 스위치로 광고한 셈이다. 둘 다 이벤트 키로 번역할 수 없어서 행을 마이그레이션하지 않고 삭제한다. 남겨 두면 파생 허용 목록이 쓰기에서 거부하는 쓰레기 PK 행이 남는데 읽기 경로는 여전히 그것을 발송 시점 게이트에 넘긴다. 과거 발송에 대해서는 아무것도 달라지지 않는다. 그전에는 워커가 투자자 환경설정 행을 읽은 적이 없어서 그 행들이 발송에 아무 영향도 주지 않았다. dev에서는 0행이 삭제됐고(거기서 테이블이 비어 있다) 프로덕션 안전을 위해 작성했다. ADMIN 행은 건드리지 않았다. 데이터만 바뀌고 테이블·enum 수 변화 없음. - 0107 (2026-07-30 dev 적용):
dashboard_alert_counts.failed_notifications가 이제INVALID_RECIPIENT를 제외한다. 그 failure_type은 대상에게 전달 가능한 주소가 없었다는 뜻이고(보통 인증된 이메일이 없는 투자자다), 영구적이며 조치할 수 없고 인앱 행은 그래도 전달됐다. 그것을 세면 어드민 알림이 영원히 늘어나 쫓을 가치가 있는 SES 실패를 묻어 버린다. 뷰만 바뀌고 테이블·enum 수 변화 없음. - 0106:
pools.redemption_type에서LIQUIDITY_WINDOWS제거. 배선된 적이 없다(배포가 빈 유동성 창 배열을 보내서 컨트랙트의 창 루프가 절대 매칭될 수 없었고, 모든requestRedemption이NotInLiquidityWindow로 revert됐다. 상환이 영원히, 조용히 막혔다). v3-88 D5가 어드민 드롭다운을 닫았지만 값이 API로는 여전히 도달 가능했고,pools.patch.update는 그것을 아예 검증하지 않았다. 그 상태로 배포된 풀 4개는(전부 Base Sepolia 84532, QA, 메인넷 노출 없음) 라벨을 바꾸는 대신 소프트 삭제했다.ON_DEMAND로 다시 쓰면 DB가 컨트랙트에 없는 기능을 주장하게 되고, 고칠 수도 없다(redemptionConfig는 initialize 전용이고 PlatformPool은 업그레이드 불가다). CHECK가 소프트 삭제된 행을 예외로 두어 그것들이 무엇이었는지에 대한 감사 기록이 온전히 남는다. 온체인 enum 번호도 다시 매긴다(ON_DEMAND 2 → 1). 풀은 생성 시 구현체에 고정되므로 안전하다. 데이터와 제약이 바뀌고 테이블·enum 수 변화 없음. - 0105:
pools.epoch_cycle_mode주석을 실제 효과에 맞게 좁혔다(D2). 컨트랙트 동작이 아니라 오프체인 리마인더를 게이팅한다. 회차 스케줄은 어느 쪽이든 열린 실패이기 때문이다(결정 C2). 주석만 바뀌고 컬럼·제약·기본값·데이터 변화 없음. - 0104 (드리프트 정합, 이미 라이브):
pools의 회차 스케줄 컬럼 6개(epoch_schedule_type,funding_anchor_date,recall_lead_days,request_window_days,epoch_cycle_mode,next_funding_date)가 몇 달 전 Supabase SQL 에디터로 바로 적용돼 라이브 DB에만 존재했다. 저장소 마이그레이션도,schema.sql항목도,supabase_migrations행도 없었다. 그래서 저장소에서 새로 띄운 환경은 그 컬럼들 없이 올라왔고, 회차 스케줄 엔진(v3-91 / v3-93, 컨트랙트 재설계 대기)이 그것들을 읽는다. 0104가 마이그레이션과schema.sql을 라이브와 문자 그대로 일치하도록 백필하고(타입, nullable 여부, 기본값, CHECK, 주석) 컬럼이 이미 있으면 아무 일도 하지 않는다. 아직 이 컬럼들을 읽는 코드는 없다. 풀 타입·검증과 풀 생성·수정 API가 여전히 배선되지 않았다. 컬럼만 바뀌고 테이블·enum 수 변화 없음. - 0103 (2026-07-30 dev 적용):
redemption_fills추가(+테이블 1). 회차 상환의 체결별 원장이고RedemptionClaimed하나당 행 하나다. 회차 요청은 pro-rata로 정산되고 잔량을 이월하므로 요청 하나가 여러 회차에 걸쳐 지급될 수 있는데, 요청 행은 누적 합계만 유지한다.payout_amount가 체결마다 덮어써졌고(두 번 체결된 요청은 마지막 체결만 자기 지급액으로 보고했다) 앞선 체결의 tx 해시가 사라졌으며,epoch_id는 이월 시 다시 가리켜졌다. 그래서 이미 투자자 지갑에 들어간 돈이 어디에도 나타나지 않았다. 요청은 진행 중으로 남고(잔량이 있다) History는 종료된 행만 보여 준다. 뷰에REDEEM_FILL분기가 생기고, 요청 행은COMPLETED이면서 체결이 있을 때만 보류되므로 원장이 단식부기로 유지된다.payout_amount는 이제 누적된다. 백필은 불가능하다(체결별 지급액과 해시가 기록된 적이 없다). - 0102 (2026-07-30 dev 적용):
admin_sessions.is_current삭제. 죽은 컬럼이고 어떤 핸들러도 쓴 적이 없다. "이 기기"는 호출자 토큰의sid로 요청마다 유도한다. API 응답은 같은 이름의 파생 필드를 유지한다. 컬럼만 바뀌고 테이블·enum 수 변화 없음. - 0101 (2026-07-30 dev 적용): 어드민 알림 환경설정을
event_key로 다시 키잉했다. My Settings 패널이 지어낸 분류 체계(new_redemption,deposit_anomaly등)로 쓰인 dev 행 9개를 삭제한다. 그 이름들은 어떤 알림 이벤트와도 맞지 않아서 이메일 토글이 저장되고도 아무것도 바꾸지 않았다. 이제category가notification_logs.event_type을 담고, 그것이 발송 워커가 게이팅하는 값이다. 데이터만 바뀌고 테이블·enum 수 변화 없음. - 0100 (2026-07-30 dev 적용):
notification_status에SUPPRESSED추가. 그 optional 이벤트를 모든 수신자가 수신 거부해서 의도적으로 보내지 않은 이메일의 종착 상태다(인앱 행은 그대로 전달된다). 수신 거부가 실패 알림 운영 큐에 들어가지 않도록FAILED에서 뺐다. enum 값만 추가되고 타입 수는 그대로다. - 0099 (2026-07-28 dev 적용):
kyc_logs.status_result의 레거시 어휘 정규화. 이 컬럼이 어휘 둘을 담고 있었다. 현재 라이터는GREEN/RED(apply-review)와IN_REVIEW/RESET/DEACTIVATED(webhook)를 쓰는데, v3 이전 행 29개는 여전히APPROVED/REJECTED/IN_PROGRESS라고 적혀 있었다(라이터 없음). 어드민 KYC 드로어가 거부 사유, reject_type, 시도 이력(docs03-kyc-identity가 요구한다)을RED매칭으로 해석하므로 그 행들이 보이지 않았다. dev 유저 한 명이 실제 거부 14건에 대해 4건으로 표시됐다. 현재 값으로 백필했다. GREEN/RED에 대해서는audit_feed출력이 그대로이고(ELSE 분기가 이미 같은KYC_APPROVED/KYC_REJECTED문자열을 만들고 있었다),IN_PROGRESS→IN_REVIEW는 그 행들을 사람의 쓰기 스트림에서 SumSub 기반 이벤트가 속한 Activity 스트림으로 옮기기도 한다. 데이터만 바뀌고 테이블·enum 수 변화 없음. - 0098 (2026-07-28 dev 적용):
platform_stats의 범위 확대.total_tvl과total_investors가lifecycle_status = 'ACTIVE'로 제한돼 있어서, MATURED / CLOSED / IMPAIRED / WIND_DOWN 풀의 아직 상환되지 않은 자본과 그 홀더가 어드민 개요에서 사라졌다. 이제 둘 다 모든 풀을 덮고, 집계 셋 모두pools.get.list와 맞추기 위해 소프트 삭제된 풀을 제외한다. 뷰만 바뀌고 테이블·enum 수 변화 없음. - 0097 (2026-07-28 dev 적용):
redemption_epoch_summary의 큐가 서로 겹치지 않게 됐다.queued_count가 열린 수요 전체(QUEUED+PARTIALLY_FILLED, 보류 포함)를 세고 있어서, 어드민 Redemptions EPOCH 탭이 보류된 요청을 "Open Queue"와 "Hold Queue"에 둘 다 세고(부분 체결된 요청은 Open과 Rollover에 둘 다) 있었다. 이제 세 집계가 집합을 분할한다.queued_count는 보류되지 않은 QUEUED,rollover_count는 보류되지 않은 PARTIALLY_FILLED,held_count는 두 상태 중 보류된 것이다.demand_lp와demand_usd는 그대로다. 함수만 바뀌고 테이블·enum 수 변화 없음. - 0096 (2026-07-28 dev 적용):
pool_position_stats(uuid[])집계 함수. 풀별 홀더 수와 LP 공급량이다. 그래서pools.get.list가 양수인portfolio_positions행을 전부 두 번 내려받아 Lambda에서 줄이는 일을 멈춘다. 함수만 바뀌고 테이블·enum 수 변화 없음. - 0095 (2026-07-28 dev 적용):
platform_stats.total_yield를yield_distributions에서 다시 유도한다. 그전에는 아무도 쓰지 않는 비정규화 컬럼인pools.total_yield_distributed를 합산하고 있었고(0026이investor_count에 대해 고친 것과 같은 부류의 버그다), 그래서 얼마를 분배했든 어드민 대시보드의 "Total Yield" KPI가 $0으로 읽혔다. 이제 소프트 삭제되지 않은 풀의DISTRIBUTED분배에 대한SUM(total_amount)다. 누적 수치이므로total_tvl과 달리 ACTIVE 풀로 제한하지 않는다.pools.total_yield_distributed는 나중에 삭제하기 위해 DEPRECATED 주석을 달고 그대로 둔다(읽는 곳 없음). 뷰만 바뀌고 테이블·enum 수 변화 없음. - 0094 (적용 대기):
nav_proposals테이블(A4 NAV 제안·승인·덮어쓰기).nav_history와 분리된 새 테이블이다(+1 → 테이블 45개, 상태 모델 B).nav-proposals.scheduler.suggestsweep(EventBridge, 약 12시간)이 fund에 연결된 단독 풀마다 최신external_pool_data_snapshots행과 라이브 온체인 reserve로 제안 NAV를 계산하고, 유의미하게 다르면서(|제안값 − nav_per_token| ≥ 0.0001) OPEN 제안이 없으면 OPEN 행을 남기고 ADMIN에게nav_proposal_pending을 발생시킨다. 그다음 ADMIN이나 SUPER_ADMIN이POST /nav-changes/proposals/{id}/approve(suggested_nav를 받는다)나/override(수정된new_nav)를 호출하면 기존 제안 경로(simulate →updateNAV→nav_historyAPPLIED/PENDING)로 흘러가고, 제안이applied_nav_history_id와 함께 APPROVED/OVERRIDDEN으로 표시된다. 승인과 덮어쓰기는 감사 로그에NAV_APPROVE/NAV_OVERRIDE를 쓴다. 계산된 NAV가 0 이하면escalate_flag = true가 된다(IMPAIRED/WIND_DOWN용으로 노출되고 절대 자동 적용되지 않는다). 트랜치 그룹 풀은 제외된다(그쪽 NAV는POST /tranche-writedown엔진의 몫이다). RLS 켜고 서비스 키 전용이다. +테이블 1, enum 변화 없음. - 0093 (적용 대기):
hard_delete_pool_atomic(uuid)개정. 삭제 가능한 풀은 언제나 하드 삭제할 수 있다. 0090의 가드는 삭제 가능한 풀을 영구히 묶어 둘 수 있었다. 어드민이 DRAFT나 배포 실패 풀에 NAV 변경이나 거버넌스 변경을 제안할 수 있는데, 그중 하나만 자식 행을 만들어도DELETE /pools/{id}?hard=true가 영원히 409를 냈다. 그런데 어드민 UI는 그런 풀에 대해 하드 삭제만 제공하고 아카이브 폴백을 절대 제공하지 않는다. 이제 RPC가lifecycle_status = 'DRAFT'또는deploy_status = 'DEPLOY_FAILED'이면 무조건 강제 삭제하고, NO ACTION 자식 8개를 FK 의존 순서로 제거한다(loan_writeoffs→nav_history,redemption_requests/yield_claims→portfolio_positions, 그리고yield_distributions,deposits,pool_governance_changes). 가드는 없다. 안전한 이유는 두 상태 다 정산된 투자자 자금을 담을 수 없기 때문이다. DRAFT는 단방향 시작 상태이고(투자자에게 보인 적도, 배포된 적도 없으며 DRAFT로 되돌아오는 전이도 없다), DEPLOY_FAILED는 풀이 DEPLOYED에 도달하기 전에만 기록된다(발행이deploy_status !== DEPLOYED를 게이트로 하고, 재시도 배포는 이미 DEPLOY_FAILED를 요구하며, 배포 워커는 DEPLOYING에만 작동한다). 게다가isPoolHiddenFromInvestor가 lifecycle과 무관하게 DEPLOY_FAILED를 숨기므로(C1) 입금을 받을 수 없다. 그 외 모든 상태는 0090의 가드 전체를 유지한다. API로는 도달할 수 없지만(핸들러가 자격을 먼저 검사한다) 서비스 role RPC 호출이 잘못 들어와 살아 있는 ACTIVE 풀을 지우는 것에 대한 안전망이다. 그리고 풀 행을 처음에FOR UPDATE로 잠그고 없는 풀에는no_data_found(02000)를 던지는데, 핸들러가 이제 그것을 뭉뚱그린 500이 아니라 404로 매핑한다. 테이블·enum 수 변화 없음. - 0091 (적용 대기):
apply_nav_change_atomic(p_nav_change_id, p_pool_id, p_nav_per_token)RPC.nav_history행을APPLIED로 표시하고pools.nav_per_token과 다시 계산한has_pending_nav_update를 하나의 트랜잭션에서 동기화한다. 그래서 큐에 있던 NAV 변경을 적용할 때 온체인에는 적용됐는데 풀 NAV가 낡은 채로 남는 일이 더 이상 생길 수 없다(체인과 DB 드리프트 1번). 멱등이다(이미 APPLIED인 행을 다시 적용하면 풀만 다시 동기화한다).POST /nav-changes/{id}/activate와 새nav-changes.scheduler.apply-pendingsweep(EventBridge, 15분)이 쓴다. 그 sweep은 24시간 타임락을 지난 큐잉된 NAV 하락을 자동 적용하고(2번, 수동 activate 불필요) 배포된 모든 풀에 대해 DB를 온체인navPerToken으로 수렴시킨다(온체인이 기준이다). 테이블·enum 수 변화 없음. - 0090 (적용 대기):
hard_delete_pool_atomic(uuid)RPC. 어드민의 풀 하드 삭제가 500("Failed to delete pool")을 내고 있었다. 맨DELETE FROM pools는nav_history.pool_id의 NO ACTION FK에 걸리는데 풀 생성이 항상 genesisnav_history기준선 행 하나를 쓰기 때문이다. 그래서 정상적으로 만들어진 풀은 절대 하드 삭제될 수 없었다. RPC가 먼저 가드를 건다(deposits / portfolio_positions / redemption_requests / yield_distributions / yield_claims / loan_writeoffs / pool_governance_changes 중 어디에라도 행이 있으면 거부하고, genesisnav_history행 하나까지만 허용한다). 그다음 그 행과 풀을 한 트랜잭션에서 지운다(CASCADE FK가 설정 자식들을 정리한다). 가드 거부는 P0001을 RAISE하고 핸들러가 HTTP 409로 매핑한다. 테이블·enum 수 변화 없음. - 0089 (2026-07-24 dev 적용): 정산 NAV 컬럼을
NUMERIC(18,6)으로 고정했다.redemption_requests.settled_nav와redemption_epochs.settled_nav가 자릿수 없는NUMERIC에서NUMERIC(18,6)이 됐고, 다른 모든 NAV 컬럼과 맞춰졌다(pools.nav_per_token,nav_history.old_nav/new_nav,redemption_requests.nav_at_request는 배포된 DB에서 이미NUMERIC(18,6)이었다. schema.sql과 docs가 자릿수 없이 보이도록 드리프트해 있었고 이제 바로잡혔다). NAV는 온체인에서 소수 6자리 고정소수점이다(NAV_PRECISION = 1e6). 스케일을 6으로 고정하면 모든 쓰기가 DB 계층에서 온체인 정밀도로 반올림되므로, JS float가 정확한 1e6 정수와 어긋나는 찌꺼기(예: 0.8500000000000001)를 남길 수 없다. 두 settled_nav 컬럼 다 전부 NULL이었으므로 캐스팅 시 반올림된 것은 없다.fill_ratio나 LP 수량 컬럼은 대상이 아니다(스케일이 다르다). 테이블·enum 수 변화 없음. - 0086 (적용 대기): 수익 경로 보강(H1/H2/H3).
yield_distributions.distribution_started_at을 추가했다(H1:/distribute의 동시성 선점 표시로, 온체인 distribute 앞에서 compare-and-set을 하므로 요청 둘이 distributeYield/withdrawFees를 이중 실행할 수 없다).claim_yield_atomic은 이제 온체인으로 검증된(COMPLETED) 청구에 대해 accrued 충분성 가드를 건너뛰고 차감을 0 이상으로 클램프한다. 온체인에서 지급된 금액이 기준이고 아직 정합되지 않은 accrued 미러를 초과할 수 있기 때문이다(H3, 시그니처 동일).reinvest_yield_atomic은p_tx_hash/p_investor_address/p_chain_id를 얻었고(DEFAULT NULL, 하위 호환) 재투자 입금에 기록되어 출처 추적과 tx별 재생 방지에 쓰인다(H2, 핸들러가 이제 온체인Reinvested이벤트를 검증한다). 테이블·enum 수 변화 없음. - 0085 (적용 대기):
process_deposit_atomic.process_deposit_with_tvl+complete_deposit_atomic쌍을 대체하는 단일 트랜잭션 입금 RPC다(F2: 중간 실패 시 TVL과 포트폴리오 포지션이 더 이상 어긋날 수 없다). 그리고investor_address와chain_id를 기록한다(F1: 8453 기본값 대신 멀티체인 출처). 배포 순서 때문에 옛 함수 둘은 남겨 두고 후속 작업에서 삭제한다. 테이블·enum 수 변화 없음. - 0088 (적용 대기):
pools.reserve_balance(라이브 리저브). nullablepools.reserve_balance를 추가했다(사람이 읽는 단위, USD). 온체인 인덱서가 배포된 풀마다 매 실행에서PlatformPool.reserveBalance()를 미러링하므로, 어드민 풀 상세가reserve_bps목표 대비 실제 리저브 비율을 보여 준다(첫 인덱싱 전까지는 NULL이고 목표값만 보여 주는 폴백이다). 테이블·enum 수 변화 없음. - 0082 (적용 대기): 감사 로그 append-only와 콜드 아카이브(v3-86).
activity_events가 이제 append-only다(BEFORE UPDATE/DELETE 트리거가 Lambda 서비스 키를 포함한 모든 role의 변경을 막는다. DELETE는archive_expired_activity_events()안에서만 가능하다). 새 콜드 테이블activity_events_archive(+1 → 테이블 44개)가 5년 하한을 넘긴 행을 원자적 아카이브 RPC(한 트랜잭션 안의 INSERT+DELETE)로 받아, 하드 퍼지 스케줄러를 대체한다. - 0081 (적용 대기): 감사 기록 필드(v3-86).
activity_events.actor_name/actor_role/actor_type/entity_label/before_state/after_state/reason/outcome을 추가했다.actor_name과actor_role은 쓰기 시점에admin_users에서 스냅샷을 뜨므로 이름 변경이나 삭제에도 기록이 살아남는다.before_state와after_state가 구조화된 변경 내용을 담고,reason은 고위험 행위에 필수이며(핸들러가 강제한다),outcome은'success'나'failure'다(실패한 시도도 감사된다).audit_feed를 다시 만들어 이것들을 노출한다(actor_type 뒤에 덧붙는다). 테이블·enum 수 변화 없음. - 0080 (적용 대기): 감사 피드의 행위자 해석(v3-86).
audit_feed뷰가actor_type('admin','investor','system')을 얻었고, 입금·상환 행에서actor_id를 투자자의user_id로 설정한다(전에는 NULL이라 항상 "SYSTEM"이었다).GET /activity-events와 CSV 내보내기가users/wallets로 투자자를 해석한다(FM PII 규칙 때문에 이름만). 그래서 Audit Log가 누가 행동했는지 보여 준다. 뷰만 바뀌고 테이블·enum 수 변화 없음. - 0079 (적용 대기): 세션 중복 제거와 유니크 키. 중복된
admin_sessions/investor_sessions행을 합치고(그룹마다 가장 최근에 관측된 것을 남겼다)idx_admin_sessions_device_unique(admin_user_id, device_info, ip_address)와idx_investor_sessions_device_unique(user_id, device_info, ip_address)를 추가했다. 둘 다NULLS NOT DISTINCT다(PG15 이상). 이제 로그인이 이 키로 upsert하므로 같은 기기와 IP에서 반복 로그인해도 행과sid하나를 재사용하고 "중복" 활성 세션이 쌓이지 않으며, 현재 기기 행이 모호하지 않다("활성 세션 중복으로 뜸" QA 수정). 테이블·enum 수 변화 없음. - 0078 (2026-07-21 dev 적용):
pools.pool_wallet삭제. 레거시 AS_POOL / v2 지갑 컬럼을 제거했다. v3에서는 지갑 역할이 깔끔히 갈린다. FM 자본은fund_wallet이고 플랫폼 운영·배포 지갑은 서버 키(ADMIN_PRIVATE_KEY) 자체다.pool_wallet은 서버 키 주소로 해석되는 기본 operator/treasury 폴백일 뿐이라 풀별 덮어쓰기가 죽어 있었다. 삭제 전 확인: 서버 키 기본값을 쓰는 풀 17개와, 커스텀 값이 자기fund_wallet과 같은 풀 1개(이미 ACTIVE/DEPLOYED이고 온체인 treasury는 고정)다. 삭제해도 온체인 상태는 바뀌지 않는다. ⚠️ 적용 순서: pool_wallet 없는 lambda 코드를 먼저 올린 뒤 마이그레이션을 돌린다. 테이블·enum 수 변화 없음. - 0077:
pools.equity_buffer_rule. FM first-loss / equity 버퍼 공시용 nullable TEXT를 추가했다(자유 텍스트, 예: "Manager absorbs NPL up to 5%"). 어드민 생성·수정 위저드가 수집하고 투자자 PDP 개요가 렌더한다. 생성DB_FIELDS와 PATCH 화이트리스트에 배선했다(노션 Admin Pool Create/Edit B3). 테이블·enum 수 변화 없음. - 0076:
deposits의 투자 건별 리스크 확인.deposits.risk_acknowledged_at(TIMESTAMPTZ)과deposits.risk_ack(JSONB{mode,items,reg_s})를 추가하고 둘을process_deposit_with_tvl에 엮었다(DEFAULT NULL확인 파라미터 둘과 함께 삭제 후 재생성, 0022 패턴). 그래서 Reg S 리스크 재확인이 입금과 같은 원자적 insert에 담긴다. v3-72 핸드오프 항목 D. 테이블·enum 수 변화 없음. - 0075:
net_yield_fee_config의 요율을 basis point로. JSONB 수수료 요율 키platform_yield_take_pct/spc_mgmt_pct/pool_mgmt_pct/perf_fee_pct/perf_hurdle_pct를 제자리에서*_bps로 이름을 바꿨다(×100). 오프체인 계산은×bps/10000이고 검증은 정수0..10000이다. 온체인 쌍둥이는 없다. 테이블·enum 수 변화 없음. - 0074:
external_pool_data_snapshots.cumulative_impairment→cumulative_loss.write_off_policy를 지난 실현 상각이고 NAV에 반영돼 있으며, 선행 NPL 신호와는 구분된다. BE 독자들과 같은 릴리스에서 이름을 바꿨다(v3-72). - 0073: DPD/NPL S2 추가분.
external_pool_data_snapshots에 시계열 컬럼(accrued_income,total_outstanding_principal,active_loan_count,total_overdue_loans,total_overdue_exposure,sector_breakdown, 전부 nullable)을 추가하고, 새 테이블external_pool_dpd_buckets(펀드가 보고하는 동적 DPD 버킷, RLS 켬, +테이블 1 → 43개), 그리고pools.npl_threshold_days(기본 90)와pools.write_off_policy를 추가했다.npl_ratio= Σ exposure(lower_days ≥ 임계값) / total_outstanding_principal를 뒷받침한다(v3-72). - 0072: investor_tier → investor_status + eligibility_mode. 잠자고 있던 미국 중심
investor_tierenum(ACCREDITED/QP)을users와pools에서 삭제했다.users.investor_status(RETAIL/PROFESSIONAL)와qualification_country/qualification_basis/qualification_verified_by/_at/_reason/qualification_expires_at(Status Gate 상태와 감사), 그리고pools.eligibility_mode(STATUS/MIN_TICKET)를 추가했다. 미국 밖 Reg S 적격투자자 모델이다(v3-74, 순증 enum 1개 → 26개). - 0071:
pools.tagline과pools.additional_disclosures추가(풀 생성 콘텐츠 계약. 히어로 태그라인과 풀별 리스크 공시이며 오프체인 표시 전용). - 0070:
admin_sessions.expires_at추가(절대 세션 만료 = 생성 + 30일로 refresh 토큰 수명과 맞춘다.adminSessionExists가 만료된 행을 거부하고 기존 행은 백필했다. S-43). - 0050: 폐기된 v3 컬럼 삭제.
pools.lp_issuance_model(과lp_issuance_modelenum),deposits.fm_notified_at,redemption_requests.fm_notified_at/fm_accepted_at이다(v3-02 LP 모델과 v3-04/v3-34 FM 단계 제거. 코드·뷰·함수 독자 없음.yield_distributions.fm_notified_at은 별개의 유효한 필드라 유지). - 0049:
deposit_statusenum에서REFUNDED값 삭제(v3-03에서 D+7 환불 플로우 제거. 쓰는 행 0개였고 앱과 seed 참조를 정리했다.deposits.status를 쓰는security_invoker뷰 둘은 원문 그대로 삭제 후 재생성). - 0048:
wallets의 유일성을 대소문자 무시로 바꿨다. 제약unique_address_per_chain을 함수 유니크 인덱스(lower(address), chain_id)로 대체하고, 한 번도 붙은 적 없는lowercase_wallet_address()트리거 함수를 삭제했다(죽은 코드 정리. 앱은 이미auth.post.verify에서 주소를 소문자로 만든다). - 0047:
users.email을 nullable로 만들고 레거시 합성<address>@wallet.aset.io자리표시자를 비웠다. 지갑 로그인 가입이 더 이상 가짜 이메일을 저장하지 않고, 어드민과 투자자 화면은 실제 주소가 확인되기 전까지 "not set"을 보여 준다. - 0046:
dashboard_alert_counts에서sbt_mint_failed가 진행 중인 민팅을 제외하고,kyc_pending은 IN_REVIEW만 센다. - 0045:
users.sbt_mint_queued_at. 진행 중인 SBT 민팅 표시다. - 0044:
uq_fund_members_one_primary부분 유니크 인덱스. 펀드마다 ACTIVE primary 펀드 매니저가 정확히 하나이고, 처음 수락된 멤버가 자동 승격된다.
v3.0 차원 컬럼은 pools에 라이브로 올라가 있고, 레거시 v2.x 컬럼은 이력용으로 남겨 둔다. 그 밖의 변경은 다음과 같다.
pools.signing_method삭제(마이그레이션 0015. 풀별 멀티시그 제거).pools.issuer삭제(0016. 펀드 신원은fund_id→funds.name이다).pools.escrow_address/pools.receipt_address삭제(0017. v3.0에서 Escrow와 Receipt NFT 제거).pools.investment_blocked삭제(0018.is_paused가 대신한다, 08 v3-29).pools.pool_type과pool_typeenum 삭제(0019. AS_POOL/FUND_POOL 바이너리 제거, v3-01 차원 모델).- 회차 기반 상환 추가(0021.
pools.redemption_epoch_days+redemption_epochs테이블 +redemption_status의QUEUED/PARTIALLY_FILLED.FM_ACCEPTED제거, v3-26/31/32/34). deposits의 Receipt NFT · Escrow · D+7 환불 컬럼 삭제(0022.receipt_code,receipt_token_id,receipt_issued_at,receipt_tx_hash,receipt_burned_at,receipt_burn_tx_hash,escrow_status,refund_eligible_at과idx_deposits_refund. v3-03/v3-11).pools.freeze_started_at추가(0023. 기간이 정해진 비상 동결이고 온체인freezeStartedAt을 미러링한다. v3-28).redemption_epochs.funding_shortfall추가(0024. 회차 펀딩 부족분이고EpochFundingNeeded의 인덱서 미러다. 기존redemption_requests.funding_shortfall과 함께 "Awaiting Funding $X" KPI를 뒷받침한다. v3-26).fund_pool_assignments테이블 삭제(0025. v3-26 이후 funds와 pools는pools.fund_id로 1:N이다. M:N 조인 테이블은 쓸모없어졌고 앱 코드가 쓴 적도 없다).platform_stats.total_investors를 살아 있는 고유 홀더로 재정의(0026. 합산하던pools.investor_count컬럼은 낡았고 유지된 적이 없다. 이제portfolio_positions에서 유도한다).pool_tvl_history.recorded_date와UNIQUE (pool_id, recorded_date)추가(0027. 새 일일 TVL 스냅샷 스케줄러의 중복 제거 키이고 이 테이블의 첫 라이터다. 투자자 Performance 탭의 TVL 차트를 뒷받침한다).fund_data_snapshots/fund_data_cache를external_pool_data_snapshots/external_pool_data_cache로 이름 바꾸고fund_id를pool_id로(0028. 소유 키는 언제나 풀이다. 풀 수준 매핑만 남기고 펀드 수준 폴백은 삭제했으며funds.external_*는 폐기됐다).audit_feed뷰 추가(0030. 활동과 감사를 통합한 피드이고activity_events의 사람 행위와 경제 이벤트 로그 테이블들의 UNION이다.GET /activity-events가 읽는다. v3-41).notification_logs.payload와notification_logs.read_at추가(0032. 구조화된 알림 문구다.payload->'in_app'이 인앱 피드를,payload->'email'이 SES 발송 워커를 먹인다.read_at은 인앱 미읽음 배지를 받친다. Notification PRD, v3-44).users.email_verified_at/pending_email/email_verification_token/email_verification_expires_at추가(0033. 투자자 이메일 등록과 인증이고, 중요·법적 고지가 실제로 전달 가능한 주소에 닿게 하려는 것이다. 그러지 않으면 지갑 로그인 투자자에게는 합성<address>@wallet.aset.io만 있다. v3-45).pool_governance_changes.change_typeCHECK을 확장해JURISDICTION_WHITELIST허용(0034. 관할 화이트리스트는 타임락이 걸린 필드이고POST /pools/{id}/governance로 제안·실행·취소한다.new_value는 JSON{country,allowed}다. v3-43).- 같은 CHECK을 더 확장해
REDEMPTION_GATING허용(0035. 회차별 상환 체결 상한이고 타임락이 걸린다.new_value는 0~100 퍼센트다. v3-43). pools.fx_rate_source추가(0036.FIXED또는EXTERNAL_FEED이고 운영 통화에서 USD로의 환율을 어떻게 정하는지다. 백엔드 NAV 환산 전용이고 DRAFT에서만 설정한다. v3-43).pools.collateral_type과collateral_typeenum 삭제(0037. 담보는 표시 전용collateral_description과collateral_ratio다. 리스크 신호는 자동 계산되는 Risk Tier로 옮겼다. v3-35/v3-07).portfolio_positions.claimable_yield와claimable_yield_synced_at추가(0038. 수익 정합기가 온체인pendingYield(정산분과 미정산분)를 DB로 미러링하므로 FE가accrued_yield만이 아니라 정확한 청구 가능 총액을 보여 준다. v3-51).platform_settings테이블 추가(0039. 서버에 저장되는 어드민 플랫폼 설정이다. 블록 탐색기 URL과 자동 새로고침 주기를 키→JSONB 뭉치로 담아 localStorage뿐이던 스텁을 대체한다.GET/PUT /admin/settings).users.first_name과users.last_name추가(0040. KYC GREEN 시점에 이미 검증된 SumSub applicant에서 가져온 투자자 이름이다.GET /users/me가company_name만이 아니라 합쳐진 표시 이름을 반환하므로 앱이 "Anonymous Investor" 대신 실제 이름을 보여 준다. v3에서 PII는 미러링만 하고 새로 수집하지 않는다).admin_user_wallets테이블 추가(0041. 비수탁 FM 서명을 위해 FM이 증명한 서명 지갑이다. 펀드 매니저가 SIWE로 지갑 소유를 증명하고 N개를 묶으면 사전 서명 가드·표시·감사 집합으로 쓰인다. 인가의 기준은 온체인msg.sender == pool.fund_wallet에 그대로 남는다.fm-wallet-signing-spec1단계).yield_distributions.deposit_tx_hash추가(0042. FM이 클라이언트에서 서명한depositYieldtx다. 값이 있으면 분배 행이PROCESSING으로 생성되고(쓰이지 않던yield_status값을 재사용하므로 enum 변화 없음), 입금이yield_funding_events에 인덱싱된 뒤 후속POST /yield-distributions/{id}/distribute가 서버 키로distributeYield와withdrawFees를 돌린다.fm-wallet-signing-spec2단계).external_pool_data_snapshots.total_rni→realized_income,total_npl→cumulative_impairment(0043. 파트너 펀드 데이터 지표를 자산군에 중립적인 이름으로 바꿨다. "Realized Net Income"은 "Net"이라는 이름이 틀렸고(값이 수수료 차감 전이다) "Non-Performing Loans"는 90일 기본의 대출 장부를 전제했다. 둘 다 수집과 표시 전용이고 NAV(DPD 기반)나 온체인에는 쓰이지 않는다. Joob 와이어 필드totalRni/totalNpl은 그대로다. 프로바이더 매퍼가 이음매다. v3-55).notification_status에ESCALATED추가(0052.POST /notification-logs/{id}/escalate가 FAILED 알림을DELIVERED로 뒤집지 않고 종착 상태로 표시한다. 뒤집는 방식은 전달 건수를 부풀리고 감사 지표에서 실패를 숨겼다).- 0053: 정확히 중복인 보조 인덱스 5개 삭제(
idx_activity_events_created,idx_auth_nonces_wallet_address,idx_external_pool_data_snapshots_pool_date,idx_fund_members_fund_id,idx_users_sumsub_applicant_id). 각각 남아 있는 UNIQUE 제약이나 PRIMARY KEY, 표준 이름의 btree와 겹쳤다(P-7 성능 정리. 쿼리 플래닝에 영향 없고 테이블·enum 수 변화 없음). - 0054:
admin_user_wallets에 Row Level Security 활성화. RLS가 없던 마지막 테이블이었다(anon은 전면 거부, 정책 없음. Lambda가 쓰는 service_role 키는 RLS를 우회하므로 서버 접근은 그대로다. S-7 /get_advisors보안). - 0055:
investor_activity뷰 추가(P-5. 통합 투자자 타임라인이고depositsINVEST +redemption_requestsREDEEM +yield_distribution_investorsYIELD +yield_claimsCLAIM을 공통 컬럼 집합으로 UNION한다.GET /investor-activity가user_id로 투자자 범위를 잡아 읽으며 클라이언트 쪽 fetch 4번을 대체한다.security_invoker = true. 테이블·enum 수 변화 없음). - 0057:
notification_logs.claimed_at추가(S-14. SES 발송 워커가 보내기 전에 행을 원자적으로 CLAIM하므로 겹쳐 도는 cron 두 개가 서로 겹치지 않는 작업 집합을 갖고 절대 이중 발송하지 않는다. markFailed가 claim을 풀어 주므로 재시도는 평소 주기를 유지한다). - 0058:
(pool_id, on_chain_request_id)에on_chain_request_id IS NOT NULL조건의 부분 유니크 인덱스uq_redemption_requests_onchain_id(S-22. 온체인 상환 요청 하나가 미러링된 DB 행 최대 하나에 대응하므로 이중 지급과 오귀속을 막는다). - 0059: 자금 관련 테이블에 양수 금액 CHECK 제약(S-26. deposits / redemption_requests / yield_distributions / yield_distribution_investors / yield_claims의 금액은
> 0, 수수료·패널티·지급액과 loan_writeoffs는>= 0이다.NOT VALID로 추가해서 마이그레이션이 기존 행은 건너뛰고 새로 들어오거나 수정되는 행은 전부 강제한다). - 0060:
investor_sessions테이블 추가(S-9. 서버에서 폐기할 수 있는 투자자 세션이고 admin_sessions를 미러링하되users.id로 키잉한다. 행 id가 투자자 JWT에sid로 실리고 행이 존재하는 동안만 토큰이 인정되므로, 로그아웃이나 앞으로의 폐기 기능이 유출된 refresh 토큰을 죽일 수 있다. RLS는 켜고 정책은 없으며 service_role은 우회한다. +테이블 1 → 41개). - 0061:
admin_users.failed_login_attempts와locked_until추가(S-11./auth/admin-login에 계정 단위 무차별 대입 잠금이다. 비밀번호가 틀릴 때마다 올라가고 5회에서 15분 잠기며 성공하면 초기화된다. IP 단위 WAF는 별도 트랙이다). - 0062:
external_pool_data_snapshots.total_subscribed추가(펀드가 보고하는 누적 약정 원금이고 nullable이다. 이전 스냅샷은 이 필드보다 앞선다). 그리고 새 테이블fund_service_providers(펀드 수준 서비스 제공자로 감사인·관리자·수탁사·법률이고, 어드민의 펀드 생성·수정에서 입력하며 풀의 Fund Data 탭에서는 읽기 전용이다. +테이블 1 → 42개). 보안 계열의 0054 RLS 마이그레이션과 번호가 겹쳐서 0054를 0062로 다시 매겼다. - 0063: 풀 수준의 KYC/KYB 필수 레벨 제한 제거.
pools.kyc_level_required와pools.requires_institutional, 그리고pool_governance_changes.change_typeCHECK의KYC_LEVEL값을 삭제했다. 풀은 더 이상 개인(KYC)과 법인(KYB) 투자자를 구분하지 않는다. 게이팅은 관할과 유효한 SBT뿐이다. 온체인PoolConfig.requiresInstitutional필드,InstitutionalOnly게이트, KycLevelChange 거버넌스 플로우는 같은 변경에서 컨트랙트에서도 제거했다. SBT의 개인·법인 속성(users.kyc_level/PlatformKYCSoulbound.isInstitution)은 영향이 없다(여전히 KYC 온보딩과 관할 해석에 쓴다). 삭제 전 기본값이 아닌 행이 0개라 안전했다. 테이블·enum 수 변화 없음. - 0064:
pools.enforce_jurisdiction추가(BOOLEAN, 기본 false). 온체인PlatformPool.enforceJurisdictionbool을 1:1로 미러링하는 명시적 관할 강제 플래그다(옵션 1 / v3-60). "화이트리스트가 비어 있지 않으면 강제"라는 암묵 규칙을 대체하므로, 화이트리스트를 비웠을 때 DB와 백엔드checkKycGating, 온체인이 드리프트 없이 일치한다. 플래그가 켜지면 배포 워커가 온체인 강제를 활성화한다(setEnforceJurisdiction과 국가별setJurisdictionAllowed, alpha-3). 이미 비어 있지 않은 화이트리스트를 갖고 있던 풀은true로 백필했다(동작 보존). 테이블·enum 수 변화 없음. - 0065:
pool_governance_changes.change_typeCHECK을 확장해TREASURY와ENFORCE_JURISDICTION허용(#13. 온체인 7일 타임락 플로우 둘 다 이미 있었지만 앱 경로가 없었다. 이제POST /pools/{id}/governance가 노출한다. TREASURY는treasury_wallet을, ENFORCE_JURISDICTION은enforce_jurisdiction을 미러링한다. 컨트랙트 변경 없음). - 0066:
investor_activity뷰에lp_filled컬럼을 덧붙였다(REDEEM 분기는redemption_requests.lp_filled이고 INVEST/YIELD/CLAIM에서는 NULL이다). 그래서 투자자 앱이 회차 Claim 동작을 미리 게이팅할 수 있다. 요청이 체결률 0%로 정산될 수 있고(전부 게이팅됐거나 이월됐다) 그러면 청구할 것이 없다.CREATE OR REPLACE VIEW로 덧붙이기만 했고 테이블·enum 수 변화 없음(#16). - 0067: 풀 관리 수수료 분리.
pools.fund_fee_wallet(풀 관리 수수료 목적지이고 컨트랙트의fundFeeWallet이며fund_wallet과 다르다)과yield_distributions.pool_mgmt_fee_amount를 추가하고, 기존 행의net_yield_fee_config키admin_fee_pct를platform_yield_take_pct로 바꿨다(그리고 새spc_mgmt_pct/pool_mgmt_pct키를 더했다. 스키마 없는 JSONB다). 수익 Lambda가 이제 플랫폼 몫(gross×pct)과 spc·풀 관리(tvl×pct×days/365)를 오프체인에서 계산하고 온체인에서withdrawFees(treasuryAmount, poolMgmtAmount)로 나눈다(풀 관리분은fund_fee_wallet으로). 성과 보수는 미뤘다. 테이블·enum 수 변화 없음. - 0068: 비율 필드를 정수 basis point(bps)로 통일했다.
pools.reserve_percentage를reserve_bps로(percent NUMERIC → INTEGER NOT NULL DEFAULT 1000, CHECK 0..10000),pools.redemption_gating_pct를redemption_gating_bps로(percent NUMERIC → INTEGER, CHECK null 또는 0..10000),pools.penalty_rate를penalty_rate_bps로(0~1 분수 NUMERIC → INTEGER, CHECK null 또는 0..10000) 바꿨다. 1% = 100 bps, 100% = 10000 bps다. 옛 값은 ×100(reserve, gating)이나 ×10000(penalty_rate)으로 이관했고, 온체인amount × bps / 10000관례와 맞는다.pool_governance_changes.change_typeenum 값(RESERVE_BPS,REDEMPTION_GATING)은 그대로이고 그new_value만 이제 bps다. 테이블·enum 수 변화 없음. - 0069: 중복이던 조기 패널티 bps 컬럼 둘을 통합했다.
pools.early_redemption_penalty_bps를 삭제했고pools.penalty_rate_bps가 유일한 조기 상환 패널티 필드가 됐으며, 백엔드가 모든 패널티 타입에 대해 그것으로 온체인RedemptionConfig.penaltyRateBps를 채운다. 별도의 어드민 조기 패널티 입력칸은 제거했다. 테이블·enum 수 변화 없음. - 0070: DB와 컨트랙트의 이름 정합(컬럼 이름만 바꾸고 값은 그대로).
pools.redemption_epoch_days를epoch_duration_days로(온체인epochDurationDays와 맞춘다),pools.kyc_jurisdiction_whitelist를jurisdiction_whitelist로(온체인jurisdictionWhitelist와 맞춘다) 바꿨다. 타입과 의미는 그대로이고 테이블·enum 수 변화 없음. 짝이 되는 컨트랙트 쪽 이름 변경(hardCap→capacity,flatFeeAmount→penaltyFeeAmount,noticePeriodDays→standardRedemptionDays)은 struct 필드만 바꾸고 DB 컬럼은 건드리지 않는다(17-changelog 참조).
아래 v3.0 마이그레이션 요약도 함께 볼 것.
🔐 인증 (테이블 4개)
ℹ️ 투자자 쪽 스키마
인증 테이블(users, auth_nonces, wallets)은 투자자 쪽 스키마다. 어드민 백엔드 스키마는 아래 Admin Management 절부터다.
👤 users
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
email | TEXT UNIQUE (인증 전까지 NULL, 0047) |
email_verified_at | TIMESTAMPTZ |
pending_email | TEXT |
email_verification_token | TEXT (메일로 보낸 토큰의 sha-256 해시, S-42) |
email_verification_expires_at | TIMESTAMPTZ |
role | user_role DEFAULT 'GUEST' |
investor_status | investor_status NOT NULL DEFAULT 'RETAIL' (0072, investor_tier 대체. Status Gate 결과) |
qualification_country | TEXT (0072, PROFESSIONAL 상태를 판단한 ISO alpha-3) |
qualification_basis | TEXT (0072, 근거 코드. 열린 집합: SG_AI / HK_PI / EU_MIFID_PRO 등) |
qualification_verified_by | UUID → FK admin_users(id) (0072, 감사: 부여한 어드민) |
qualification_verified_at | TIMESTAMPTZ (0072, 감사: 부여 시각) |
qualification_verified_reason | TEXT (0072, 감사: 증빙이나 메모) |
qualification_expires_at | TIMESTAMPTZ (0072, 재검증 만료. SBT 만료를 따라간다) |
sumsub_applicant_id | TEXT UNIQUE |
kyc_level | kyc_level |
kyc_status | kyc_status DEFAULT 'NOT_STARTED' |
sbt_status | sbt_status DEFAULT 'NOT_MINTED' |
sbt_tx_hash | TEXT |
sbt_token_id | INTEGER |
sbt_error | TEXT |
country | TEXT |
risk_level | TEXT |
first_name | TEXT. 개인 투자자 이름이고 KYC GREEN 시점에 검증된 SumSub applicant에서 미러링한다 (0040) |
last_name | TEXT. 개인 투자자 이름 (0040) |
company_name | TEXT. KYB 법인명(개인은 first_name/last_name을 쓴다) |
company_registration_number | TEXT |
company_country | TEXT |
kyc_source | TEXT DEFAULT 'SELF' CHECK (SELF/PARTNER_REUSED), v3-17 |
sbt_expires_at | TIMESTAMPTZ. v3-19 SBT 유효기간 미러 |
sbt_mint_queued_at | TIMESTAMPTZ. 진행 중인 민팅 표시(0045). 큐에 넣을 때 찍고 종착 쓰기에서 지운다. 최근(10분 이내)이면 재시도 UI를 숨긴다 |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ DEFAULT now() |
🔑 auth_nonces
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
wallet_address | TEXT NOT NULL UNIQUE |
nonce | TEXT NOT NULL UNIQUE |
expires_at | TIMESTAMPTZ NOT NULL |
used_at | TIMESTAMPTZ |
created_at | TIMESTAMPTZ DEFAULT now() |
💳 wallets
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
user_id | FK → users (CASCADE) |
address | TEXT NOT NULL |
chain_id | chain_id NOT NULL |
label | TEXT |
created_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(address, chain_id)
🎫 investor_sessions (0060)
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
user_id | FK → users (CASCADE) |
device_info | TEXT |
ip_address | INET |
last_seen_at | TIMESTAMPTZ DEFAULT now() |
expires_at | TIMESTAMPTZ (생성 + 30일. refresh 토큰 수명을 미러링한다) |
created_at | TIMESTAMPTZ NOT NULL DEFAULT now() |
IDX: user_id. RLS는 켜고 정책은 없다. 서비스 키(Lambda)만 건드리고
service_role은 RLS를 우회한다.
폐기 가능한 투자자 세션(S-9). 투자자 인증은 상태 없는 JWT라서 이것이 없으면 유출된 30일 refresh 토큰이 계속 access 토큰을 찍어 낼 수 있고 그걸 죽일 방법이 없다. SIWE 로그인마다 행을 넣고, 그
id가 토큰에sid로 실리며 행이 존재하는 동안만 토큰이 인정되므로 로그아웃은 삭제다.admin_sessions의 투자자 쪽 짝이고users.id로 키잉한다.
🛡️ 어드민 관리 (테이블 4개)
👤 admin_users
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
email | TEXT NOT NULL UNIQUE |
name | TEXT |
avatar_url | TEXT |
wallet_address | TEXT |
role | admin_role DEFAULT 'OPERATOR' |
invite_code | TEXT UNIQUE |
invited_by | FK → admin_users.id |
last_active_at | TIMESTAMPTZ |
deleted_at | TIMESTAMPTZ (소프트 삭제) |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ DEFAULT now() |
password_hash | TEXT |
auth_method | TEXT DEFAULT 'GOOGLE_OAUTH' |
totp_secret | TEXT |
totp_enabled | BOOLEAN NOT NULL DEFAULT false |
totp_last_used_step | BIGINT |
totp_enrolled_at | TIMESTAMPTZ |
🔴 소프트 삭제는 이메일을 계속 붙잡아 둔다
DELETE /admin-users/{id}는 deleted_at을 설정하고 행을 남긴다(2026-03-04부터 소프트 삭제이고 그전에는 하드 삭제였다). admin_users_email_key는 조건 없는 UNIQUE (email)이라 삭제된 계정이 자기 주소를 계속 소유한다. 삭제 여부와 무관하게 같은 이메일로 두 번째 행을 만들 수 없다.
이 테이블에 쿼리를 쓰기 전에 그 짝을 먼저 읽을 것. deleted_at IS NULL로 범위를 잡은 중복 검사는 "이미 존재한다"의 뜻에 대해 제약과 의견이 다르고, 초대 엔드포인트가 고칠 수 없는 500을 내보낸 경위가 정확히 그것이다. 가드는 통과했고 INSERT가 유니크 인덱스에 걸렸으며 어떤 재시도도 그것을 풀 수 없었다.
그래서 POST /admin-users/invite는 insert 대신 소프트 삭제된 행을 복구한다(감사에는 ADMIN_USER_INVITE가 아니라 ADMIN_USER_RESTORE로 남는다). 복구는 그 계정의 이전 삶을 지운다. admin_user_permissions 행을 먼저, 그다음 password_hash와 totp_* 일습, 로그인 잠금을 지운다. 되살아난 사람이 제거될 때 갖고 있던 권한이나 자격 증명을 그대로 들고 돌아올 수 없게 하려는 것이다. 같은 행을 유지하는 것은 의도한 것이다. 감사 이벤트와 invited_by 참조가 그 id를 가리키고, 두 번째 행은 한 사람의 이력을 둘로 쪼갠다.
인증
Google OAuth 또는 비밀번호. 최초 어드민은 env나 DB로 심는다.
비밀번호 로그인(/super, Super Admin)은 TOTP 2FA가 필수다(마이그레이션 0009_admin_totp). 비밀번호가 맞으면 5분짜리 mfa_token을 돌려주고, /auth/admin-2fa/verify가 그것과 6자리 인증 앱 코드를 진짜 JWT로 교환한다. 첫 로그인 때 등록이 강제된다. totp_last_used_step이 마지막으로 받아들인 30초 타임 스텝을 저장해 코드 재사용을 막는다. 백업 코드는 없고, 복구는 totp_* 컬럼 4개를 손으로 초기화하는 것이다.
🔒 admin_user_permissions
| 컬럼 | 타입 |
|---|---|
admin_user_id | FK → admin_users (CASCADE) |
page_key | TEXT NOT NULL |
granted | BOOLEAN DEFAULT false |
granted_by | FK → admin_users.id |
granted_at | TIMESTAMPTZ DEFAULT now() |
PK(admin_user_id, page_key). 오퍼레이터에게만 쓴다. 어드민은 전체 접근 권한을 갖는다.
🔑 admin_user_wallets
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
admin_user_id | FK → admin_users (CASCADE) |
wallet_address | TEXT NOT NULL |
verified_at | TIMESTAMPTZ DEFAULT now() |
created_at | TIMESTAMPTZ DEFAULT now() |
마이그레이션 0041 (B3). FM이 증명한 서명 지갑이다. 펀드 매니저가 SIWE로 지갑 소유를 증명하고(
POST /admin/wallet/nonce→/admin/wallet/verify), FM 한 명이 지갑 N개를 가질 수 있다(풀마다fund_wallet이 다르다). 인가의 기준은 온체인에 그대로 있고(msg.sender == pool.fund_wallet), 이것은 사전 서명 가드와 표시, 감사를 위한 증명된 지갑 집합이다.UNIQUE (admin_user_id, lower(wallet_address)). SoT:fm-wallet-signing-spec.
🖥️ admin_sessions
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
admin_user_id | FK → admin_users (CASCADE) |
device_info | TEXT |
ip_address | INET |
last_seen_at | TIMESTAMPTZ DEFAULT now() |
expires_at | TIMESTAMPTZ (생성 + 30일, 0056) |
created_at | TIMESTAMPTZ DEFAULT now() |
폐기 가능한 어드민 세션: 어드민 로그인(
/auth/admin-2fa/verify,/auth/admin-oauth)마다 행 하나이고, 그 행id가 발급된 JWT에sid클레임으로 박힌다. 토큰은 행이 존재하고expires_at이 지나지 않은 동안에만 인정된다(마이그레이션 0056, S-43. 30일 refresh 토큰 수명을 미러링하는 절대 만료이고, 0056 이전 행의 NULL은 만료 없음으로 친다)./auth/refresh와withAuth/withRole가드가sid행이 없거나 만료된 토큰을 거부한다. "모든 기기에서 로그아웃"(/auth/admin/logout-all)은 그 어드민의 행을 전부 지우고, 기기별 로그아웃(/auth/admin/sessions/{id}/revoke)은 하나를 지운다.last_seen_at은 refresh 때 갱신된다. 로그인은(admin_user_id, device_info, ip_address)로 upsert한다(idx_admin_sessions_device_unique, NULLS NOT DISTINCT, 0079). 같은 기기와 IP에서 반복 로그인해도 행과sid하나를 재사용하므로 목록에 세션이 중복되지 않는다.investor_sessions에는(user_id, device_info, ip_address)에 대한 짝인idx_investor_sessions_device_unique가 있다.
만료는 인증뿐 아니라 읽을 때도 적용한다(0102).
GET /auth/admin/sessions가 인증 가드와 같은 조건으로expires_at을 거르므로, 만료된 행이 더 이상 활성 기기로 나열되지 않는다(전에는 그 행이 받치던 토큰이 이미 죽었는데도 멀쩡해 보이는 "Log out" 버튼과 함께 나타났다). 정리 스케줄러는 없다./auth/refresh가 호출자 자신의 만료된 행을 지우므로 쌓이지 않는다.is_current는 삭제됐다(0102). "이 기기"는 호출자 토큰의sid로 요청마다 유도하고(auth.get.admin-sessions) 그 컬럼을 쓴 것은 아무것도 없었다. API 응답은 같은 이름의 파생 필드를 유지한다.
🏢 펀드 관리 (테이블 4개)
🏦 funds
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
name | TEXT NOT NULL |
verified | BOOLEAN DEFAULT false |
description | TEXT |
established | SMALLINT |
total_originated | NUMERIC DEFAULT 0 |
website_url | TEXT |
logo_url | TEXT |
primary_contact_name | TEXT |
primary_contact_email | TEXT |
status | fund_status DEFAULT 'ACTIVE' |
notification_health | TEXT DEFAULT 'HEALTHY' |
external_provider | TEXT. 펀드 데이터 프로바이더 키(예: 'JOOB') |
external_fund_id | TEXT. 프로바이더 쪽 펀드 id |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ DEFAULT now() |
👥 fund_members
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
fund_id | FK → funds (CASCADE) |
wallet_address | TEXT |
email | TEXT NOT NULL |
is_primary | BOOLEAN DEFAULT false. 펀드마다 ACTIVE primary가 정확히 하나다(0044). 처음 수락된 멤버가 자동으로 primary가 되고, 어드민이 관리하며 primary는 제거하거나 비활성화할 수 없다 |
name | TEXT |
status | TEXT DEFAULT 'ACTIVE' |
joined_at | TIMESTAMPTZ DEFAULT now() |
created_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(fund_id, wallet_address), UNIQUE(fund_id, email), UNIQUE(fund_id) WHERE is_primary AND status='ACTIVE' (
uq_fund_members_one_primary, 0044)
📨 fund_invites
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
fund_id | FK → funds (CASCADE) |
email | TEXT NOT NULL |
invite_code | TEXT NOT NULL UNIQUE |
invited_by | FK → admin_users.id |
status | TEXT DEFAULT 'PENDING' |
accepted_at | TIMESTAMPTZ |
expires_at | TIMESTAMPTZ DEFAULT now() + 7 days |
created_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(fund_id, email)
🧾 fund_service_providers
펀드 수준의 서비스 제공자(감사인, 관리자, 수탁사, 법률 등)다. 어드민의 펀드 생성·수정에서 입력하고 풀의 Fund Data 탭에 읽기 전용으로 노출한다(0054). 제공자는 매니저나 펀드에 속하고 그 펀드의 풀들이 물려받는다.
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
fund_id | FK → funds (CASCADE) |
role | TEXT NOT NULL. 예: Auditor, Administrator, Custodian, Legal |
name | TEXT NOT NULL |
created_at | TIMESTAMPTZ DEFAULT now() |
0110: RLS 활성화(켬, 정책 없음). 이 행들은 공개 투자자 풀 페이지에 렌더되므로 publishable key로 열거하거나 수정할 수 있어서는 안 된다.
⚠️
role은 허용 목록 없는 자유 텍스트다. 의도한 어휘가 있는데도 그렇다. 오타(Audtior)가 투자자용 Fund Data 탭에 그대로 닿고, 같은 역할의 두 철자를 묶어 주는 것이 없다.name과 배열 자체도 상한이 없다(parseServiceProviders가 trim은 하지만 길이나 개수를 제한하지 않는다).
⚠️ 전체 교체 쓰기가 원자적이지 않다.
replaceFundServiceProviders가 그 펀드의 모든 행을 DELETE한 뒤 새 집합을 INSERT하는데 문장이 둘로 나뉘어 있다. insert가 실패하면 delete는 이미 커밋된 뒤라 그 펀드의 제공자가 사라지고, 핸들러는 "Failed to update fund service providers"를 반환한다. 그건 "아무것도 바뀌지 않았다"로 읽힌다. 복구는 손으로 다시 입력하는 것이다. 저장소는 정확히 이런 용도로 이미 Postgres 함수를 쓰고 있다(increment_accrued_yield,complete_redemption_atomic). 둘을 하나로 옮기는 것이 해법이다. 0110에 묶지 않았다. 알림 재구축과 관계없는 문제다.
INDEX(fund_id) (
idx_fund_service_providers_fund_id)
📡 펀드 데이터 동기화 (테이블 3개)
외부 펀드 데이터 프로바이더(예: Joob)의 이력·요약을 외부 매핑된 풀에 대해 미러링한다. 하이브리드 서빙이다. 이력은 DB에서, 즉석 데이터는 짧은 TTL 캐시로 준다. 소유 키는 pool_id(pools.id이고 매핑은 pools.external_*에 있다)이므로 FK 제약이 없다(풀이 배포 전일 수 있다). 마이그레이션 0028에서 fund_data_*에서 이름을 바꿨다(풀 수준만 남기고 펀드 수준 폴백은 삭제).
📈 external_pool_* — 삭제됨 (0197)
external_pool_data_snapshots, external_pool_data_cache, external_pool_dpd_buckets, external_pool_nonperforming과 external_pool_dpd_latest 뷰가 없어졌다. 파트너 펀드 데이터를 최신값으로 upsert하던 저장소였고 아래 테이블 셋이 대체한다.
🔴 확장하지 않고 없애야 했던 이유. 프로바이더가 이력을 다시 쓴다. 실제로 확인된 사실이다. 마스터 이력에는 trigger: "backfill" 행(마스터가 존재하기 전 기간의 사후 재구성), 하위 펀드가 들어오거나 나갈 때 값이 움직이는 attach/detach 행이 있고, 225일 중 20일은 fundValue가 null인데 나중에 채워질 수 있다. external_pool_data_snapshots는 (pool_id, snapshot_date)로 키잉해 upsert했으므로 재작성마다 대체된 값이 파괴됐다. 그 순간 두 가지가 불가능해졌다. "이 NAV를 상각할 때 파트너가 뭐라고 했는가"에 답하는 것, 그리고 산식을 고친 뒤 다시 유도하는 것이다. 입력이 사라졌기 때문이다.
loan_writeoffs는 함께 삭제되지 않았다. 자기 절을 참조할 것. 파트너가 아직 제공하지 않는 대출 단위 기록을 위한 스키마이고 라이터를 가진 적이 없으며, 미뤄진 v3-13 DPD → NAV 파이프라인이 그것을 채울 것이다.
설계의 기준 문서: joob-feed-architecture.md.
📞 report_fetches
외부 리포트 API 호출 하나당 행 하나이고 성공과 실패 둘 다 남긴다. "이게 마지막으로 언제 됐나"가 CloudWatch 고고학이 아니라 질의 가능한 사실이 된다. 이것이 대체한 파이프라인은 모든 오류를 잡아먹고 200 {"pools":0}을 반환했으므로, 폐기된 키와 403, 킬 스위치와 조용한 하루를 구분할 수 없었고 피드가 이틀 동안 멈춰 있었다.
| 컬럼 | 타입 |
|---|---|
id | BIGSERIAL PK |
run_id | UUID NOT NULL. 스케줄러 호출 하나 |
source | TEXT NOT NULL ('JOOB') |
entity_type / entity_id | TEXT. FUND/1, MASTER/2. 디스커버리 호출에서는 NULL |
endpoint | TEXT NOT NULL |
envelope_code | INTEGER. 🔴 실제로 중요한 상태다. 프로바이더가 논리적 실패에도 HTTP 200으로 답하고 결과를 본문의 code에 담는다 |
envelope_type | TEXT (SUCCESS | VALIDATION_ERROR | FORBIDDEN 등) |
http_status | INTEGER. 애플리케이션 대신 엣지나 프록시가 답한 경우만 잡힌다 |
provider_request_id | TEXT. 문제를 신고할 때 프로바이더가 요구하는 값 |
latency_ms · response_bytes · observation_count | INTEGER |
error | TEXT |
started_at · finished_at | TIMESTAMPTZ NOT NULL |
IDX: (source, started_at DESC) · (source, entity_type, entity_id, started_at DESC) · error IS NOT NULL 조건의 부분 인덱스 (started_at DESC) · RLS 켬, 정책 없음
🧾 report_events
관측 로그다. 행 하나 = "이 출처가 이 엔티티에 대해 이 업무일 기준으로 이 지표가 이 값이라고 말했다". Append-only여서 재작성은 새 행이지 덮어쓰기가 아니다.
| 컬럼 | 타입 |
|---|---|
id | BIGSERIAL PK |
source · entity_type · entity_id | TEXT NOT NULL |
pool_id | UUID FK pools(id), nullable. 풀이 매핑되기 전에도 엔티티를 관측할 수 있다 |
scope | TEXT NOT NULL CHECK IN ('ENTITY','POOL'). ENTITY는 파트너가 보고하는 펀드나 마스터, POOL은 우리에게 귀속된 것이다. 둘을 떼어 놓는 것이 파트너의 펀드 가치가 이 풀의 TVL로 나가는 것을 막는다 |
metric | TEXT NOT NULL. FUND_VALUE · REALIZED_INCOME · CUMULATIVE_LOSS · TOTAL_SUBSCRIBED · DPD_EXPOSURE · DPD_LOAN_COUNT · DPD_PCT_OF_OUTSTANDING · NPL_PENDING_* · NPL_WRITTEN_OFF_* · FX_RATE · OWNERSHIP_SHARE · GROSS_RETURN · SUBSCRIBED · MASTER_* · CONVERTED_RETURN과 속성들(NAME · MANAGER · STATUS_CODE · STATUS_LABEL · CURRENCY_CODE · START_DATE · END_DATE · DURATION_SECONDS · SUBFUND_COUNT · COMPOSITION_VERSION · DAYS_ELAPSED) |
dimension | TEXT NOT NULL DEFAULT ''. DPD 버킷의 하한이다. NOT NULL인 이유는 Postgres가 UNIQUE에서 NULL을 서로 다른 값으로 보기 때문이다. 그러면 차원 없는 같은 관측이 폴링마다 들어간다 |
unit | TEXT NOT NULL CHECK IN ('CURRENCY','RATIO','PERCENT','COUNT','DAYS','TEXT','TIMESTAMP'). 🔴 실제로 무게를 지는 필드다. −1782로 나온 NAV는 10.2억 루피아를 57.3만 달러에서 뺀 결과였다 |
currency | TEXT (unit=CURRENCY일 때) |
value | NUMERIC, nullable (0196) |
text_value | TEXT, nullable (0196). 둘 중 정확히 하나만 설정된다는 CHECK이 있다 |
as_of | DATE NOT NULL. 업무일이고, 월 단위 키는 그달의 1일로 저장한다 |
as_of_grain | TEXT NOT NULL CHECK IN ('DAY','MONTH'). 마스터 DPD는 월 단위다 |
observed_at | TIMESTAMPTZ NOT NULL. 우리가 본 시각이고 재작성의 순서를 정한다 |
qualifier | JSONB NOT NULL DEFAULT {}. 프로바이더 자신의 서술이다. source / trigger / compositionVersion / computedAt / explicit / mixed |
fetch_id | BIGINT NOT NULL FK report_fetches(id) |
raw | JSONB. 받은 그대로의 응답이다. 그래서 모델링하지 않은 필드가 마이그레이션 더하기 재조회가 아니라 재유도로 해결된다 |
digest | TEXT NOT NULL. 멱등 키이고 버전 접두사가 붙는다 |
UNIQUE (source, entity_type, entity_id, metric, dimension, as_of, digest). 원장의
(chain_id, tx_hash, log_index)에 대응한다. 이미 기록된 값은 다시 읽어도 아무 일이 없고, 재작성된 값은 해시가 달라 새 행으로 들어온다. 신원 컬럼들에 대한 평범한 UNIQUE로 하지 않은 것은 의도적이다. 그러면 재작성이 충돌이 된다. IDX: (pool_id, scope, metric, dimension, as_of DESC, observed_at DESC) · (source, entity_type, entity_id, as_of DESC) · (fetch_id) · RLS 켬, 정책 없음
🔭 report_pool_latest
프로젝션이다. (pool_id, scope, metric, dimension)마다 최신 관측이고, 증분이 아니라 report_events에서 다시 만든다. 뒤에 이벤트가 없는 행은 덮어쓰이기를 기다리는 행이므로 다른 무엇도 이 테이블을 쓰지 않는다. 컬럼은 로그와 같고 event_id와 rebuilt_at이 더 있으며, PK는 그 키 컬럼 4개다.
시계열에는 테이블이 필요 없다. 로그에 대한 DISTINCT ON (as_of)가 곧 시계열이다.
🏊 풀 (테이블 7개)
🏊 pools
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
fund_id | FK → funds.id. **필수(v3-26)**이고 앱 계층에서 강제한다(pools.post.create). fund_id 없는 행을 정리한 뒤 NOT NULL 마이그레이션을 하기 전까지 DB에서는 여전히 nullable이다 |
name | TEXT NOT NULL |
description | TEXT |
lifecycle_status | lifecycle_status DEFAULT 'DRAFT' |
is_paused | BOOLEAN DEFAULT false |
category | TEXT NOT NULL → FK pool_categories(name) |
capacity | NUMERIC DEFAULT 0 |
min_investment | NUMERIC NOT NULL |
apy_rate | NUMERIC nullable (0216). 투자자에게 보이는 표시용 연 요율이고 발생 기준이 아니다 — FIXED 드라이버는 accrual_rate_bps로 청구하며 그쪽은 온체인 immutable이다. 🔴 NULL ≠ 0. 0%는 풀이 *"아무것도 안 준다"*고 주장하는 것이고, NULL은 *"표시 요율을 공표하지 않았다"*다. 읽는 쪽이 먼저 들어갔다(566ce775) — formatApyRate가 다른 빈 값들과 같은 대시를 낸다. 2026-08-27 결정(v3-154): 발생 모드에 따라 accrual_rate_bps와 배타적이다. TARGET 풀은 apy_rate를 갖고 accrual_rate_bps를 비우며, FIXED 풀은 그 반대다. ⚠️ 0216은 이 절반만 열었다 — accrual_rate_bps는 아직 NOT NULL DEFAULT 0이라 TARGET 쪽 배타는 미완이다 |
term | TEXT DEFAULT '' |
eligibility_mode | eligibility_mode NOT NULL DEFAULT 'MIN_TICKET' (0072, investor_tier 대체. STATUS는 Status Gate, MIN_TICKET은 Ticket Gate) |
asset_count | INTEGER DEFAULT 0 |
chain_id | chain_id NOT NULL |
accepted_currencies | currency[] DEFAULT '{USDC}' |
reserve_bps | INTEGER NOT NULL DEFAULT 1000 (0068). basis point이고(1% = 100 bps) CHECK 0..10000이다. 0068에서 reserve_percentage(percent NUMERIC)에서 이름을 바꿨다 |
accrual_rate_bps | INTEGER nullable, no default (0218); 도입은 0207. FIXED 수익 드라이버의 연 발생률이고 bps다. CHECK 0..10000. 🔴 2026-08-27 결정(v3-154): 발생 모드에 따라 apy_rate와 배타적이다. FIXED 풀이 이쪽을 갖고 apy_rate를 비운다. 0216·0218로 양쪽 다 열렸다. 🔴 그리고 이 컬럼의 NULL이 모드를 정한다 — resolveYieldMode가 이걸 읽어 createFixedPool/createTargetPool을 고르고, Clones라 선택은 영구다. NULL ≠ 0(0은 아무것도 안 주기로 약정한 FIXED 풀이다). 이미 bps이므로 perf_hurdle_bps와의 허들 비교는 × 100 없이 그대로 가져간다(이유). 🔴 생성 시에만 정한다. PlatformPool.initialize에 배포되고 setter가 없다. 요율을 바꾸면 이미 발생한 채무를 다시 쓰게 되기 때문이다(v3-131 (3)). 이 컬럼은 무엇이 배포됐는지의 기록이지 제어 표면이 아니다. DEFAULT 0은 기존 행에는 맞고(그 구현체에는 발생 드라이버 자체가 없다) 새 풀에는 안전한 기본값이 아니므로, pools.post.create가 이 필드를 필수로 요구한다. 0인 풀은 깔끔히 배포되고 아무것도 발생시키지 않으며 그렇다고 revert하지도 않는다 |
reserve_balance | NUMERIC (0088). 라이브 온체인 리저브(사람이 읽는 단위, USD)이고 인덱서가 매 실행에서 PlatformPool.reserveBalance()(raw 1e6 ÷ 1e6)로 미러링한다. reserve_bps는 목표 비율일 뿐이고 이쪽이 실제 보유 리저브다. NULL은 아직 인덱싱되지 않았다는 뜻이다(DRAFT나 미배포는 NULL로 남고 어드민 UI는 목표값만 보여 주는 화면으로 폴백한다) |
lockup_days | INTEGER DEFAULT 0 |
lockup_label | TEXT DEFAULT '' |
maturity_days | INTEGER |
penalty_type | penalty_type DEFAULT 'NO_EARLY' |
penalty_fee_amount | NUMERIC |
penalty_rate_bps | INTEGER (0068). basis point이고(1% = 100 bps) CHECK은 null 또는 0..10000이다. 0068에서 penalty_rate(0~1 분수 NUMERIC)에서 이름을 바꿨다. 유일한 조기 상환 패널티 필드이고, 0069부터 백엔드가 모든 패널티 타입에 대해 이것으로 온체인 RedemptionConfig.penaltyRateBps를 채운다(중복이던 early_redemption_penalty_bps 컬럼은 삭제했다) |
standard_redemption_days | INTEGER DEFAULT 7 |
redemption_notes | TEXT |
collateral_type | 🗑 삭제됨 — 마이그레이션 0037_drop_pool_collateral_type(v3-35). 담보는 표시 전용이고 collateral_description(자유 텍스트)과 collateral_ratio가 대신한다. 리스크 신호는 자동 계산되는 Risk Tier다(v3-25) |
collateral_ratio | NUMERIC (표시 전용. 100%를 넘을 수 있다) |
yield_frequency | TEXT DEFAULT 'MONTHLY' |
yield_trigger | 🗑 삭제됨 — 마이그레이션 0010_drop_yield_trigger(v3-20, 2026-06-12 적용). 수익은 청구 기반이고 수동뿐이며 AUTO는 제거했다. FE와 풀 생성·수정 Lambda가 더 이상 참조하지 않는다. ⚠️ 아직 올리지 않았다면 풀 Lambda를 다시 올릴 것 |
custom_interval_value | INTEGER. 마이그레이션 0011_custom_yield_interval(v3-20, 2026-06-12 적용). CUSTOM 수익 주기 값이고(CHECK > 0) yield_frequency = 'CUSTOM'일 때 custom_interval_unit과 함께 쓴다. 없거나 잘못되면 백엔드가 MONTHLY로 폴백한다 |
custom_interval_unit | TEXT. 마이그레이션 0011(v3-20). CHECK ∈ days / weeks / months |
allow_rollover | BOOLEAN DEFAULT false. ⚠️ MVP에서는 재투자를 어떤 화면에서도 제공하지 않는다(2026-08-27, 아직 반영 안 됨, v3-151). 컬럼은 유지하고, 경로를 닫는 것은 false 기본값이다 |
min_reinvest_amount | NUMERIC DEFAULT 50. ⚠️ 위와 같다. MVP에서 제공하지 않고(2026-08-27, v3-151) 컬럼은 유지한다 |
next_yield_due | TIMESTAMPTZ. 추정 다음 분배일이고 자동으로 계산한다(v3-20의 Estimated → Funded. 수동 "예정" 단계 없음). 앵커 + interval(yield_frequency)이고 앵커 = 마지막 DISTRIBUTED 행의 period_end ?? 그 행의 distributed_at(유도된 기간보다 앞선 행) ?? subscription_end_date ?? start_date다(v3-145: 풀이 아직 모집 중일 때 쿠폰을 빚져서는 안 되므로 격자가 모집 마감에서 시작하고, start_date는 마감일이 없는 풀에만 남는다). 정산 시각을 앵커로 삼으면 뒤의 모든 지급일이 그 정산이 늦은 만큼을 물려받았고, 기간제 풀에서는 그 기간이 아직 미지급인 채로 마지막 지급일이 만기를 넘길 수 있었다. (a) 분배가 성공할 때마다 바로(yield-distributions.post.create), (b) 매일 도는 pools.scheduler.yield-due sweep에서 다시 계산한다. UTC 월 산술에 월말 클램프가 붙는다(1월 31일 + 1개월 = 2월 28일이나 29일). 지난 날짜는 앞으로 굴리지 않는다(FE가 D+n 연체로 렌더한다). DRAFT에서는 NULL이고, MATURED 풀도 날짜가 자기 만기를 넘어가면 NULL이다. ⚠️ 예전에는 CLOSED와 MATURED를 통째로 비웠는데, 그러면 미지급 기간이 모든 표면에서 한꺼번에 사라졌다(due 큐는 next_yield_due IS NOT NULL로 뽑고, 어드민 목록은 yield_overdue로 거르며, 대시보드에는 연체 지표가 없다). 그러면서도 yield-distributions.post.create는 두 상태 모두에 대해 분배를 계속 받았다. 모집이 끝난 풀도 쿠폰을 빚지고 만기 온 풀도 마지막 기간을 빚진다. 이제 일정은 resolveNextYieldDue의 만기 상한으로 끝나고, 마지막 정산이 만기 너머로 앵커를 옮기면 스스로 종료된다.🔴 상환 계획이 있는 만기 풀은 주기를 아예 벗어난다(v3-147). 다음 날짜가 앵커 이후 가장 이른 redemption_epochs.funding_date이므로 쿠폰과 그 회차의 상환이 같은 날에 떨어지고, 늦은 지급은 다음 것을 밀어내는 대신 늦은 것으로 보인다. 상한이 먼저 돈다. 기간이 아직 돌고 있을 때 도래한 기간은 그 자리에 남는다. 그것을 계획에 넘기면 앞으로 밀려 연체가 아니게 되기 때문이다. 체인이 실제로 받은 날짜를 가진 회차만 센다. funding_date가 NULL이면 체인이 그것을 유도하고 있다는 뜻이고, 유도된 날짜는 약속이 될 수 없다 |
yield_overdue | BOOLEAN DEFAULT false. next_yield_due < now()일 때 매일 도는 sweep이 설정하고, 분배가 성공할 때마다 false로 되돌린다 |
start_date | DATE |
end_date | DATE. 🔴 모집 마감이 아니라 만기다. resolveMaturityAt가 아직 온체인 maturity_date가 없는 풀에 대해 이것을 두 번째 후보 기간 종료로 읽으므로, 이 날짜를 앞당기면 풀의 기간이 앞당겨지고 수익 상한도 함께 의무를 떨군다. 모집 마감은 아래 컬럼이다(0204 / v3-141) |
subscription_end_date | DATE. 모집이 닫히는 시점이고 입금을 받는 마지막 날이다(0204). lifecycle sweeper의 ACTIVE → CLOSED 패스는 이 컬럼만 본다. NULL은 예정된 마감이 없다는 뜻이고 0204 이전에 만든 모든 풀과 모집이 기간 끝까지 열려 있는 모든 풀이 그렇다. 그런 풀은 손으로 닫거나 그냥 만기가 온다. 이 날짜와 만기 사이에 수익 기간이 최소 하나 남게 하는 상한은 제약이 아니라 생성 위저드가 강제한다. 풀 페이지의 인라인 편집기가 요청마다 컬럼 하나씩을 쓰므로, 이것과 end_date에 걸치는 CHECK은 정당한 두 단계 편집의 앞 절반을 거부하게 된다. 모든 풀 형태에서 묻지만, 형태가 정하는 것은 질문이 아니라 규칙이다. 만기가 있는 풀은 반드시 설정해야 하고 상한을 지켜야 하며, OPEN_ENDED 풀은 설정해도 되고 지킬 상한이 없다(축은 redemption_type이 아니라 maturity_model이다) |
pool_implementation | TEXT. 이 풀의 프록시가 고정된 PlatformPool 구현체이고, 배포 시 factory.poolImplementationFixed()에서 읽는다(0205 / v3-142). 🔴 풀의 생애 동안 불변이다. 풀은 대상이 바이트코드에 박힌 Clones 프록시이고 팩토리의 poolImplementation은 immutable이라, 설정이 똑같은 풀 둘이 다르게 동작할 수 있고 구현체에 없는 기능은 절대 추가할 수 없다. 주소를 손으로 비교하지 말고 canReopenOffering(@aset/types)으로 읽을 것. 규칙이 "할 수 없는" 구현체의 닫힌 목록이라, 앞으로의 재배포에는 코드 변경이 필요 없고 기능이 조용히 회수될 수도 없다. 🔴 2026-08-31(W-M)부터 상태가 셋이다. 0x…는 세대이고, 'UNKNOWN'은 배포는 됐는데 읽기가 실패한 풀이며 pool_generation_unreadable을 올린다. NULL은 배포되지 않은 풀(또는 0205보다 앞선 풀)이다. 'UNKNOWN'이 생긴 이유는 예전에는 NULL이 가운데 경우까지 담았기 때문이다. 이 읽기는 설계상 치명적이지 않으므로 실패가 조용히 도착했고, 실제로 그랬다. getter 이름이 바뀌었는데 옛 이름이 0을 반환하는 대신 revert했고, 그 뒤 배포된 모든 풀이 아무것도 기록하지 않았다 |
pool_factory | TEXT. 이 풀을 만든 팩토리이고, 배포 시점에 FACTORY_ADDRESS_{chainId}가 갖고 있던 값이다(0205). 위 컬럼과 중복이 아니다. 위쪽은 풀이 무엇을 할 수 있는지를 말하고 이쪽은 백엔드가 실제로 어느 팩토리를 가리키고 있었는지를 말한다. 팩토리를 다시 배포한 뒤 env 교체와 API 재배포를 조용히 놓칠 수 있고, 그러면 새 풀이 계속 옛 구현체에서 나오는데 아무것도 이상해 보이지 않는다. 그것을 처음 드러낼 것이 이 컬럼이다 |
display_after_close | BOOLEAN DEFAULT true |
published_at | TIMESTAMPTZ |
tvl | NUMERIC DEFAULT 0 |
nav_per_token | NUMERIC(18,6) NOT NULL. 소수 6자리 고정소수점이고 온체인 NAV_PRECISION = 1e6이다 |
has_pending_nav_update | BOOLEAN DEFAULT false |
total_yield_distributed | NUMERIC DEFAULT 0 (0095에서 폐기. 어떤 핸들러나 트리거도 쓴 적이 없다. platform_stats.total_yield는 이제 yield_distributions에서 유도한다) |
investor_count | INTEGER DEFAULT 0 (폐기. 낡았고 유지된 적이 없다. GET /pools와 platform_stats는 portfolio_positions에서 실시간으로 센다) |
last_nav_update | TIMESTAMPTZ |
avg_asset_size | NUMERIC |
avg_tenor | TEXT |
historical_default_rate | NUMERIC |
npl_threshold_days | INTEGER NOT NULL DEFAULT 90 (이 DPD를 넘긴 대출은 부실이다. npl_ratio 임계값. 0073/v3-72) |
write_off_policy | TEXT (어드민 전용. 이 DPD를 넘기면 상각한다. 투자자에게는 보이지 않는다. 0073/v3-72) |
npl_threshold_days | INTEGER (펀드의 NPL 정의. 대출이 부실이 되는 DPD이고 기본 90이다) |
write_off_policy | TEXT (펀드의 재량 상각 정책. 예: "180 DPD에 상각") |
tagline | TEXT (0071. 풀 페이지의 짧은 히어로 요약이고 description과는 다르다) |
additional_disclosures | JSONB (0071. 풀별 리스크 공시 [{title, body}]이고 플랫폼 공통 문구 아래에 덧붙는다) |
equity_buffer_rule | TEXT, nullable (0077). R6 first-loss 계층을 서술하는 산문이고 투자자에게는 **"Manager first-loss commitment"**로 렌더된다. 0118부터 계층 자체가 구조화됐으므로(buffer_rate_bps/buffer_direction/buffer_basis) 이것은 그 계층의 정의가 아니라 서술이다. equity_buffer_prose_requires_layer(0121)가 buffer_rate_bps > 0이 아니면 이 텍스트를 거부한다. 모든 풀이 요율 0으로 도는 동안에는 어드민 생성 위저드에 노출하지 않는다 |
pool_address | TEXT |
lp_token_address | TEXT |
deploy_status | TEXT DEFAULT 'NOT_DEPLOYED' |
deploy_error | TEXT |
deploy_tx_hash | TEXT |
operating_currency | TEXT |
fx_rate | NUMERIC |
supported_chains | INTEGER[] DEFAULT '{8453}' |
primary_chain_id | INTEGER DEFAULT 8453 |
external_provider | TEXT. 풀 수준의 펀드 데이터 프로바이더 덮어쓰기 |
external_fund_id | TEXT |
custody_mode | TEXT NOT NULL DEFAULT 'PLATFORM', CHECK IN ('PLATFORM','MIRROR'). 0199. MIRROR는 포지션이 플랫폼 밖에 있고 우리가 파트너 리포트로 미러링한다는 뜻이다. 자본이 들고 나는 경로가 없다(입금, 재투자, 상환 요청, 수익 청구가 전부 거부된다). 읽기 모델은 다른 풀과 똑같다. ⚠️ is_showcase와 다르다. 그쪽은 출시 전 미리보기라는 뜻이고 풀을 Overview만 있는 "Coming soon" 페이지로 접어 Fund Data 탭을 숨긴다 |
external_entity_type | TEXT NOT NULL DEFAULT 'FUND', CHECK IN ('FUND','MASTER'). 0194. external_fund_id가 프로바이더 쪽의 어떤 엔티티를 가리키는지다. FUND는 /api/guest/fund/{id}, MASTER는 /api/guest/master/{id}다. MASTER로 바꾸면 보고된 값이 덮는 범위가 달라지므로(마스터는 이 풀이 직접 지분을 갖지 않은 것까지 포함해 모든 활성 하위 펀드를 합산한다) 기본값이 아니라 의도적인 설정 결정이다 |
net_yield_fee_config | JSONB (0067, bps는 0075). {platform_yield_take_bps, spc_mgmt_bps, pool_mgmt_bps, perf_fee_bps, perf_hurdle_bps}이고 platform_yield_take = gross×bps/10000, spc·pool mgmt = tvl×bps/10000×days/365다(spc는 treasury로, pool은 fund_fee_wallet으로). 성과 보수는 미뤘다. NULL이면 net = gross다 |
fund_wallet | TEXT. 파트너 잔여분 릴리스 대상이다(컨트랙트의 fundWallet) |
treasury_wallet | TEXT. settleYield의 treasury 수수료 대상이다 |
fund_fee_wallet | TEXT (0067). 풀 관리 수수료 대상이고(컨트랙트의 fundFeeWallet) fund_wallet과 다르다 |
is_showcase | BOOLEAN DEFAULT false |
maturity_model | maturity_model DEFAULT 'FIXED_TERM' |
jurisdiction_whitelist | TEXT[] DEFAULT '{}'. ISO 3166-1 alpha-3다(v3-60. 온체인 jurisdictionWhitelist와 맞추려고 0070에서 kyc_jurisdiction_whitelist에서 이름을 바꿨다) |
enforce_jurisdiction | BOOLEAN DEFAULT false (0064). 온체인 enforceJurisdiction을 미러링하고 true면 화이트리스트로 제한한다 |
tranche_group_id | UUID (v3-14. NULL이면 단독 풀이다) |
tranche_role | tranche_role enum (v3-14) |
partner_id | TEXT |
collateral_description | TEXT (v3-07) |
is_hidden | BOOLEAN NOT NULL DEFAULT false. 0123. 기본 어드민 목록에서만 풀을 숨긴다(?include_hidden=true로 포함). 기능 변화도, 온체인 구성요소도, 투자자 표면에 대한 영향도 없다. 홀더는 여전히 상환하러 풀에 도달할 수 있어야 한다. deleted_at이나 is_paused와 독립이고 어느 lifecycle 단계에서든 토글할 수 있다 |
is_emergency_frozen | BOOLEAN DEFAULT false (v3-05 hard freeze) |
freeze_started_at | TIMESTAMPTZ (0023/v3-28. 온체인 freezeStartedAt 미러이고 NULL이면 동결이 아니다). 입금 중단과 7일 자동 만료만 여기에 앵커된다. 다시 동결할 때마다 새로 찍히고 그것이 설계다 |
exit_window_anchor | TIMESTAMPTZ (0208). 72시간 출구 차단의 앵커이고 온체인 exitWindowAnchor를 미러링한다. 온전한 창 두 개가 지나야 전진하므로, 동결이 어떤 패턴으로 오든 출구가 144시간 중 72시간을 넘겨 막히는 일이 없다. 다시 동결해도 새로 찍지 않고 해제해도 지우지 않는다. NULL이면 미러링되지 않았다는 뜻이고 freeze_started_at으로 폴백한다 |
redemption_gating_bps | INTEGER (0068). basis point이고(1% = 100 bps) CHECK은 null 또는 0..10000이다. 0068에서 redemption_gating_pct(percent NUMERIC)에서 이름을 바꿨다. ⚠️ 폐기. MVP에 포함하지 않는다(2026-08-27, 아직 반영 안 됨, v3-150). 화면과 API에서 뺐고 컬럼은 유지한다 |
apy_disclosure | TEXT |
target_size_max | NUMERIC |
wind_down_proposed_at | TIMESTAMPTZ (v3-06) |
wind_down_executed_at | TIMESTAMPTZ (v3-06) |
impairment_proposed_at | TIMESTAMPTZ (v3-12) |
junior_depleted_at | TIMESTAMPTZ, nullable. 0116. 손실 워터폴이 junior 트랜치를 지운 시점이다(wipedOut). NULL이면 그런 적이 없다. tranche.post.writedown이 impairment_proposed_at과 같은 UPDATE에서 쓰고 자동으로 지우지 않는다. 운영자 문구가 junior 소진을 원인으로 지목할 수 있게 하려고 존재한다. impairment_proposed_at만으로는 안 된다. impairment는 무관한 이유로 손으로 제안되기도 하기 때문이다. 어드민 풀 상세의 "Impairment proposed. Deposits still open" 배너가 읽는다 |
buffer_rate_bps | INTEGER NOT NULL DEFAULT 0, CHECK 0–10000. 0118. total_deposited에 대한 bps로 표현한 파트너의 first-loss 약정이다(R6). 모든 풀에서 0이고 그것이 확인된 출시 조건이다. 0이면 버퍼 항이 사라지고 실현된 첫 1달러의 손실이 투자자에게 닿는다. ⚠️ 0122 이후 제외 대상은 트랜치 그룹뿐이다(buffer_not_on_tranche_pools). 수동 NAV 경로가 그 밖의 모든 단독 풀에 적용한다 |
buffer_direction | TEXT NOT NULL DEFAULT 'FIRST_LOSS', CHECK IN (FIRST_LOSS, EXCESS). 0118. FIRST_LOSS는 파트너가 상한까지 흡수한다는 뜻이고, EXCESS는 투자자가 상한까지 흡수하고 파트너는 그것을 넘는 것만 가져간다는 뜻이다. 요율은 같은데 책임이 뒤집힌다. 상한 5%에 손실 8%면 오직 이 컬럼에 따라 투자자에게 3%나 5%가 남는다. 0에서 대칭이 아니고(상한이 0인 EXCESS는 투자자가 아무것도 흡수하지 않는다는 뜻이다) 그래서 기본값은 0일 때 현재 동작을 보존하는 쪽이다 |
buffer_basis | TEXT NOT NULL DEFAULT 'GROSS', CHECK IN (GROSS, NET). 0118. 파트너가 보고하는 cumulative_loss가 그들의 흡수 전인지(GROSS, 우리가 상한을 뺀다) 이미 반영된 값인지(NET, 상한을 0으로 강제한다)다. net 수치에서 또 빼면 R8의 이중 계산을 한 층 위에서 반복하는 것이다 |
fund_report_cadence_days | INTEGER, nullable, CHECK > 0. 0118. 계약으로 약정된 리포트 주기다. NULL이면 기록에 없다는 뜻이고, NULL인 동안은 앞을 내다보는 투자자 문구를 숨긴다. 관측된 주기는 약속이 아니다. dev에서는 7개월에 리포트 7건이 불규칙한 간격으로 왔다. external_fund_id가 필요하다(cadence_requires_external_mapping, 0122). 파트너의 의무이고 피드가 없는 풀은 리포트를 받지 않는다 |
apy_basis | TEXT NOT NULL DEFAULT 'GROSS_DEPOSIT', CHECK IN (GROSS_DEPOSIT, DEPLOYED). 0118. apy_rate가 투자자 입금 전액에 대한 요율인지 실제로 파트너에게 보낸 부분에 대한 요율인지다. DEPLOYED에서는 남겨 둔 몫이 아무것도 벌지 않으므로 표시된 요율이 홀더가 실현하는 것보다 크다. 리저브가 사실상 0인 동안은 무기력하다 |
lp_total_supply | NUMERIC, nullable. 0117. 온체인 LP totalSupply()의 인덱서 미러이고 R9의 분모다. ⚠️ NULL은 미러링되지 않았다는 뜻이지 공급량 0이 아니다. 산식이 아직 이것을 읽지 않는다 |
max_investment | NUMERIC |
allows_us_persons | BOOLEAN DEFAULT false |
redemption_type | TEXT DEFAULT 'ON_DEMAND' CHECK (FIXED_MATURITY/ON_DEMAND). 0106이 LIQUIDITY_WINDOWS를 제거했다. 배선된 적이 없다(배포가 늘 빈 창 배열을 보내서 모든 요청이 NotInLiquidityWindow로 revert됐다. 상환이 영원히 막혔다). 그걸 쓰던 Base Sepolia QA 풀 4개는 라벨을 바꾸지 않고 폐기했다. 그 컨트랙트는 막힌 채로 남고 고칠 수 없다(redemptionConfig는 initialize 전용이고 풀은 업그레이드 불가다). CHECK이 소프트 삭제된 행을 예외로 두어 이력이 남는다 |
epoch_duration_days | NUMERIC NOT NULL DEFAULT 0. 0021/v3-26. 0이면 즉시, 0보다 크면 회차다(N일마다 정산). 온체인 epochDurationDays와 맞추려고 0070에서 redemption_epoch_days에서 이름을 바꿨다 |
nav_deviation_cap_bps | NUMERIC. 0021/v3-32. 회차 이상치 자동 보류(NAV 이탈 상한)이고 nullable이다 |
nav_staleness_seconds | NUMERIC. 0021/v3-32. 회차 이상치 자동 보류(NAV 신선도)이고 nullable이다 |
epoch_schedule_type | TEXT CHECK (MONTHLY/QUARTERLY), nullable. 0104/v3-93 회차 일정이다. 어드민이 펀딩일을 입력하지 않았을 때 C2의 열린 실패 기본값이 전진하는 기간이고, epoch_duration_days와 공존한다(그쪽은 즉시 대 회차 스위치와 기간 자체를 유지한다) |
funding_anchor_date | DATE, nullable. 0104/v3-93. 주기 경계를 역산하는 앵커다(cutoff = fundingDate − recall_lead_days, windowOpen = cutoff − request_window_days). ⚠️ 라이브 컬럼 주석은 여전히 대체된 v3-91 모델(boundary(n)=anchor + n*duration)을 서술한다. 0104의 설명 참조 |
recall_lead_days | INTEGER CHECK (NULL OR ≥ 0), nullable. 0104/v3-93. 요청 마감에서 펀딩 도착까지의 리드 타임이고 옛 통지 기간과 정산 지연을 흡수한다. executeEpoch가 cutoff + recall_lead_days로 게이팅된다 |
request_window_days | INTEGER CHECK (NULL OR ≥ 0), nullable. 0104/v3-93 모델 B의 요청 창 길이다. 무게를 진다. 요청은 오직 그 안에서만 받는다(v3-93 참조) |
epoch_cycle_mode | TEXT NOT NULL DEFAULT 'SEMI_AUTO' CHECK (SEMI_AUTO/AUTO). 0104/v3-93이고 0105가 오프체인 리마인더 게이트로 좁혔다. 🔴 폐기됐고 어떤 런타임 경로도 읽지 않는다. 0192/v3-140 (2026-08-18 적용, 주석만). 원래 의미는 이랬다. SEMI_AUTO는 요청 창이 열리기 전에 "다음 펀딩일을 확정하라"는 알림을 받는 쪽이고(그 뒤에는 setEpochFundingDate가 revert한다), AUTO는 안 받는 쪽이다. 온체인 동작은 한 번도 바꾸지 않았고(일정은 어느 쪽이든 C2 열린 실패다), AUTO는 아무도 날짜를 정하지 않은 주기를 지켜보던 유일한 것을 대체자를 세우지 않고 껐다. 그래서 풀이 체인의 직전 + epoch_duration_days 유도로 떨어졌고 정산이 그것을 스토리지에 굳혔다. 이제 리마인더는 모든 회차 풀에 대해 상태를 보고 발동한다. ⚠️ 삭제가 아니라 유지다. NOT NULL 기본값 아래 모든 행이 값을 갖고 있고, "이제 신경 쓰지 않는다"를 표현하려고 컬럼을 지우면 옛 해석 아래 만들어진 풀들의 역사를 다시 쓰게 된다. 은퇴는 그 자체로 별도 작업이다. 정직한 상태는 존재하고, 채워져 있고, 읽히지 않는다는 것이다. 🔴 다시 읽기 시작하지 말 것. "이 풀에 대한 알림은 그만"은 알림 환경설정이지 일정 컬럼이 아니다 |
next_funding_date | TIMESTAMPTZ, nullable. 0104/v3-93. 이번 주기의 확정된 펀딩·청구일이고 주기마다 어드민이 입력한다. 운영상 바뀌는 값이라 위의 불변 일정 손잡이들과 의도적으로 분리했다. ⚠️ 여기에 값이 있다고 해서 주기가 확정됐다는 뜻은 아니다. 아래 출처 컬럼들을 볼 것 |
next_funding_date_confirmed_at | TIMESTAMPTZ, nullable. 0111/v3-107 (적용됨). 온체인 setEpochFundingDate tx가 확정된 뒤에만 설정하고, 정산이 주기를 전진시킬 때 비운다. 그래서 출처가 주기마다 다시 무장한다. 이 컬럼이(아래 것과 함께) 있어야 투자자에게 보이는 확정 / 예정 배지에 답할 수 있다. 체인은 답할 수 없다. 정산이 열린 실패 날짜를 같은 온체인 슬롯에 이벤트 없이 실체화하기 때문이다(v3-105) |
next_funding_date_set_by | UUID FK → admin_users, nullable. 0111/v3-107 (적용됨). 누가 확정했는지다. 체인이 아니라 확정 엔드포인트의 인증된 호출자에서 가져온다. EpochFundingDateSet에는 행위자가 없고 tx 발신자는 공유 ORACLE 키다. 위 타임스탬프와 짝이고, 표시하는 주기에 대해 둘 다 있을 때만 배지가 확정으로 읽는다 |
redemption_term_epochs | INTEGER CHECK (NULL OR > 0), nullable. 0189/v3-132 (2026-08-17 적용). 만기 후 상환 계획이 펀딩일 몇 개를 덮는지이고, 위저드가 개월로 묻는 경우에도 회차 수다(개월은 달력 기준에서만 딱 나눠떨어진다). NULL이면 열린 회차라는 뜻이고, 모든 요청형 회차 풀과 그래서 이전에 배포된 모든 풀이 그렇다. 🔴 NULL과 "기록된 회차 없음"은 다른 답이다. 소비자가 둘을 뭉개면 안 된다. 배포의 쓰기 목록, 리마인더, GET /pools/{id}/repayment-cycles가 전부 이것으로 풀에 계획이 있는지를 판단한다. 옛 문서의 스펙 이름은 redemption_window_epochs다 |
epoch_date_basis | TEXT CHECK (NULL OR CALENDAR/FIXED_DAYS), nullable. 0189/v3-132 (2026-08-17 적용). 계획이 지급 간격을 어떻게 잡는지다. 매월 고정된 날인지, 일정 주기만큼 떨어뜨리는지 |
epoch_roll_day | SMALLINT CHECK (NULL OR 1–28), nullable. 0189/v3-132 (2026-08-17 적용). CALENDAR에서 지급이 떨어지는 날짜다. 28에서 멈추는 이유는 2월이 거기서 멈추기 때문이고, 없는 29~31일을 메우는 모든 규칙은 파트너가 동의한 지급일을 옮긴다. 🔴 이것과 epoch_date_basis가 규칙이고, v3-136이 그 규칙이 만든 날짜로 무엇을 해도 되는지를 말한다. 사람에게 보여 주는 것은 되고, 체인에 쓰거나 일정을 정하게 두는 것은 절대 안 된다. 읽는 것은 허용되고 실제로 셋이 읽는다(위저드 미리보기, 확인 diff, 리마인더의 제안). 스케줄러가 이것으로 주기 날짜를 유도해 행동하면 이 설계가 없앤 조용한 폴백이 되살아난다. 컬럼 주석은 이렇다. "no runtime path may DERIVE A SCHEDULED DATE from these; a human-facing suggestion may read them." |
deleted_at | TIMESTAMPTZ (소프트 삭제) |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ DEFAULT now() |
해결됨 — 회차 일정 컬럼 6개와 거기서 남은 교훈
epoch_schedule_type · funding_anchor_date · recall_lead_days · request_window_days · epoch_cycle_mode · next_funding_date는 v3-93 엔진 작업보다 앞서 SQL 에디터로 곧장 프로덕션에 추가됐다(Supabase main, 단일 프로젝트라 dev와 prod가 같은 DB다). 한동안 그 컬럼들은 라이브 DB에만 존재했다. 저장소 마이그레이션도, supabase_migrations 행도, 그것을 읽는 코드도 없었다.
이제 양쪽 다 닫혔다. 0104_epoch_schedule_cols.sql이 컬럼을 백필하고(ADD COLUMN IF NOT EXISTS이므로 프로덕션에는 아무 일도 하지 않는다) schema.sql이 그것을 담으므로 새 환경이 올바르게 올라온다. 그리고 엔진이 읽는다. pools.post.create와 pools.patch.update가 받고 pools.scheduler.epoch-funding-date가 semi-auto 주기를 굴린다(v3-91 / v3-93 반영 완료).
남길 만한 교훈은 이 사건보다 오래간다. supabase_migrations는 무엇이 적용됐는지에 대한 믿을 만한 기록이 아니다. SQL 에디터로 돌린 것은 이력 행을 남기지 않고 스키마를 바꾼다. 이 컬럼 6개와 pools.reserve_balance(0088)가 둘 다 그랬다. 그래서 이 문서 머리의 (적용 대기) 표시는 검증된 상태가 아니라 의도의 표명이다. 그중 무엇이든 믿기 전에 information_schema와 대조하고, 손으로 적용할 때는 이력 행도 함께 쓸 것.
제약
nav_per_token_range: nav_per_token > 0 AND nav_per_token <= 1.0chk_start_before_end: end_date IS NULL OR start_date IS NULL OR start_date < end_datechk_subscription_end_after_start(0204): subscription_end_date IS NULL OR start_date IS NULL OR start_date < subscription_end_date. 위와 같은 모양이다.end_date와의 관계는 의도적으로 제약하지 않는다. 컬럼 설명 참조.
파생 풀 응답 필드 (v3-20, 컬럼이 아니다)
GET /pools와 GET /pools/{id}는 파생 필드 둘을 합쳐 준다(요청마다 yield_distributions에서 계산하고 idx_yield_distributions_pool_status로 묶어 조회한다).
last_distribution_date= 그 풀의DISTRIBUTED행 중MAX(distributed_at). "마지막으로 지급한" 표시다.schedule_anchor= 같은 행의period_end(없으면 그 행의distributed_at, 그다음subscription_end_date, 그다음start_date).next_yield_due의 앵커다. 위 항목과 나눈 이유는 둘이 다른 질문에 답하기 때문이다. 분배가 언제 나갔는가와, 그것이 어느 기간을 갚았는가. 일정을 굴려도 되는 것은 뒤의 것뿐이다.next_yield_funded(boolean) =PENDING이나PROCESSING상태의yield_distributions행이 있다는 뜻이고 Funded 신호다(FE가 날짜를 진하게 대 흐린~추정으로 렌더한다). ⚠️ 한계: 지금의 단발 분배 플로우에서는 이 창이 몇 초뿐이다. 파트너의depositYield가settleYield와 분리되면 의미가 생긴다.
✅ v3.0 차원 컬럼 — 배포됨
위의 v3.0 차원 컬럼들(external_provider부터 redemption_type까지)은 DB에 라이브로 올라가 있다. 원래 설계 초안 대비 타입 정정: partner_id는 TEXT다(UUID FK가 아니다), tranche_role은 enum이다(TEXT CHECK가 아니다), maturity_model 값은 FIXED_TERM / OPEN_ENDED다(REVOLVING이 아니다). kyc_level_required와 requires_institutional은 0063에서 삭제했다(풀이 더 이상 KYC/KYB 레벨을 구분하지 않는다). nav_data_source와 tranche_structure는 구현 전에 v3-15 / v3-14에서 제거했다.
폐기된 필드 (v3.0)
| 필드 | 상태 |
|---|---|
pool_type | 삭제됨(마이그레이션 0019). AS_POOL/FUND_POOL 바이너리를 제거했고 차원(fund_id, fund_wallet 등)에서 유도한다. enum 타입도 삭제했다. |
lp_issuance_model | 삭제됨(마이그레이션 0050). v3.0에서는 늘 PLATFORM_ISSUED다(v3-02). lp_issuance_model enum 타입도 삭제했다. |
escrow_addressreceipt_address | 삭제됨(마이그레이션 0017). v3.0에서 Escrow와 Receipt NFT를 제거했다(v3-11/v3-03). |
investment_blocked | 삭제됨(마이그레이션 0018). is_paused가 대신한다(08 v3-29). "Fully Subscribed"는 tvl >= capacity로 유도한다. |
early_redemption_penalty_bps | 삭제됨(마이그레이션 0069). penalty_rate_bps와 중복이었고, 이제 그것이 모든 패널티 타입에서 온체인 RedemptionConfig.penaltyRateBps를 채우는 유일한 조기 상환 패널티 필드다. |
escrow_model, yield_distribution_model, receipt_token_address | 배포된 DB에 존재한 적이 없다(설계 초안 필드일 뿐이다). |
폐기된 컬럼은 과거 풀을 위해 남겨 둔다. 새 풀은 v3.0 차원만 쓴다.
🏷️ pool_categories
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
name | TEXT NOT NULL UNIQUE |
created_at | TIMESTAMPTZ DEFAULT now() |
🌐 pool_chain_deployments
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools (CASCADE) |
chain_id | INTEGER NOT NULL |
pool_contract_address | TEXT |
lp_token_address | TEXT |
operator_wallet | TEXT |
reserve_balance | NUMERIC DEFAULT 0 |
total_deposited | NUMERIC DEFAULT 0 |
is_active | BOOLEAN DEFAULT true |
deployed_at | TIMESTAMPTZ DEFAULT now() |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(pool_id, chain_id)
⚠️ 라이터가 없고 행이 0개다. 배포는
pools에 바로 쓰고(pool_address,lp_token_address,chain_id,deploy_tx_hash) 살아 있는 풀 중supported_chains에 항목이 둘 이상인 것은 없다. 0088, 0117, 0119에 적어 뒀고,deployed_block이 실수로 여기에 잠깐 추가된 뒤 0129에 다시 적었다. 체인별 원장은 쓸 수 있으려면 먼저 만들고 채워야 한다. 아무도 쓰지 않는 테이블의 컬럼은 앞서 나간 것처럼 보일 뿐 실제로 앞서 나간 게 아니다.
📈 pool_tvl_history
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools (CASCADE) |
tvl | NUMERIC NOT NULL |
recorded_at | TIMESTAMPTZ DEFAULT now() |
recorded_date | DATE (마이그레이션 0027) |
IDX: pool_id, recorded_at DESC · UNIQUE (pool_id, recorded_date)
매일 도는 TVL 스냅샷 스케줄러(pools.scheduler.tvl-snapshot.ts, CDK 24시간 주기)가 쓰고 유일한 라이터다. recorded_date(recorded_at의 UTC 달력 날짜)가 중복 제거 키다. sweep이 보이는 풀마다 하루에 한 행을 (pool_id, recorded_date)로 upsert하므로, at-least-once 재실행이 같은 날 행을 중복시키지 않고 덮어쓴다. 마이그레이션 0027 이전에는 이 테이블에 라이터가 없어서(GET 리더와 seed뿐이었다) 투자자 Performance 탭의 TVL 차트와 Inflow-Outflow가 비어 보였다.
🧩 pool_asset_compositions
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools (CASCADE) |
label | TEXT NOT NULL |
percentage | NUMERIC NOT NULL |
제약
CHK: percentage >= 0 AND percentage <= 100
📄 pool_documents
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools (CASCADE) |
name | TEXT NOT NULL |
type | document_type NOT NULL |
size | TEXT |
storage_url | TEXT |
docusign_view_url | TEXT |
created_at | TIMESTAMPTZ DEFAULT now() |
📦 underlying_assets
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools (CASCADE) |
category | TEXT |
external_asset_id | TEXT |
borrower_name | TEXT |
principal_value | NUMERIC |
maturity_date | TIMESTAMPTZ |
status | asset_status DEFAULT 'ACTIVE' |
last_synced_at | TIMESTAMPTZ |
💰 입금 (테이블 1개)
🧾 money_events
원장(0132). Append-only다. 모든 프로젝션 값은 이것을 다시 재생해 계산할 수 있어야 한다.
| 컬럼 | 타입 |
|---|---|
id | BIGSERIAL PK |
chain_id | chain_id NOT NULL |
tx_hash | TEXT NOT NULL |
log_index | INTEGER NOT NULL |
block_number | BIGINT NOT NULL |
occurred_at | TIMESTAMPTZ NOT NULL (블록 타임스탬프) |
kind | money_event_kind NOT NULL |
pool_id | FK → pools |
user_id | FK → users (nullable. 풀 수준 사실에는 홀더가 없다) |
asset | TEXT NOT NULL |
amount | NUMERIC NOT NULL |
payload | JSONB NOT NULL DEFAULT '{}' |
origin | TEXT NOT NULL CHECK IN ('CHAIN','REPORT','SEED'). 0198, 기본값 없음. CHAIN은 컨트랙트 로그이고(chain_id/tx_hash/log_index로 키잉), REPORT는 리포트 브리지가 파트너 관측에서 유도한 것이며(origin_ref로 키잉, 부분 유니크 인덱스), SEED는 마이그레이션이나 손으로 넣은 행이다. 🔴 fold.ts는 이것을 읽지 않는다. 미러 풀의 프로젝션은 다른 어떤 풀의 것과도 구분되면 안 되기 때문이다. 감사와 표시, 리스크는 읽어도 된다 |
origin_ref | TEXT. REPORT면 report_events.id다. SEED면 마이그레이션 번호이고, db/migrations/ 밖에서 심은 19개 행은 NULL이다. CHAIN이면 NULL이다 |
ingested_at | TIMESTAMPTZ NOT NULL DEFAULT now() |
UNIQUE(chain_id, tx_hash, log_index) · INDEX(pool_id, chain_id, block_number, log_index) · INDEX(user_id, occurred_at DESC) WHERE user_id IS NOT NULL · INDEX(pool_id, kind, occurred_at DESC)
🔴 재생은
(chain_id, block_number, log_index)순서로 한다. 절대id로 하지 않는다.id는 insert 순서다. API 빠른 경로는 특정 트랜잭션을 채굴되는 대로 받아들이고 인덱서는 나머지를 몇 분 뒤에 훑으므로, 두 순서는 예외적으로가 아니라 일상적으로 어긋난다.occurred_at도 동점을 못 깬다. 블록 타임스탬프라서 한 블록 안에서는 전부 같다.
왜 테이블 하나인가. 이벤트 타입마다 테이블을 두면 세 가지가 깨진다. 단일 멱등 키 (
UNIQUE (chain_id, tx_hash, log_index). 서로 다른 관례 네 개를 대체했다), 한 트랜잭션 안의 순서(deposit()의log_index3번DEPOSITED와 4번RELEASED_TO_PARTNER가 원자적 분할이다), 그리고 정합. "이 풀의 모든 움직임"이 쿼리 하나여야 하기 때문이다.
규모. 거래소가 아니라 사모펀드 플랫폼이다. 이벤트는 투자자 행동과 풀 주기를 따라간다. 풀 100개 × 투자자 500명 × 연 15회 정도의 행동에 NAV와 정산을 더하면 연 150만 행 정도(약 1GB)이고 10년이면 1,500만 행이다. Postgres 테이블 하나로는 작고, 이 규모에서 파티셔닝할 일은 아니다.
payload는 raw 로그가 아니라 디코딩된 인자를 담는다. topic과 data를 다시 저장하면 디코딩이 이미 만들어 낸 것을 보관하려고 테이블을 두 배로 만드는 셈이다.
📐 money_positions · money_pool_state · money_tvl_daily
프로젝션(0133).
money_events에서만 유도한다.
money_positions — PK (pool_id, user_id): lp_balance, principal, accrued_yield, yield_debt(0177), yield_claimed, invested_at, source(0178), rebuilt_from_event_id.
money_pool_state — PK pool_id: 컨트랙트 자신의 카운터이고 눈으로 대조할 수 있게 이름을 맞췄다. total_deposited, reserve_balance, unclaimed_yield, redemption_committed, total_epoch_top_up, lp_total_supply, settled_unclaimed_lp, nav_per_token, acc_yield_per_share. (held_fund_releases는 0183이 hold-back을 없앨 때까지 여기 있었다.)
money_tvl_daily — PK (pool_id, as_of): 하루치 상태라서 다시 돌려도 중복이 아니라 덮어쓴다. 나중에 덧댄 가드가 아니라 구조상 멱등이다.
모든 컬럼이 파생이다. 재생으로 다시 만들 수 없는 값은 아래의 워크플로 테이블에 속한다. 프로젝션이 유도 불가능한 것을 담는 순간 "재생과 프로젝션을 대조한다"가 드리프트 검사이기를 그치고 서로 다른 둘을 비교하는 일이 된다.
rebuilt_from_event_id가 마지막으로 접어 넣은 원장 행을 기록하므로, 재생이 처음부터 다시 돌지 않고 이어서 돌며 낡은 프로젝션이 의심이 아니라 눈에 보인다.
이를 받치는 불변식은 주석이 아니라
money_pool_state의 CHECK 제약이다. 풀이 보유한 것보다 많이 약속하게 만드는 쓰기는 거기서 실패한다. 나중에 잔액 부족으로 revert되는 청구로 드러나지 않는다.
🗂️ money_deposit_workflow · money_redemption_workflow · money_distribution_workflow
체인이 담지 않는 사실들(0134).
"체인이 원장이다"는 실제로 움직인 돈에 대해 성립한다. 리스크 동의, 운영자의 거부와 그 사유, 펀딩 기한, 실패한 트랜잭션에 대해서는 아무 말도 하지 않는다. 실패한 트랜잭션은 이벤트를 내지 않으므로 어떤 재생도 그것을 만들어 낼 수 없다.
| 테이블 | 담는 것 |
|---|---|
money_deposit_workflow | risk_ack, risk_acknowledged_at, failure_type, error_message |
money_redemption_workflow | admin_id, admin_decided_at, rejection_reason, funding_status, funding_shortfall, funding_deadline, pending_reserve_at, 실패 정보 |
money_distribution_workflow | gross_amount, fee_amount, pool_mgmt_fee_amount, fee_config_applied, distributed_by, period*, apy_rate, 실패 정보 |
money_events로 가는 FK는 nullable이고 그게 핵심이다. 거부된 요청, 실패한 입금, 제출되지 않은 의사에는 원장 이벤트가 없다. 바로 그래서 재구성할 수 없다. NOT NULL 참조는 이 테이블들이 존재 이유인 행들을 담지 못하게 만든다.
money_events.payload가 아니다. 원장이 어드민의 메모를 담는 순간 "모든 행이 체인 사실이다"가 거짓이 되고 재생은 드리프트 검사이기를 그친다.
⚠️
fee_config_applied가 무게를 진다. 수수료 분할은 gross 빼기 net처럼 보이지만 컨트랙트는 수수료 요율을 아예 갖고 있지 않다(0067). Lambda가 오프체인에서 계산해 결과를 보내므로 이벤트는 net과 전송 두 건을 담고 요율은 절대 담지 않는다. 나중에 다시 계산하려면 그때의net_yield_fee_config가 필요한데 그 설정은 편집 가능하다. 스냅샷이 없으면 옛 분배를 오늘의 요율로만 다시 유도할 수 있다. 같은 이름을 쓴 다른 숫자다.
💰 deposits — 삭제됨 (0165)
없어졌다. 입금은 money_events의 Deposited 이벤트이고, 화면이 읽는 행은 money_deposit_list다. 원장에 money_deposit_workflow(어떤 체인 이벤트도 담지 않는 리스크 동의와 실패 상세)를 더해 유도한다.
재투자도 같은 목록에 있고 축만 다르다. Deposited.amount는 raw 스테이블코인이고 Reinvested.yieldAmount는 18자리로 정규화돼 있어서, 뷰가 레지스트리의 소수 자릿수가 아니라 디코더가 기록한 축으로 나눈다(0158).
리셋 이전 입금: 35건 중 17건을 원장으로 재생했고, 그것이 체인에 아직 디코딩 가능한 로그가 남아 있던 전부다. 나머지는 0174에서 legacy 스키마와 함께 갔다.
💼 portfolio_positions
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
user_id | FK → users.id |
pool_id | FK → pools.id |
tokens | NUMERIC NOT NULL |
effective_value | NUMERIC NOT NULL |
accrued_yield | NUMERIC DEFAULT 0 |
invested_at | TIMESTAMPTZ NOT NULL |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ DEFAULT now() |
source | TEXT. State C 출처이고 CHECK DEPOSIT/TRANSFER_IN/SECONDARY_PURCHASE/LEGACY_SEEDED (0013/0014) |
entry_price | NUMERIC. 0125: LP 토큰당 가중평균 취득원가(원가 기준 손익). NULL이면 원가를 모른다는 뜻이다 |
last_reconciled_at | TIMESTAMPTZ. 0013 State C의 마지막 온체인 정합 |
entry_tx_hash | TEXT. 0014 State C 취득 tx |
claimable_yield | NUMERIC NOT NULL DEFAULT 0. 0038에서 정합한 온체인 pendingYield(정산분+미정산분, 사람이 읽는 USD). v3-51 |
claimable_yield_synced_at | TIMESTAMPTZ. 0038의 마지막 수익 정합 동기화 시각이고 NULL이면 FE가 accrued_yield로 폴백한다 |
UNIQUE(user_id, pool_id) · CHECK portfolio_positions_source_check (0014)
취득원가(0125).
entry_price는 포지션별 손익을 계산할 수 있는 유일한 필드다.effective_value는tokens × nav_per_token이고 포지션이 바뀔 때마다 다시 쓰이므로 언제나 현재 평가액이다. 이제 모든 자본 유입 경로가 가중평균을 유지한다. 입금과 재투자는금액 / 민팅된 토큰으로 잡고(체인이 실제로 부과한 가격이다. 하락이 큐에 걸려 있는 동안에는nav_per_token이 아니라 공지된 NAV다. v3-111), 전송 수령분은 풀 NAV로 마킹한다. 부분 상환은 건드리지 않고 전량 이탈은 행을 지운다. 원가를 모르면 NULL로 남아 손익 없음으로 드러나지 원가 0의 이익으로 나타나지 않는다. 문서에 있던nav_at_investment는 존재한 적이 없고 추가할 계획도 없다.State C (docs 21-holder-verification): 입금 경로 둘 다 새 포지션에
source='DEPOSIT'을 붙인다. 인덱서 라이터와, 0126부터는process_deposit_atomic이다(insert 시에만. 추가 매수는 출처를 바꾸지 않는다). 인덱서의 LP 홀더 간Transfer라이터는 포지션을 그 지갑의 **절대 온체인balanceOf()**로 맞춘다(증분이 아니다). 수령자는 upsert하고(새 행이면source='TRANSFER_IN'과entry_price,entry_tx_hash), 발신자는 동기화한다. 절대 잔액으로 쓰면 reorg 재처리가 멱등이 된다(별도 원장이 필요 없다).
🔄 상환 (테이블 3개)
🔄 redemption_requests — 삭제됨 (0165)
없어졌다. 상환은 그 체인 이벤트들이다. RedemptionRequested, RedemptionFunded, RedemptionCompleted, RedemptionClaimed이고, money_redemption_list가 그것들로 행을 유도한다. 체인이 내리지 않는 결정(누가 승인했는지, 거부 사유, 펀딩 기한, 부족분)은 money_redemption_workflow를 조인해 가져온다.
상태는 어떤 이벤트가 존재하는지로 유도하므로, 상환이 COMPLETED인 이유는 원장이 그 완료를 담고 있기 때문이다. 그 유도 중 셋은 알아 둘 만하다.
amount는 LP 토큰이고 절대 null이 아니다(0153). 잠깐lp × nav였던 적이 있는데, LP 라벨을 단 USD 수치였고 NAV가 1.0인 동안에는 맞아 보인다.- 회차 체결분은
RedemptionClaimed의 합이다. PARTIALLY_FILLED 대비 COMPLETED는 체결된 LP와 요청한 LP의 비교다(0155). 전에는RedemptionRolledOver가 같은 트랜잭션에서 몇 순간 전에 찍힌 도장을 강등시켜 그것을 판정했다. - REJECTED는 취소도 덮는다.
failure_type으로 나뉘고(INVESTOR_CANCELLED대 운영자의 분류), 그래서 철회가 거절과 합산되는 일이 없다(0162).
epoch_id는 원장이 다시 만들 수 없는 유일한 사실이다. RedemptionRequested가 그것을 담지 않고 acceptingEpochAt은 마감 전후로 다른 주기를 돌려준다. 그래서 요청 시점에 체인에서 읽어 워크플로 행에 저장한다(0154).
리셋 이전 상환: 11건 중 8건을 원장으로 재생했다. 재생하지 못한 셋 중 하나는 알아 둘 만하다. 0723 Epoch 1의 QUEUED 요청이 RedemptionRequested가 넓어지기 전에 발생해서 topic0이 현재 ABI와 맞지 않고, 그래서 살아 있는 파이프라인이 디코딩할 수 없다.
🔄 redemption_epochs
0021 / v3-26. 풀별 회차 정산 원장이고 (풀, 온체인 epochId)마다 행 하나다. EpochSettled 이벤트를 미러링한다. 그 회차의 모든 claimRedemption을 굴리는 체결률과 forward pricing 정산 NAV다. 인덱서와 Lambda만 쓴다(RLS 켬, 정책 없음).
🔴 0189와 0190이 함께 긋는 선: 계획은 pools에, 실제로 일어난 일은 redemption_epochs에
pools.redemption_term_epochs / epoch_date_basis / epoch_roll_day는 운영자가 생성 시점에 동의한 규칙이다. redemption_epochs.funding_date는 실제로 성사된 트랜잭션의 기록이다. 의도한 날짜를 이 테이블에 넣으면 설계 전체가 딛고 선 구분이 무너진다. 리마인더(v3-135)와 GET /pools/{id}/repayment-cycles는 funding_date IS NULL을 아무도 이 주기를 정하지 않았다로 읽는데, 그것이 누군가 정할 생각이었다도 뜻하게 되면 둘 다 정직하게 답할 수 없다.
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools.id (ON DELETE CASCADE) |
epoch_index | INTEGER NOT NULL. 온체인 epochId |
settlement_allowed_at | TIMESTAMPTZ. 0112 (2026-08-03 dev 적용), epoch_end_at에서 이름을 바꿨다. executeEpoch가 이 주기를 정산할 수 있는 가장 이른 시각이다. setEpochSettleAfter 지연을 포함한 펀딩·청구일이고 fundingDate + recall_lead_days 상한이 걸린다. PoolCommonLib.settlementAllowedAt(currentEpochEndsAt())을 미러링하고 인덱서만 쓴다. ⚠️ 요청 마감이 아니다. 요청은 그보다 recall_lead_days 앞선 cutoff에서 닫히고, 그 값은 체인에서 유도하며(readEpochSchedule) 의도적으로 저장하지 않는다. 체인으로 옮기지 않고 컬럼으로 남긴 이유는 investor_activity 뷰가 이것으로 next_settle_at을 채우는데 SQL 뷰가 RPC를 호출할 수 없기 때문이다 |
epoch_start_at | 0112에서 삭제됨 (적용됨). 직전 주기가 정산된 순간을 담았는데, 그건 이미 그 주기 자신의 행에 settled_at으로 있고 어디에도 독자가 없었다. 이름도 절대 해서는 안 되는 해석을 불렀다. 모델 B에서 요청 창은 cutoff − request_window_days에 열리고 직전 정산과는 무관하다 |
total_demand_lp | NUMERIC. 총 상환 수요(LP) |
fill_ratio | NUMERIC. 0..1 소수(온체인 1e18 / 1e18), CHECK 0..1 |
settled_nav | NUMERIC(18,6). 정산 시점의 forward pricing NAV이고 소수 6자리 고정소수점이다(0089) |
funding_date | TIMESTAMPTZ, nullable. 0190/v3-133 (2026-08-17 적용). 이 주기에 대해 체인이 실제로 받은 날짜다. EpochFundingDateSet에서만 쓰고 그 외 어디에서도 쓰지 않는다. 🔴 NULL은 "날짜가 없다"는 뜻이 아니다. PoolCommonLib.epochFundingDateAt은 모든 주기에 대해 답하고, 저장된 것이 없으면 직전 + epochDurationDays를 돌려준다. 진짜 날짜만큼 자신 있게 말해진 날짜이고, 정산이 그것을 스토리지에 굳힌다(RedemptionLib.sol:894). 이 컬럼이 정해진 날짜와 유도된 날짜를 구분할 유일한 방법이고, 그래서 관측된 사실만 담고 의도한 날짜는 절대 담지 않는다. ⚠️ 주기 2부터 센다. 주기 1은 setEpochSchedule이 쓰는데 그건 EpochScheduleSet을 내고 EpochFundingDateSet은 절대 내지 않는다(GovernanceLib.sol:154,171). 그래서 주기 1은 구조상 행이 없고, 1부터 세면 모든 계획을 영원히 한 주기씩 모자라게 보고하게 된다 |
funding_shortfall | NUMERIC. 0024/v3-26. 회차 부족분이고(리저브+톱업이 수요에 못 미친 양) EpochFundingNeeded의 인덱서 미러다. "Awaiting Funding $X" KPI를 받친다. ⚠️ 회차 풀에서는 구조적으로 NULL이다. 이것과 pending_reserve_at은 즉시 경로의 PENDING_RESERVE 상태에 속하고 회차 풀은 그 상태에 들어가지 않는다. 여기의 NULL은 인덱서 누락이 아니고 그 증거로 인용해서도 안 된다 |
settled_at | TIMESTAMPTZ |
chain_id | chain_id. 0021 인덱서 출처 |
tx_hash | TEXT. 0021 인덱서 출처 |
block_number | BIGINT. 0021 인덱서 출처 |
log_index | INTEGER. 0021 인덱서 출처 |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ |
IDX: UNIQUE(pool_id, epoch_index)(정산 시 upsert와 조회), log_index IS NOT NULL 조건의 (chain_id, tx_hash, log_index) 부분 유니크(인덱서 멱등성). RLS 켬, 정책 없음.
fill_ratio는 [0,1] 구간의 소수로 저장한다(예: 0.25 = 25%). 온체인 1e18fillRatio를 1e18로 나눈 값이다.
🧾 redemption_fills — 삭제됨 (0165)
없어졌고, 애초에 작동한 적이 없다. 이것을 채우던 라이터가 인덱서에서 한 번도 디스패치되지 않아서, 회차 정산의 체결별 기록처럼 보이면서 늘 비어 있었다.
그 행들은 RedemptionClaimed의 청구별 사본이었고 그 이벤트는 원장에 있다. 활동 피드의 REDEEM_FILL 분기가 그 이벤트를 바로 읽고(0156), money_redemption_list가 lp_filled와 누적 지급액을 위해 그것을 합산한다.
📊 yield_distributions
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools.id |
total_amount | NUMERIC NOT NULL. 실제 분배된 순액(수수료 차감 후) |
gross_amount | NUMERIC. v3.0에서 수수료 차감 전 입금 총액 |
fee_amount | NUMERIC DEFAULT 0. treasury로 뗀 수수료(플랫폼 몫 + spc 관리 + 성과) |
pool_mgmt_fee_amount | NUMERIC DEFAULT 0 (0067). fund_fee_wallet으로 뗀 풀 관리 수수료 |
distributed_by | FK → admin_users.id |
distributed_at | TIMESTAMPTZ DEFAULT now() |
distribution_type | TEXT DEFAULT 'MANUAL' |
apy_rate | NUMERIC |
period_start | TIMESTAMPTZ |
period_end | TIMESTAMPTZ |
status | yield_status DEFAULT 'PROCESSING' + CHECK status <> 'PENDING' (0113). 아래 설명 참조 |
period | TEXT |
investor_count | INTEGER DEFAULT 0 |
tx_hash | TEXT |
deposit_tx_hash | TEXT. 0042에서 FM이 클라이언트로 서명한 depositYield tx (PROCESSING → /distribute) |
chain_id | chain_id. 0013 인덱서 출처(레거시 어드민 행에서는 NULL) |
block_number | BIGINT. 0013 인덱서 출처 |
log_index | INTEGER. 0013 인덱서 출처 |
yield_per_share | NUMERIC. 0013 온체인 YieldDistributed의 yieldPerShare (1e18) |
fm_notified_at | TIMESTAMPTZ |
tx_submitted_at | TIMESTAMPTZ |
claimed_at | TIMESTAMPTZ |
distribution_started_at | TIMESTAMPTZ. 0086 (H1) /distribute의 동시성 선점 표시(온체인 distribute 앞에서 compare-and-set을 하므로 요청 둘이 이중 실행할 수 없다) |
failure_type | TEXT |
error_message | TEXT |
created_at | TIMESTAMPTZ DEFAULT now() |
updated_at | TIMESTAMPTZ |
IDX: pool_id · status · 복합 (pool_id, status). 마이그레이션
0011(v3-20)이고 풀 읽기 모델의 집계 조회를 받친다 · log_index 조건의 (chain_id, tx_hash, log_index) 부분 유니크.0013인덱서 멱등성 0013: 온체인 인덱서가YieldDistributed에 대해DISTRIBUTED행을 upsert하고(빈 테이블 → 영구 연체를 고친 변경이다)pools.next_yield_due를 다시 계산하며yield_overdue를 지운다. 어드민의POST /yield-distributions행과는tx_hash로 정합한다(제자리 갱신이고 중복을 만들지 않는다).
0113 — status는 절대 PENDING일 수 없다 (v3-104)
PENDING은 오직 레거시 서버키 생성 경로(deposit_tx_hash 없는 POST /yield-distributions)에서만 나왔다. 그 경로는 depositYield를 직접 서명하고 같은 호출 안에서 정산했다. 거기서 PENDING은 1초도 안 되는 insert 상태였으므로, 그 상태에 머물러 있는 행은 크래시 고아이고 무엇도 그것을 고칠 수 없었다. 인덱서는 tx_hash로 정합하는데 run-distribution은 settle 호출 뒤에야 그것을 쓴다. 그래서 그 기간은 영원히 미분배로 읽히고, 홀더가 지급받았는지를 DB로는 알 수 없다.
그 경로는 제거됐고(엔드포인트가 deposit_tx_hash 없이는 400을 낸다) 기록됐지만 미분배인 상태는 이제 **PROCESSING**이다. FM의 deposit_tx_hash를 갖고 있고 POST /yield-distributions/{id}/distribute라는 복구 경로가 있다. 컬럼 기본값도 PROCESSING으로 옮겼다. 그러지 않으면 status를 그냥 빠뜨린 INSERT가 고아를 조용히 되살리기 때문이다.
⚠️ yield_status enum 값은 삭제하지 않았다. 이 타입은 yield_distribution_investors.status와 공유하고 거기서는 PENDING이 정당하다(아직 청구되지 않은 배정). 금지는 이 테이블에만 걸린 CHECK 제약이다.
그래서 기록을 기다리는 기간은 행 자체가 아니다. GET /yield-distributions?include_due=true가 pools.next_yield_due에서 합성하는 due 행이다. 그것을 status='DUE' 원장 행으로 실체화하는 안은 기각됐다. 돈 없는 행을 이 테이블에 두면 platform_stats의 총 수익, 투자자 목록, 인덱서의 tx_hash 정합, /{id}/distribute 가드에서 전부 제외해야 하는데, 한 군데만 놓쳐도 홀더에게 지급된 액수가 부풀려진다.
📊 yield_distribution_investors
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
distribution_id | FK → yield_distributions (CASCADE) |
user_id | UUID |
investor_address | TEXT |
investor_name | TEXT |
share_percentage | NUMERIC |
amount | NUMERIC NOT NULL |
tx_hash | TEXT |
status | yield_status DEFAULT 'PENDING' |
claimed_at | TIMESTAMPTZ |
claim_tx_hash | TEXT |
claim_type | TEXT |
reinvest_lp_amount | NUMERIC |
created_at | TIMESTAMPTZ DEFAULT now() |
IDX: distribution_id, user_id
💎 yield_claims — 삭제됨 (0165)
없어졌다. 청구는 YieldClaimed 이벤트이고, 화면이 읽는 행은 yield_claim_list(0157)다.
라이터가 둘 있었다. claim_yield_atomic을 거치는 API 핸들러와 인덱서의 writeYieldClaimed인데 둘 다 함께 사라졌다. 그 저장 프로시저는 컨트랙트가 먼저 정리하는 경쟁 상태를 막으려고 행 잠금을 잡았다. 같은 수익에 대한 두 번째 청구는 온체인에서 revert되므로, 그 잠금은 남은 게 얼마인지에 대해 체인과 의견이 다를 수만 있었다.
옛 PENDING 상태는 재현하지 않는다. 뒤에 트랜잭션이 없는 청구를 기록하는 프런트엔드 폴백을 위해 있었는데, 온체인에 아무것도 없는 청구 행은 움직이지 않은 돈이 움직였다고 주장한다. 유도된 모든 행에는 트랜잭션이 있으므로 모든 행이 COMPLETED다.
🧭 indexer_cursor
| 컬럼 | 타입 |
|---|---|
chain_id | chain_id (PK) |
contract_address | TEXT (PK). 센티널 'ALL'(체인당 커서 하나) |
last_block | BIGINT NOT NULL. 완전히 처리된 마지막 블록 |
updated_at | TIMESTAMPTZ DEFAULT now() |
PK (chain_id, contract_address). 폴러는
last_block + 1에서 재개하고, 청크가 온전히 적용된 뒤에만 전진한다(부분 진행은 안전하다). 첫 실행은head − confirmations에서 시작하고 과거 이력은 별도 백필 경로가 덮는다.
💵 yield_funding_events
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools.id (CASCADE) |
chain_id | chain_id NOT NULL |
depositor | TEXT NOT NULL |
stablecoin | TEXT NOT NULL |
gross_amount | NUMERIC NOT NULL |
tx_hash | TEXT NOT NULL |
block_number | BIGINT NOT NULL |
log_index | INTEGER NOT NULL |
deposited_at | TIMESTAMPTZ NOT NULL |
created_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(chain_id, tx_hash, log_index) · IDX pool_id. 파트너나 treasury의
YieldDeposited이벤트를 남기는 기록이다(분배 전에 들어온 총액).
📊 NAV 이력과 거버넌스 (테이블 4개)
📊 nav_history
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools.id |
old_nav | NUMERIC(18,6) NOT NULL. 소수 6자리 고정소수점 |
new_nav | NUMERIC(18,6) NOT NULL. 소수 6자리 고정소수점 |
reserve_consumed | NUMERIC DEFAULT 0 (v3-16). R8 이후로는 항상 0이다. 리저브는 상환 유동성이지 손실 계층이 아니다. 컬럼은 이력용으로 남긴다 |
loss_amount | NUMERIC, nullable, CHECK ≥ 0. 0122. 이 NAV를 만든 미충당 손실이고(파트너 버퍼를 지난 뒤) 풀의 통화 기준이다. 보고된 총액이 아니다. 가격을 움직인 것은 투자자에게 닿은 부분이기 때문이다. NAV를 직접 입력했을 때는 NULL이고, 그것 자체가 사람이 가격을 매긴 행과 산식으로 유도한 행을 구분하는 방법이다 |
loss_as_of | DATE, nullable. 0122. 그 손실 수치의 기준일이다. 직접 입력한 NAV에서는 NULL이다 |
attested_off_chain | BOOLEAN NOT NULL DEFAULT false. 0201. 온체인 updateNAV 없이 NAV를 기록했을 때 true다. 풀의 자본이 우리 수탁 아래 있지 않기 때문이다(custody_mode = MIRROR). 증언된 하락은 큐에 넣지 않고 즉시 적용한다. 24시간 타임락은 미러 풀에는 없는 출구를 보호하는 장치이고, 대기 값을 담아 줄 컨트랙트도 없다 |
source | TEXT DEFAULT 'admin_override' (예: JOOB_DPD_AUTO, v3-13) |
proposed_by | FK → admin_users.id |
proposed_at | TIMESTAMPTZ DEFAULT now() |
effective_at | TIMESTAMPTZ NOT NULL |
status | nav_update_status DEFAULT 'PENDING' |
reason | TEXT |
제약
nav_positive: new_nav > 0nav_max: new_nav <= 1.0reserve_consumed >= 0
IDX: pool_id, status
🤖 nav_proposals (0094, A4)
사람을 기다리는 자동 NAV 제안이고 nav_history와 분리돼 있다(상태 모델 B). 계산된 NAV가 pools.nav_per_token과 유의미하게 다르면 제안 스케줄러가 OPEN 행을 쓴다. 승인이나 덮어쓰기는 그 값을 제안 경로로 흘려보내고 그 결과로 생긴 nav_history 행을 연결한다.
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | UUID PK | |
pool_id | UUID NOT NULL (FK 없음. 스냅샷을 따른다) | |
suggested_nav | NUMERIC NOT NULL | 스케줄러가 계산한 값 |
current_nav | NUMERIC NOT NULL | 제안 시점의 pools.nav_per_token |
source_snapshot_date | DATE | 제안을 유도한 스냅샷 |
status | TEXT DEFAULT 'OPEN' CHECK (OPEN | APPROVED | OVERRIDDEN | DISMISSED) | |
escalate_flag | BOOLEAN DEFAULT false | rawNav ≤ 0이면 IMPAIRED나 WIND_DOWN이고 자동 적용하지 않는다 |
reason | TEXT | |
created_at | TIMESTAMPTZ DEFAULT now() | |
resolved_by | UUID | 승인하거나 덮어쓴 admin_users.id |
resolved_at | TIMESTAMPTZ | |
applied_nav_history_id | UUID | 승인이나 덮어쓰기로 생긴 nav_history 행 |
IDX: pool_id, status · RLS 켬(서비스 키 전용)
📋 loan_writeoffs (v3-13)
Joob DPD API(와 앞으로의 파트너 동등물)에서 오는 대출별 자동 상각의 감사 기록이다. 행마다 Aset Lambda가 특정 기초 대출에 OJK 기준을 적용한 시점을 남긴다.
| 컬럼 | 타입 | 비고 |
|---|---|---|
id | UUID PK | |
pool_id | FK → pools.id | |
loan_id | TEXT NOT NULL | 파트너 내부의 대출·eNote ID(예: Joob loan_id 1345) |
dpd_bucket | TEXT CHECK | DPK(1-90) / KURANG_LANCAR(91-120) / DIRAGUKAN(121-180) / MACET(181 이상) |
dpd_days | INTEGER NOT NULL | 스냅샷 시점의 실제 DPD |
rate_applied | NUMERIC NOT NULL | OJK 요율: 0.05 / 0.15 / 0.50 / 1.00 |
principal_amount | NUMERIC NOT NULL | 대출 원금(풀의 operating_currency 기준) |
writeoff_amount | NUMERIC NOT NULL | principal_amount × rate_applied |
snapshot_date | DATE NOT NULL | DPD를 측정한 날 |
nav_history_id | FK → nav_history.id NULL | 연결된 NAV 갱신(합산된 상각이 NAV에 반영됐을 때) |
created_at | TIMESTAMPTZ DEFAULT now() |
제약
CHK: dpd_bucket IN ('DPK', 'KURANG_LANCAR', 'DIRAGUKAN', 'MACET')
IDX: pool_id, snapshot_date · pool_id, loan_id · nav_history_id
🏛️ pool_governance_changes (v3-18)
7일 타임락이 걸린 거버넌스 설정 변경이다(fund_wallet / reserve_bps / kyc_level).
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools.id |
change_type | TEXT CHECK ('FUND_WALLET' | 'RESERVE_WALLET' | 'RESERVE_BPS' | 'RESERVE_PERCENTAGE' | 'JURISDICTION_WHITELIST' | 'REDEMPTION_GATING' | 'TREASURY' | 'ENFORCE_JURISDICTION'). RESERVE_PERCENTAGE는 0213의 과도기 별칭이고 RESERVE_WALLET은 0217에서 들어왔다(컨트랙트의 propose/execute/cancel과 타임락이 먼저 있었다 — W-S) |
new_value | TEXT NOT NULL |
proposed_by | FK → admin_users.id |
proposed_at | TIMESTAMPTZ DEFAULT now() |
effective_at | TIMESTAMPTZ NOT NULL. 타임락 만료 |
executed_at | TIMESTAMPTZ |
cancelled_at | TIMESTAMPTZ |
tx_hash | TEXT |
IDX: pool_id · pending 조건의 부분 인덱스 (pool_id, change_type)
📣 풀 업데이트 (테이블 2개, WO-6)
📣 pool_updates
풀별 공지 피드다(어드민이나 FM이 쓴 것과 SYSTEM 자동 생성). 투자자에게 보이는 범위는 API 계층에서 홀더로 게이팅한다(공정 공시).
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_id | FK → pools.id (CASCADE) |
author_type | TEXT CHECK ('ADMIN' | 'FUND_MANAGER' | 'SYSTEM') |
author_admin_user_id | FK → admin_users.id. SYSTEM이면 NULL |
author_label | TEXT NOT NULL. 서버가 정하는 "Aset · {fund_name}" 또는 "Aset" |
title | TEXT NOT NULL |
body | TEXT NOT NULL. 마크다운 |
category | TEXT CHECK ('INFO' | 'IMPORTANT' | 'MATERIAL_EVENT') |
published_at | TIMESTAMPTZ DEFAULT now() |
edited_at | TIMESTAMPTZ |
deleted_at | TIMESTAMPTZ. 소프트 삭제 |
created_at | TIMESTAMPTZ DEFAULT now() |
IDX: deleted_at IS NULL 조건의 부분 인덱스 (pool_id, published_at DESC)
📜 pool_update_revisions
MATERIAL_EVENT 업데이트를 수정할 때마다 그 직전 버전을 스냅샷으로 남긴다(감사 기록).
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
pool_update_id | FK → pool_updates (CASCADE) |
title | TEXT NOT NULL |
body | TEXT NOT NULL |
category | TEXT NOT NULL |
edited_by | FK → admin_users.id |
created_at | TIMESTAMPTZ DEFAULT now() |
IDX: pool_update_id
🪪 KYC / 신원 (테이블 1개)
🪪 kyc_logs
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
user_id | FK → users (CASCADE) |
provider_id | TEXT |
status_result | TEXT NOT NULL. **GREEN**이나 RED(SumSub 심사 결과이고 apply-review가 쓴다), 또는 IN_REVIEW / RESET / DEACTIVATED(webhook lifecycle, kyc.post.webhook) 중 하나다. DB enum이 아닌 것은 의도적이다. SumSub이 우리가 본 적 없는 답을 낼 수 있고, 그것을 기록하는 일이 webhook을 실패시켜서는 안 된다. v3 이전 표기인 APPROVED / REJECTED / IN_PROGRESS는 0099에서 정규화해 없앴다. 어드민 KYC 드로어가 거부 사유와 시도 이력을 RED 매칭으로 해석하므로 두 번째 어휘가 시도 횟수를 조용히 줄여 세고 있었다 |
risk_score | NUMERIC |
reviewed_at | TIMESTAMPTZ DEFAULT now() |
reject_type | reject_type |
reject_reason | TEXT |
읽을 때 두 어휘는 서로 바꿔 쓸 수 없다.
GREEN/RED행만reject_type을 가질 수 있으므로 "이 홀더가 마지막에 어떻게 판정됐나"는 그 행들로 걸러야 한다(kyc/review-log.ts→readLatestReviewLog). 종류를 가리지 않고 최신 행을 가져오는 것은 실제 버그였다.RED/FINAL뒤에 쓰인 lifecycle 로그(예:applicantDeactivated)가reject_type이 NULL인 채로 맨 위에 앉아서isFinalRejected가 false를 반환했고 종착 차단이 풀렸다.
IDX: (user_id, reviewed_at DESC) · (reviewed_at DESC). 0183. 읽기 경로 둘 다 최신순이다. 홀더별(마지막 판정과 시도 이력)과 전체 홀더 대상(어드민 목록이 필터 없이 테이블 전체를 페이징하는데 전에는 쓸 만한 인덱스가 아예 없었다). 복합 인덱스가 단일 컬럼
user_id인덱스를 대체한다. 그 조회를 이미 덮기 때문이다.
🔔 알림 (테이블 7개)
마이그레이션 0110에서 재구축했다(v3-103).
notification_logs는 행 하나가 두 일을 하고 있었다. 인앱 수신함 항목과 이메일 전달 로그였고,notification_status·recipient_typeenum과 함께 삭제했다. 옛 행은 이관하지 않았다. 이제 테이블 셋이 각각 질문 하나에 답한다.
🔔 notification_events — 무슨 일이 있었나
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
event_key | TEXT NOT NULL |
idempotency_key | TEXT NOT NULL UNIQUE |
variables | JSONB NOT NULL DEFAULT '{}' |
subject_type | TEXT NOT NULL |
subject_id | UUID |
occurred_at | TIMESTAMPTZ DEFAULT now() |
사건 하나당 행 하나이고 수신자와 무관하다.
idempotency_key의 UNIQUE가 중복 제거 장치의 전부다. 열린 실패로 동작하던 별개 조회 넷(dedupByEntity,recentlyNotified, 손으로 짠 쿼리 둘)을 대체했다. 그것들은 조회 자체가 오류를 내면 중복을 통과시켰다. 반복 알림은 시간 창을 넘기는 대신 주기를 키에 넣는다(epoch_gated:<pool>:<epoch>:2026-07-31).
IDX: event_key + occurred_at DESC, subject_type + subject_id
🔔 notifications — 누구에게 알리나 (인앱 수신함)
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
event_id | UUID NOT NULL → notification_events |
recipient_kind | recipient_kind NOT NULL |
recipient_id | UUID NOT NULL |
variables | JSONB (수신자별 덮어쓰기) |
in_app | JSONB NOT NULL |
category | TEXT NOT NULL |
read_at | TIMESTAMPTZ |
created_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(event_id, recipient_kind, recipient_id). 한 사람은 한 사건을 한 번 듣고, 그래서 일부만 실패한 fan-out을 안전하게 재시도할 수 있다.
상태 컬럼이 없는 것은 의도적이다. 수신함 항목은 있거나 없거나이고, 이메일이 나갔는지는 전달의 몫이다. 전에는 투자자가 읽을 수 있는 항목이
status = 'FAILED'를 달고 있었다. 그 상태가 이메일을 서술했기 때문이다.
recipient_id는 언제나 사람이다.INVESTOR는users.id,ADMIN은admin_users.id다(FM도 여기 포함된다). 옛 컬럼은 행에 따라 user_id이거나 풀 id이거나 펀드 id였고, 그래서 발송 워커에 80줄짜리 해석기가 필요했으며fund_id가 없는 풀의 FM 공지가 조용히 아무에게도 닿지 않았다.
in_app은 쓰는 시점에 렌더해 저장한다. 피드 쿼리가 카탈로그에 의존하지 않고, 문구를 고쳐도 누군가 이미 읽은 메시지를 다시 쓰지 않는다. 이메일은 반대다. 보내는 시점에 렌더하므로 문구 수정이 아직 큐에 있는 것에 반영되고, 홀더 천 명 fan-out이 같은 HTML 천 벌 대신 변수를 저장한다.
IDX: recipient_kind + recipient_id + created_at DESC, recipient_kind + recipient_id (read_at IS NULL 부분 인덱스)
📤 notification_deliveries — 어떻게 나갔나
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
notification_id | UUID NOT NULL → notifications |
channel | notification_channel NOT NULL |
destination | TEXT NOT NULL |
status | delivery_status DEFAULT 'PENDING' |
attempts | INTEGER NOT NULL DEFAULT 0 |
next_attempt_at | TIMESTAMPTZ NOT NULL DEFAULT now() |
provider_message_id | TEXT |
failure_type | notification_failure_type |
error_message | TEXT |
sent_at | TIMESTAMPTZ |
created_at | TIMESTAMPTZ DEFAULT now() |
UNIQUE(notification_id, channel). 나중에 푸시나 웹훅을 더하는 것은 payload를 한 벌 더 복제하는 게 아니라 행을 하나 더 두는 일이다. 옛 단일
channel컬럼 때문에WEBHOOKenum 값을 끝내 쓸 수 없었다.
next_attempt_at이 백오프 시계다(1/5/15/60/240분). 옛 워커에는 카운터뿐이었고 실패한 행을 즉시 풀어 줬으므로 9분 만에 시도 세 번이 타 버렸고, 짧은 SES 장애에 걸린 것들이 영구 실패로 남았다.
provider_message_id는 SES MessageId다. 반송이나 신고 웹훅은 몇 분 뒤에 그것만 들고 도착하므로, 이게 없으면 반송을 그것을 일으킨 발송에 귀속시킬 수 없다.
status=PENDING | SENT | BOUNCED | COMPLAINED | FAILED | SKIPPED.SENT는 SES가 접수했다는 뜻이지 전달됐다는 뜻이 아니다.SKIPPED는 의도적인 미발송이고(수신 거부나 차단 주소) 실패가 아니다. 그래서dashboard_alert_counts.failed_notifications는FAILED만 세고, 이것이 마이그레이션 0104의INVALID_RECIPIENT예외를 대체한다.
IDX: next_attempt_at (PENDING/FAILED 부분 인덱스), provider_message_id (NOT NULL 부분 인덱스), status + created_at DESC
🚫 email_suppressions
| 컬럼 | 타입 |
|---|---|
email | TEXT PK (소문자) |
reason | suppression_reason NOT NULL |
detail | TEXT |
suppressed_at | TIMESTAMPTZ DEFAULT now() |
SES가 영구 반송이나 신고로 보고한 주소다. 0110 이전에는 아무것도 이것을 채우지 않았다. enum 값과 SES 구성 세트는 둘 다 있었지만 그 세트에 이벤트 목적지가 없었고
SendEmailCommand가 그것을 지목한 적도 없어서, 피드백이 만들어지고 버려졌다. 차단된 주소는 critical 이벤트보다도 우선한다. 어차피 메일이 도착하지 않고, 계속 보내면 플랫폼의 모든 이메일이 공유하는 발신 평판이 상한다. 일시적 반송은 차단하지 않는다(메일함이 찼다고 투자자를 영원히 침묵시킬 수는 없다).
⚙️ notification_preferences
| 컬럼 | 타입 |
|---|---|
user_type | TEXT NOT NULL |
user_id | UUID NOT NULL |
category | TEXT NOT NULL |
email_enabled | BOOLEAN DEFAULT true |
PK(user_type, user_id, category). 행이 없으면 이메일 켬이다(opt-out 모델). 0110에서 컬럼은 그대로 두고 RLS를 켰다(다른 알림 테이블처럼 켜고 정책은 없다).
⚠️
user_id에는 FK가 없다.user_type에 따라users나admin_users를 가리키므로 제약으로 표현할 수 없다. 둘이 일치하는지 강제하는 것이 없고(('ADMIN', <투자자 uuid>)를 쓰는 버그가 있으면 아무것과도 맞지 않은 채 남아 있을 것이다)ON DELETE CASCADE도 없다. 지금은 무해하다. 어드민 계정은 소프트 삭제되고users에는 하드 삭제 경로가 없어서 고아가 생기지 않는다.notifications.recipient_id도 같은 이유로 같은 절충을 한다(피드 테이블 하나가 둘보다 낫다).
category는user_type두 값 모두에 대해 알림 문구 레지스트리의event_key를 담는다.ADMIN은 0101부터,INVESTOR는 0108부터다.decideChannels()가notification_preferences.category === notification_events.event_key를 그대로 게이트로 쓰고 매핑 계층은 없으며, 카탈로그가optional로 표시한 이벤트만 저장할 수 있다. critical 이벤트는 언제나 발송되고(D4) 강제 변환이 아니라 쓰기 시점에 거부한다. 받아들이는 두 집합은 손으로 나열한 것이 아니라 알림 카탈로그에서 유도한다(notifications/preferences/catalog.ts. 거의 같던 모듈 둘이 합쳐진 0110 이후로는app으로 거른 하나의 목록이다).
- ADMIN (0101) — 패널이 지어낸 분류 체계(
new_redemption,deposit_anomaly,kyc_submission등)로 쓰인 행 13개를 삭제했다. 게이트는 쓸 때TOGGLEABLE_ADMIN_EVENT_KEYS, 보낼 때decideChannels()다.- INVESTOR (0108) —
NAV_UPDATE와POOL_PERFORMANCE를 삭제했다. 게이트는 쓸 때TOGGLEABLE_INVESTOR_EVENT_KEYS, 보낼 때 같은decideChannels()다. 지금 토글 가능한 것은pool_lifecycle_active뿐이고 나머지 투자자 이벤트는 전부critical이다.두 분류 체계가 같은 방식으로 실패했고 그건 짚어 둘 만하다. 토글이 저장되고 스위치로 다시 읽혔지만 모든 이메일이 그대로 나갔다. 이 테이블 자신의 CRUD 핸들러 말고는 아무도 이것을 읽은 적이 없기 때문이다. 진짜
event_key로 키잉되지 않은 토글은 강제할 수 없고, 그 실패는 조용하다. 설정에만 존재하는 어휘를 다시 들이지 말 것. 0110부터 환경설정은 채널을 고르는 생산 시점에 적용된다. fan-out이 사람마다 행 하나를 쓰므로 수신 거부는 전달 행 자체가 없다는 뜻이고 실패 집계에 나타날 수 없다. 그것이SUPPRESSED(0100)를 은퇴시켰다. 그 값은 브로드캐스트 행이 발송 시점에 수신자를 정하던 시절에만 필요했다.
👣 pool_follows (0109)
| 컬럼 | 타입 |
|---|---|
user_id | UUID NOT NULL → users(id) ON DELETE CASCADE |
pool_id | UUID NOT NULL → pools(id) ON DELETE CASCADE |
created_at | TIMESTAMPTZ NOT NULL DEFAULT now() |
PK(user_id, pool_id). 행이 있으면 팔로우 중이고 언팔로우는 그것을 지우므로 다시 팔로우하는 것은 멱등 upsert다. 상태 컬럼은 없다. "팔로우했었다" 이력은 읽을 사람이 없고, 있으면 거기에도 보내고 싶어질 뿐이다.
pool_lifecycle_active뒤의 옵트인 목록이고pools.scheduler.lifecycle이UPCOMING → ACTIVE전이에서 큐에 넣는다. 옵트인이어야 하는 이유는 문구가 "알려 달라고 요청하셨습니다"라고 말하기 때문이다. 요청하지 않은 사람에게는 거짓이고, 브로드캐스트하면 제품 안내가 원치 않는 광고가 된다. 엔드포인트는GET/POST/DELETE /pool-follows다. 22-notifications 참조.
IDX:
pool_id(PK는user_id로 시작하지만 생산자는 "이 풀을 누가 팔로우하나"를 묻는다). RLS 켬, 정책 없음. 서비스 키 핸들러가 모든 읽기와 쓰기를 호출자의user_id로 범위 잡는다. ⚠️ 풀이 평소 쓰는 소프트 삭제에는 CASCADE가 발동하지 않으므로, 생산자는 자기가 순회 중인 풀로 필터링하고 목록 엔드포인트는 살아 있는 풀까지 조인한다.
🔔 epoch_window_subscriptions (0115)
| 컬럼 | 타입 |
|---|---|
user_id | UUID NOT NULL → users(id) ON DELETE CASCADE |
pool_id | UUID NOT NULL → pools(id) ON DELETE CASCADE |
created_at | TIMESTAMPTZ NOT NULL DEFAULT now() |
PK(user_id, pool_id). 행이 있으면 구독 중이고 해지는 그것을 지우므로 다시 구독하는 것은 멱등 upsert다. 모양은 위
pool_follows와 의도적으로 똑같다.
epoch_request_window_open뒤의 옵트인 목록이고epochWindowSubscribers대상이 해석하며pools.scheduler.epoch-window-open(매시간)이 큐에 넣는다.pool_follows를 재사용하지 않고 두 번째 테이블을 둔 이유: 그쪽은 입금이 열리는 것에 대한 동의이고 이쪽은 상환 요청 창이 열리는 것에 대한 동의다. 두 안내 다 "알려 달라고 요청하셨습니다"라고 말하므로, 목록을 공유하면 각 안내 수신자의 절반에게 그 문장이 거짓이 된다. 포지션을 갖고 있다는 것도 이 동의가 아니다. 회차 창은 주기적으로 열리므로portfolio_positions에서 수신자를 유도하면 모든 홀더에게 매 주기 메일이 가고 끌 방법이 없다. 엔드포인트는GET/POST/DELETE /window-subscriptions다.
POST는 회차 풀이 아닌 것(epoch_duration_days = 0)을 거부한다. 즉시 상환 풀에는 요청 창이 없으므로 안내가 발동할 수 없고 토글은 아무 데도 연결되지 않은 스위치가 된다.
IDX:
pool_id(pool_follows와 같은 생산자 접근 경로다). RLS 켬, 정책 없음. ⚠️ 소프트 삭제에 대한 주의도pool_follows와 같다.
⚡ 플랫폼 설정과 활동 (테이블 5개 + 뷰 1개)
⚙️ platform_config
| 컬럼 | 타입 |
|---|---|
key | TEXT PK |
value | TEXT NOT NULL |
updated_by | FK → admin_users.id |
updated_at | TIMESTAMPTZ DEFAULT now() |
⚙️ platform_settings (0039)
| 컬럼 | 타입 |
|---|---|
key | TEXT PK |
value | JSONB NOT NULL |
updated_at | TIMESTAMPTZ DEFAULT now() |
서버에 저장하는 어드민 플랫폼 설정이다(체인별 블록 탐색기 URL, 자동 새로고침 주기, 대액 입금 알림 임계값). 이전의 localStorage뿐이던 스텁을 대체한다. 설정 뭉치 전체가
key='platform_config'아래 있다. API는GET/PUT /admin/settings다(platform-settings.get/.put.update). 위의platform_config(TEXT KV이고 어드민이 귀속된다)와는 다르다. 이쪽은 Admin Settings UI를 위한 JSONB 뭉치 저장소다.
PUT은 인식하는 필드만으로 뭉치를 다시 만든다. 그래서 호출자는 유지하고 싶은 모든 필드를 보내야 하고, admin-web의 Platform Configuration 폼이 전부 함께 제출한다. 인식하는 키는
explorerUrls,refreshInterval(정수 5~3600초),largeDepositThreshold(양의 정수, 지우려면null)다.largeDepositThreshold에는 기본값이 없다. 값이 없는 동안에는large_deposit알림이 절대 발동하지 않는다(docs22-notifications). 마이그레이션은 없다. 기존 JSONB 뭉치 안의 새 키일 뿐이다.
📋 activity_events
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
event_type | TEXT NOT NULL |
description | TEXT NOT NULL |
status_badge | TEXT NOT NULL |
actor_id | UUID |
actor_name | TEXT (0081 스냅샷) |
actor_role | TEXT (0081 스냅샷) |
actor_type | TEXT ('admin'|'system', 0081) |
entity_label | TEXT (0081) |
before_state | JSONB (0081) |
after_state | JSONB (0081) |
reason | TEXT (0081) |
outcome | TEXT NOT NULL DEFAULT 'success' (0081) |
metadata | JSONB |
related_entity_type | TEXT |
related_entity_id | UUID |
created_at | TIMESTAMPTZ DEFAULT now() |
IDX: created_at DESC, related_entity_type + related_entity_id 0081 (v3-86): actor_name과 actor_role을 쓰는 시점에 스냅샷으로 떠서 어드민 이름 변경이나 삭제에도 기록이 살아남는다. before_state에서 after_state로가 구조화된 변경 diff이고, reason은 고위험 행위에 필수이며, outcome은
'success'나'failure'다(실패한 시도도 감사된다). 0082 (v3-86): append-only다. BEFORE UPDATE/DELETE 트리거(activity_events_enforce_append_only)가 모든 role의 변경을 막고(BYPASSRLS도 트리거는 건너뛰지 않는다) DELETE는archive_expired_activity_events(p_cutoff)안에서만 허용된다(SECURITY DEFINER이고, 트리거가 존중하는 트랜잭션 로컬 플래그를 세운다). 🔴event_type은 enum이 아니라 TEXT다. 값의 집합은apps/infra/lib/shared/audit/activity.ts의ActivityEventTypeunion이므로, 값을 추가하는 것은 마이그레이션이 아니라 코드 변경이다.POOL_ARCHIVE와POOL_RESTORE는 대기 중이다(v3-110). 지금은 아카이브와 영구 삭제가POOL_DELETE를 공유하고(metadata.mode로만 구분된다) 복구는 전용 타입도 사유도 없이 일반POOL_UPDATE로만 남으므로 평범한 수정과 구분할 수 없다.
📋 activity_events_archive
콜드 아카이브다(0082, v3-86). 컬럼은 activity_events와 같고 archived_at TIMESTAMPTZ DEFAULT now()가 더 있다. 5년 보관 하한을 넘긴 행을 archive_expired_activity_events()가 여기로 옮긴다(원자적 INSERT+DELETE). 그래서 감사 기록이 뜨거운 테이블과 audit_feed 뷰 밖으로 옮겨질 뿐 파괴되지 않는다. RLS는 전면 거부다(service_role은 우회한다). 옛 하드 퍼지 스케줄러를 대체한다.
🪙 stablecoin_registry
| 컬럼 | 타입 |
|---|---|
id | UUID PK |
chain_id | INTEGER NOT NULL |
symbol | TEXT NOT NULL |
contract_address | TEXT NOT NULL |
decimals | INTEGER DEFAULT 6 |
is_active | BOOLEAN DEFAULT true |
UNIQUE(chain_id, symbol)
Enum 레퍼런스
배포된 DB 기준 enum 타입 27개다(Supabase pg_enum과 대조, 2026-08-06). redemption_status는 마이그레이션 0021에서 다시 만들었고(0021/v3-26 + v3-34) enum 수는 그대로다. collateral_type은 마이그레이션 0037에서 삭제했다(v3-35). 0110이 알림 쌍을 교체했다. notification_status와 recipient_type을 삭제하고 그 자리에 delivery_status, recipient_kind, suppression_reason을 추가했다(v3-103). 순증 1개다.
| Enum | 값 | 비고 |
|---|---|---|
admin_role | OPERATOR, ADMIN, SUPER_ADMIN, FUND_MANAGER | |
asset_status | ACTIVE, SETTLED, OVERDUE | |
chain_id | 8217, 8453, 11155111, 84532 | Kaia, Base, Sepolia, Base Sepolia |
currency | USDC, USDT, DAI | |
delivery_status | PENDING, SENT, BOUNCED, COMPLAINED, FAILED, SKIPPED | 0110. notification_deliveries의 채널별 전달 결과다. notification_status를 대체한다. SKIPPED는 수신 거부 경우이고(실패가 아니다) BOUNCED와 COMPLAINED는 이제 V2 자리표시자가 아니라 실제 상태다 |
deposit_status | PENDING, PROCESSING, COMPLETED, FAILED | REFUNDED는 삭제했다(0049). 입금이 원자적이라 환불 플로우가 없다 |
document_type | PPM, SUBSCRIPTION, AUDIT, RISK_DISCLOSURE | |
eligibility_mode | STATUS, MIN_TICKET | 0072. 풀 게이팅 모드(Status Gate / Ticket Gate) |
fund_status | ACTIVE, INACTIVE | |
investor_status | RETAIL, PROFESSIONAL | 0072. investor_tier(ACCREDITED/QP)를 대체한다. 미국 밖 Reg S 모델 |
kyc_level | INDIVIDUAL, INSTITUTION | SBT 속성이다(KYC 온보딩과 관할 해석). 풀은 더 이상 레벨로 게이팅하지 않는다(0063) |
kyc_status | NOT_STARTED, IN_REVIEW, APPROVED, REJECTED | |
lifecycle_status | DRAFT, UPCOMING, ACTIVE, IMPAIRED, WIND_DOWN, CLOSED, MATURED | v3.0에서 IMPAIRED(v3-12)와 WIND_DOWN(v3-06)이 추가되고 DISTRESSED가 제거됐다 |
maturity_model | FIXED_TERM, OPEN_ENDED | v3.0 |
nav_update_status | PENDING, APPLIED, CANCELLED | v3.0에서 CANCELLED 추가(타임락 중 어드민 취소) |
notification_channel | EMAIL, WEBHOOK | WEBHOOK은 0087에서 추가했다. ⚠️ 값은 있지만 나가는 웹훅 발신기는 없다. DB에 없는 것 참조 |
notification_failure_type | BOUNCED, SPAM_FILTERED, SERVER_ERROR, TIMEOUT, INVALID_RECIPIENT, RATE_LIMITED | |
penalty_type | YIELD_BASED, PRINCIPAL_BASED, NO_EARLY, FLAT_FEE | |
recipient_kind | INVESTOR, ADMIN | 0110. recipient_type을 대체하고, 그쪽은 FM도 담고 있었다. FM은 펀드로 범위가 잡힌 어드민 행이므로 세 번째 값은 종류가 아니라 범위를 서술하고 있었다 |
redemption_status | REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED | 0021: QUEUED와 PARTIALLY_FILLED 추가(회차, v3-26), FM_ACCEPTED 제거(v3-34). PENDING_RESERVE는 즉시 상환 풀용이다(v3-04) |
reject_type | RETRY, FINAL | |
sbt_status | NOT_MINTED, MINTED, FAILED | |
suppression_reason | HARD_BOUNCE, COMPLAINT, MANUAL | 0110. 주소가 email_suppressions에 오른 이유다 |
tranche_role | SENIOR, MEZZANINE, JUNIOR | v3-14 |
transfer_source | PLATFORM, FUND | |
user_role | GUEST, INVESTOR | |
yield_status | PENDING, PROCESSING, DISTRIBUTED, FAILED | 0113: PENDING은 yield_distribution_investors.status에서만 유효하고 yield_distributions는 CHECK으로 금지한다. 두 테이블이 타입을 공유하므로 enum 값은 남는다 |
DB에 없는 것
escrow_model, yield_distribution_model, nav_data_source, tranche_structure, risk_tier, pool_category. 설계 초안의 enum이고 만들어진 적이 없거나 배포 전에 제거됐다.
뷰
dashboard_alert_counts
🔴 아래 정의는 0113의 것이고, 0113은 더 이상 유효한 정의가 아니다(2026-08-27 정정).0175_distribution_joins_the_ledger가 이 뷰를 CREATE OR REPLACE로 다시 냈고 그 변경은 겉모습만 바꾼 게 아니다. 집계 8개 중 5개가 이제 원장이 대체한 테이블 대신 원장 뷰를 읽는다 (money_deposit_list, money_distribution_list, money_redemption_list, money_redemption_workflow). apps/infra/db/schema.sql의 스냅샷을 읽을 것. 거기에 0175의 본문과 failed_redemptions에 대한 0163·0171의 설명이 들어 있다. 아래 SQL은 실제로 도는 것이 아니라 0113이 도입한 모양으로 남겨 둔 것이다.
SELECT
COUNT(*) FROM notification_deliveries WHERE status = 'FAILED' AS failed_notifications, -- 0110: deliveries, not
-- inbox rows. BOUNCED/COMPLAINED are actioned by the
-- suppression list and SKIPPED was never attempted, so
-- only FAILED counts; this retires 0107's
-- INVALID_RECIPIENT exception.
COUNT(*) FROM users WHERE sbt_status = 'FAILED'
AND (sbt_mint_queued_at IS NULL OR sbt_mint_queued_at < now() - interval '10 minutes')
AS sbt_mint_failed, -- 0046: in-flight mints excluded
COUNT(*) FROM deposits WHERE status = 'FAILED' AS failed_deposits,
COUNT(*) FROM redemption_requests WHERE status = 'FAILED' AS failed_redemptions,
COUNT(*) FROM yield_distributions WHERE status = 'FAILED' AS failed_yield,
COUNT(*) FROM users WHERE kyc_status = 'IN_REVIEW' AS kyc_pending, -- 0046: NOT_STARTED excluded (nothing to review)
COUNT(*) FROM redemption_requests WHERE status = 'REQUESTED' AS pending_redemptions,
COUNT(*) FROM yield_distributions WHERE status = 'PROCESSING'
AND deposit_tx_hash IS NOT NULL -- 0114
AND created_at < now() - interval '24 hours' AS stalled_yield; -- 0113: renamed
-- from pending_yield, and was
-- status = 'PENDING', now impossible.
-- Counts a stalled distribution: the
-- FM's deposit landed but nobody ran
-- POST /{id}/distribute.0113 —
pending_yield→stalled_yield(이름 변경) (v3-104) 이 알림은PENDING분배를 세고 있었는데 그 상태를 테이블이 이제 금지한다. 영원히0으로 읽힐 것이고, 영원히 조용한 알림은 작동하는 안전망처럼 읽힌다. 대신 실제로 사람이 필요한 상태를 센다. 온체인 입금은 들어왔는데 분배된 적이 없는 경우다.컬럼을 다시 가리키게만 한 게 아니라 이름을 바꿨다. "pending"은 Yield 화면에서 아직 기록되지 않은 기간을 부르는 말인데(다른 큐다) 옛 이름이 읽는 사람을 엉뚱한 쪽으로 보냈기 때문이다.
CREATE OR REPLACE VIEW는 컬럼 이름을 바꿀 수 없어서 0113이 뷰를 지우고 다시 만들고, 모든 독자가 같은 변경에서 함께 움직인다.dashboard.get.stats.ts, admin-webshared/api/dashboard.ts(alerts.stalled_yield),routes/dashboard.tsx(ALERT.stalledYield→ "Stalled Distributions"),routes/fund-detail.tsx("Stalled Yield")다.⚠️ 리터럴
24가 네 군데에 적혀 있다.STALLED_YIELD_HOURS(lib/shared/yield/stalled.ts)가 백엔드 쪽 주인이고, admin-web이isStalledProcessing을 위해STALLED_PROCESSING_HOURS(shared/api/yield-distributions.ts)로 되풀이하며,schema.sql스냅샷이 그것을 말하고, 라이브 마이그레이션도 말한다. 넷을 함께 바꾸지 않으면 알림 배지, 그 드릴다운, FM 대시보드, Yield 화면이 서로 다른 숫자를 보고한다.✅ 다섯 번째는 아니다.
dashboard.get.stats.ts의countStalledYieldByPool은STALLED_YIELD_HOURS를 임포트한다. 되풀이하지 않는다. 펀드 범위 대시보드는 두 번째 독자이지 두 번째 사본이 아니다. 짚어 둘 만한 이유는 admin-web의 주석이 여전히 "셋이 일치해야 한다"고 말하며 사본이 아니라 독자를 세고 있기 때문이다.🔴 SQL에서 살아 있는 사본은
0113이 아니라0175다. 0113이 처음 썼고 0114가 좁혔으며 0175가 뷰 전체를 다시 내면서 그 간격을 그대로 옮겼다. 0113이나 0114의 텍스트를 놓고 고치면 대체된 마이그레이션을 편집하는 것이고 DB에는 절대 닿지 않는다.✅ 가드는
schema.sql을 통해 살아 있는 모양을 본다.lib/shared/yield/__tests__/stalled-threshold.test.ts가 하드코딩된 경로 둘을 읽는다.db/schema.sql과db/migrations/0114_stalled_yield_requires_deposit_tx.sql이다. 앞의 것은 0175 이후이고 현재이므로, 라이브 뷰에서 임계값이 흘러가면 여전히 실패한다. 0114 쪽 절반은 눈을 가리는 게 아니라 무해하다. 마이그레이션은 불변의 역사이므로 그 단언은 누군가 과거를 다시 쓸 때만 실패할 수 있다. 정리해도 되지만 구멍은 아니다.🔴 구멍은 한 층 위에 있다.
schema.sql이 최신인지는 아무도 확인하지 않는다. 가드의 권위 전체가 모든 마이그레이션이 스냅샷을 다시 만든다는 데 걸려 있는데 그 관례는 습관만으로 지켜진다. 임계값을 옮기고schema.sql을 그대로 두는 마이그레이션은 가드를 통과한다. 오늘은 둘이 맞아 있다. 같은 틈이 컬럼 7개를 마이그레이션에만 살아 있게 했다(이 문서 머리의 설명 참조).
0114 —
stalled_yield는deposit_tx_hash IS NOT NULL을 요구한다 0113은 PROCESSING이 확정된 입금을 함의한다고 봤다.yield_distribution_stalled알림은 그럴 수 없다. 그 문구가 수익이 "온체인에 입금됐다"고 말하고 tx 해시를 출력하므로 해시로 거른다. 좁히지 않으면 배지가 안내가 보내는 것의 상위 집합을 세고(배지 3, 안내 2) 드릴다운이 아무도 통지받지 않은 행을 나열한다. 해시 없는 PROCESSING 행은 어차피 거기서 조치할 수도 없다.POST /{id}/distribute가 그 해시를 온체인에서 검증하고 없으면 400을 내며, UI도 같은 필드로 Distribute 버튼을 게이팅한다.조건식만 바꾸므로(컬럼 이름과 타입이 같다)
CREATE OR REPLACE VIEW로 충분하고 뷰가 권한을 유지한다. 컬럼 이름을 바꾸느라 DROP해야 했던 0113과 다르다. 함께 움직이는 것:dashboard.get.stats.ts(countStalledYieldByPool), admin-webisStalledProcessing, 그리고 새lambda/yield.scheduler.stalled.ts.
platform_stats
total_investors는 현재 홀더의 고유 수다(portfolio_positions.tokens > 0). 낡은 pools.investor_count 컬럼의 합이 아니다(마이그레이션 0026).
total_yield는 **DISTRIBUTED 상태 yield_distributions**의 순액(수수료 차감 후) 합이다(마이그레이션 0095). 전에는 pools.total_yield_distributed를 합산했는데, investor_count와 마찬가지로 어떤 핸들러도 트리거도 RPC도 그것을 쓰지 않아서 어드민 대시보드 KPI가 $0에 고정돼 있었다.
범위(마이그레이션 0098): ACTIVE만이 아니라 소프트 삭제되지 않은 모든 풀이다. MATURED / CLOSED / IMPAIRED / WIND_DOWN 풀도 상환될 때까지 투자자 자본을 갖고 있으므로, 옛 ACTIVE 전용 필터는 풀이 ACTIVE를 벗어나는 순간 그 자본과 홀더를 어드민 개요에서 사라지게 했다. 소프트 삭제된 풀은 어디서나 제외하고 이는 pools.get.list와 같다.
SELECT
COALESCE(SUM(p.tvl), 0) AS total_tvl,
COALESCE((
SELECT SUM(yd.total_amount)
FROM yield_distributions yd
JOIN pools yp ON yp.id = yd.pool_id
WHERE yd.status = 'DISTRIBUTED' AND yp.deleted_at IS NULL
), 0) AS total_yield,
COALESCE((
SELECT COUNT(DISTINCT pp.user_id)
FROM portfolio_positions pp
JOIN pools ap ON ap.id = pp.pool_id
WHERE ap.deleted_at IS NULL AND pp.tokens > 0
), 0) AS total_investors
FROM pools p
WHERE p.deleted_at IS NULL;audit_feed
활동과 감사를 통합한 피드다(마이그레이션 0030, 결정 v3-41. 행위자와 기록 필드는 0080/0081에서 v3-86이 다시 범위를 잡았다). 스트림 둘을 읽을 때 합치고, activity_events 행 모양에 0080/0081 감사 컬럼을 더해 투영한다 (… actor_id, related_entity_type, related_entity_id, created_at, metadata, actor_type, actor_name, actor_role, entity_label, before_state, after_state, reason, outcome).
activity_events— API 경계에서 기록한 사람 어드민의 재량 행위다(actor_id는 JWT). freeze, pause, impair, wind-down, 발행, 거버넌스, 상환 결정이다. 0081 스냅샷 필드(actor_name, actor_role 등)를 담는다.- 경제 이벤트 로그 테이블(읽기만 하고 복사하지 않으므로 이중 쓰기가 없다):
deposits,redemption_requests(요청과 완료만. 어드민 결정은 이미 1번 스트림에 있다),yield_distributions,nav_history,kyc_logs.
id에는 출처별 접두사가 붙어서(deposit:, redreq:, redcomp:, yield:, nav:, kyc:) UNION 전체에서 피드 행이 유일하게 유지된다. **actor_type(0080)**이 행위 주체를 구분하므로 GET /activity-events가 actor_id를 올바른 테이블에 대조한다. 'admin'은 admin_users, 'investor'는 users(입금·상환 행이 이제 actor_id에 NULL이 아니라 투자자의 user_id를 담는다), 'system'은 해석하지 않는다. 스냅샷 필드(0081)는 1번 스트림에서만 채워지고 경제 스트림에서는 NULL이다. GET /activity-events가 이 뷰를 읽는다(스냅샷이 우선이고 라이브 조인이 폴백이다). security_invoker = true다.
CREATE VIEW audit_feed WITH (security_invoker = true) AS
SELECT ae.id::text AS id, ae.event_type, ... FROM activity_events ae
UNION ALL SELECT 'deposit:' || d.id::text, 'DEPOSIT', ... FROM deposits d
UNION ALL SELECT 'redreq:' || r.id::text, 'REDEMPTION_REQUEST', ... FROM redemption_requests r
UNION ALL SELECT 'redcomp:' || r.id::text, 'REDEMPTION_COMPLETED', ... FROM redemption_requests r WHERE r.completed_at IS NOT NULL
UNION ALL SELECT 'yield:' || y.id::text, 'YIELD_DISTRIBUTED', ... FROM yield_distributions y
UNION ALL SELECT 'nav:' || n.id::text, 'NAV_CHANGE', ... FROM nav_history n
UNION ALL SELECT 'kyc:' || k.id::text, 'KYC_' || k.status_result, ... FROM kyc_logs k;investor_activity
투자자 활동 타임라인을 통합한 뷰다(마이그레이션 0055, P-5. 0103에서 다섯 번째 출처가 추가됐다). 웹 앱이 따로 받아 와 클라이언트에서 합치던 출처들의 UNION이고 공통 컬럼 집합 하나로 투영한다. 각 행은 자기 출처의 uuid PK를 id로 유지한다(PK들이 서로 다른 uuid라 충돌하지 않는다). GET /investor-activity가 이 뷰를 user_id로 범위 잡아 읽는다. security_invoker = true다.
- INVEST ←
deposits(occurred_at은created_at) - REDEEM ←
redemption_requests(occurred_at은COALESCE(requested_at, created_at)이고 상환 전용 부가 필드를 담는다). 0103: 요청이COMPLETED이고redemption_fills행이 있으면 보류한다. 그때는 지급액이 그 체결들로 나열되므로 둘 다 보여 주면 같은 USDC를 두 번 세게 된다. 열린 것(QUEUED/PARTIALLY_FILLED),REJECTED/FAILED, 즉시 상환 풀, 0103 이전 요청은 영향이 없다 - REDEEM_FILL ←
redemption_fills(0103.occurred_at은settled_at,amount는payout_amount,status는'COMPLETED'고정이다. 체결은 이미 전송된 돈이다.lp_filled는 이 체결의filled_lp,epoch_id는 정산이 나온 회차다). 회차 상환의 정산된 체결마다 행 하나이므로, 여러 회차에 걸친 정산이 실제로 이뤄진 지급들로 나타난다 - YIELD ←
yield_distribution_investors에pool_id와 타임스탬프를 위해yield_distributions를 조인한다 (amount는yield_distribution_investors.amount,occurred_at은COALESCE(yd.distributed_at, ydi.created_at)) - CLAIM ←
yield_claims(occurred_at은COALESCE(completed_at, created_at).investor_address컬럼이 없어서 NULL이다)
컬럼: id (uuid), activity_type (text: INVEST|REDEEM|REDEEM_FILL|YIELD|CLAIM), occurred_at (timestamptz), amount (numeric), pool_id (uuid), user_id (uuid), investor_address (text), status (text), tx_hash (text)와 REDEEM 전용 nullable 부가 필드 payout_amount, penalty_amount, nav_at_request, transfer_source, epoch_id, funding_status. P-5 후속으로 on_chain_request_id, next_settle_at(요청이 속한 회차의 redemption_epochs.epoch_end_at), epoch_settled_at(redemption_epochs.settled_at), error_message, failure_type, rejection_reason을 더했다. REDEEM 행에서 채워지고(INVEST 행도 입금 실패에서 온 error_message/failure_type을 담는다) 그래서 투자자 활동 피드가 회차 Claim·Cancel·Settle 동작과 FAILED/REJECTED 상세를 렌더할 수 있다. 다른 타입에서는 NULL이다. 마이그레이션 0066이 REDEEM 행에 lp_filled(= redemption_requests.lp_filled, 부분 체결을 가로지르는 누적 정산 LP)를 덧붙여 앱이 회차 Claim 동작을 미리 게이팅할 수 있게 했다(요청이 체결률 0%로 정산되거나 이월돼 청구할 것이 없을 수 있다). 다른 타입에서는 NULL이다. enum은 text로 캐스팅한다.
CREATE VIEW investor_activity WITH (security_invoker = true) AS
SELECT d.id, 'INVEST', d.created_at, d.amount, d.pool_id, d.user_id, d.investor_address, d.status::text, d.tx_hash, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::integer, NULL::text, NULL::text, NULL::timestamptz, NULL::timestamptz, d.error_message, d.failure_type, NULL::text, NULL::numeric FROM deposits d
UNION ALL SELECT r.id, 'REDEEM', COALESCE(r.requested_at, r.created_at), r.amount, r.pool_id, r.user_id, r.investor_address, r.status::text, r.tx_hash, r.payout_amount, r.penalty_amount, r.nav_at_request, r.transfer_source::text, r.epoch_id, r.funding_status, r.on_chain_request_id, e.epoch_end_at, e.settled_at, r.error_message, r.failure_type, r.rejection_reason, r.lp_filled FROM redemption_requests r LEFT JOIN redemption_epochs e ON e.pool_id = r.pool_id AND e.epoch_index = r.epoch_id WHERE NOT (r.status = 'COMPLETED' AND EXISTS (SELECT 1 FROM redemption_fills f WHERE f.request_id = r.id))
UNION ALL SELECT f.id, 'REDEEM_FILL', f.settled_at, f.payout_amount, f.pool_id, f.user_id, f.investor_address, 'COMPLETED'::text, f.tx_hash, f.payout_amount, NULL, NULL, NULL, f.epoch_id, NULL, NULL, NULL, f.settled_at, NULL, NULL, NULL, f.filled_lp FROM redemption_fills f
UNION ALL SELECT ydi.id, 'YIELD', COALESCE(yd.distributed_at, ydi.created_at), ydi.amount, yd.pool_id, ydi.user_id, ydi.investor_address, ydi.status::text, ydi.tx_hash, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM yield_distribution_investors ydi JOIN yield_distributions yd ON yd.id = ydi.distribution_id
UNION ALL SELECT yc.id, 'CLAIM', COALESCE(yc.completed_at, yc.created_at), yc.amount, yc.pool_id, yc.user_id, NULL::text, yc.status, yc.tx_hash, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM yield_claims yc;데이터 타입 레퍼런스
| 타입 | 설명 |
|---|---|
PK | 기본 키. 유일한 ID이고 중복이 없다 |
FK | 외래 키. 다른 테이블의 PK를 가리킨다 |
UUID | 유일 ID. 무작위 128비트이고 전역에서 유일하다 |
ENUM | 고정된 선택지. 미리 정한 값만 쓴다 |
TEXT | 가변 길이 문자열 |
NUMERIC | 정확한 수. 오차가 없어 금액에 쓴다 |
BOOLEAN | 참 / 거짓 플래그 |
TIMESTAMPTZ | 시간대가 붙은 날짜와 시각 |
INTEGER | 정수. 소수가 없다 |
SMALLINT | 작은 정수. 2바이트 |
JSONB | JSON 바이너리. 구조화된 데이터이고 질의할 수 있다 |
INET | IP 주소. IPv4 또는 IPv6 |
DATE | 날짜만. 시각 성분이 없다 |
관계도
users<-1:N->walletsusers<-1:N->kyc_logsusers<-1:N->depositsusers<-1:N->portfolio_positionsusers<-1:N->redemption_requestsusers<-1:N->yield_claimsadmin_users<-1:N->admin_user_permissionsadmin_users<-1:N->admin_sessionsadmin_users<-1:N->fund_invites(invited_by)funds<-1:N->fund_membersfunds<-1:N->fund_invitesfunds<-1:N->pools(pools.fund_id를 통해. 모든 풀은 정확히 하나의 펀드를 갖는다. v3-26)pools<-FK->funds(fund_id)pools<-FK->pool_categories(category → name)pools<-1:N->pool_chain_deploymentspools<-1:N->pool_tvl_historypools<-1:N->pool_asset_compositionspools<-1:N->pool_documentspools<-1:N->underlying_assetspools<-1:N->nav_historypools<-1:N->depositspools<-1:N->portfolio_positionspools<-1:N->redemption_requestspools<-1:N->yield_distributions<-1:N->yield_distribution_investorspools<-1:N->yield_claimspools<-1:N->loan_writeoffs(v3-13)pools<-1:N->pool_governance_changes(v3-18)pools<-1:N->pool_updates<-1:N->pool_update_revisions(WO-6)pool_updates/pool_update_revisions<-N:1->admin_users(author_admin_user_id / edited_by)loan_writeoffs<-N:1->nav_history(nav_history_id)portfolio_positions<-1:N->redemption_requests(position_id)portfolio_positions<-1:N->yield_claims(position_id)yield_distributions<-N:1->admin_users(distributed_by)redemption_requests<-N:1->admin_users(admin_id)nav_history/pool_governance_changes<-N:1->admin_users(proposed_by)admin_users<-1:N->activity_events(actor_id)auth_nonces— 독립(SIWE 인증)external_pool_dpd_buckets— 독립(소유 키는 pools.id이고 FK는 없다. 0073/v3-72)notification_events<-1:N->notifications<-1:N->notification_deliveries(0110.notifications의 수신자는 다형이다.recipient_kind+recipient_id이고 FK가 없다)email_suppressions— 독립(유저가 아니라 주소로 키잉한다)notification_preferences— 독립(다형 유저)platform_config<-N:1->admin_users(updated_by)stablecoin_registry— 독립external_pool_data_snapshots/external_pool_data_cache— 독립(소유 키는 pools.id이고 FK는 없다)
v3.0 마이그레이션 요약
배포됨. v3.0은 바이너리 pool_type을 24개 차원의 설정 모델로 대체한다(Pool Models 참조).
pools의 새 컬럼 (라이브)
fund_wallet, treasury_wallet, is_showcase, maturity_model, jurisdiction_whitelist, tranche_group_id(v3-14), tranche_role(v3-14), partner_id, collateral_description, is_emergency_frozen, freeze_started_at(0023/v3-28), redemption_gating_bps(0068), apy_disclosure, target_size_max, wind_down_proposed_at / wind_down_executed_at(v3-06), impairment_proposed_at(v3-12), max_investment, allows_us_persons, redemption_type, net_yield_fee_config, epoch_duration_days(0021/v3-26), nav_deviation_cap_bps / nav_staleness_seconds(0021/v3-32).
새 테이블 (라이브)
loan_writeoffs(v3-13 DPD 상각 감사), pool_governance_changes(v3-18 타임락 거버넌스), external_pool_data_snapshots / external_pool_data_cache(외부 풀 데이터 동기화), pool_updates / pool_update_revisions(WO-6 공지 피드), redemption_epochs(0021/v3-26 회차 정산 원장), platform_settings(0039 어드민 플랫폼 설정 JSONB 저장소), nav_proposals(0094 A4 NAV 제안·승인), redemption_fills(0103 체결별 회차 원장), pool_follows(0109 투자자 풀 오픈 옵트인), epoch_window_subscriptions(0115 투자자 상환 창 옵트인).
새 enum (라이브)
maturity_model, tranche_role. 그리고 추가된 값: lifecycle_status.IMPAIRED(v3-12), lifecycle_status.WIND_DOWN(v3-06), redemption_status.PENDING_RESERVE(v3-04), redemption_status.QUEUED / redemption_status.PARTIALLY_FILLED(0021/v3-26), nav_update_status.CANCELLED. 제거된 값: redemption_status.FM_ACCEPTED(0021/v3-34. 새 타입을 만들어 바꿔치우는 방식으로 enum을 다시 만들었고 레거시 행은 REQUESTED로 옮겼다).
nav_data_source와 tranche_structure enum은 v3.0 설계에서 초안이 잡혔으나 구현 전에 각각 v3-15와 v3-14에서 제거됐다.
✅ 반영됨 — v3-109 NAV 확정 (마이그레이션 0117~0122)
2026-08-04 dev 적용. 이 절은 여기 있던 스펙을 대체한다. 컬럼 그룹 다섯 중 셋이 계획과 다른 모양으로 나갔고, 그 차이가 요점이다.
pools — 버퍼 설정(R6), 마이그레이션 0118. 컬럼 넷으로 계획했는데 셋으로 나갔고, 넷 중 하나는 버그로 드러났다.
| 컬럼 | 타입 | 비고 |
|---|---|---|
buffer_rate_bps | INTEGER NOT NULL DEFAULT 0, CHECK 0..10000 | 매니저의 first-loss 요율이다. bufferCap = total_deposited × buffer_rate_bps / 10000. 모든 풀에서 0이고 그것이 확인된 출시 조건이지 자리표시자가 아니다 |
buffer_direction | TEXT NOT NULL DEFAULT 'FIRST_LOSS', CHECK (FIRST_LOSS | EXCESS) | 누가 손실을 지는가. 스펙은 방향을 Joob에게 물어볼 열린 질문으로 적어 뒀는데 대신 컬럼으로 나갔다. 12시간마다 도는 산식에 대해 기한 없는 대기는 안전장치가 아니기 때문이다. 상한 5%에 손실 8%면 투자자가 3%나 5%를 지는데 어느 화면에도 그 둘을 구분할 것이 없다. 0에서 대칭이 아니므로 기본값은 0일 때 현재 동작을 보존하는 쪽이다 |
buffer_basis | TEXT NOT NULL DEFAULT 'GROSS', CHECK (GROSS | NET) | 계획의 buffer_reporting_basis를 buffer_* 접두사에 맞춰 이름을 바꾼 것이다. GROSS는 파트너가 총 손실을 보고하고 Aset이 버퍼를 빼는 것, NET은 이미 그들의 흡수가 반영된 것이라 이중 계산 대신 상한을 0으로 강제한다. R8을 한 층 위에서 반복하는 것을 막는다 |
fund_report_cadence_days | INTEGER NULL, CHECK > 0 | 계약으로 약정된 리포트 주기다. NULL이면 기록에 없다는 뜻이고, NULL인 동안은 앞을 내다보는 투자자 문구를 숨긴다. 관측된 주기는 약속이 아니다. dev에서는 7개월에 리포트 7건이 불규칙한 간격으로 왔다 |
apy_basis | TEXT NOT NULL DEFAULT 'GROSS_DEPOSIT', CHECK (GROSS_DEPOSIT | DEPLOYED) | apy_rate가 입금 전액에 대한 요율인지 파트너에게 보낸 부분에 대한 요율인지다. 리저브가 사실상 0인 동안은 무기력하다 |
🔴 buffer_cap과 buffer_balance는 의도적으로 만들지 않았다. 계획은 줄어드는 원장을 서술했다. buffer_balance = max(0, buffer_cap − 흡수분), 그다음 uncovered = loss − buffer_balance다. 이건 흡수분을 두 번 뺀다. loss = cap = 100이면 정답이 0인데 미충당 100으로 보고한다. NAV 산식은 절대값이라 버퍼에는 상태가 전혀 필요 없다. FIRST_LOSS에서는 uncovered = max(0, loss − cap), EXCESS에서는 min(loss, cap)이다. 회귀 테스트가 틀린 버전을 막아 두고 있고, bufferAbsorbed는 표시용 값으로만 남는다. 상한을 실체화하는 안은 두 번째 이유로도 기각됐다. 저장된 상한과 살아 있는 total_deposited는 조용히 어긋나고, 과거 NAV가 어느 쪽을 썼는지 알려 줄 것이 없다.
무기력한 설정을 불가능하게 만드는 제약들.
| 제약 | 규칙 | 이유 |
|---|---|---|
buffer_requires_external_mapping | 0이 아닌 버퍼에 외부 매핑을 요구했다 | sweep이 버퍼의 유일한 독자인 동안에는 맞았다. 수동 NAV 경로가 손실에서 유도하기 시작하자 이 금지가 13개 풀 중 12개에서 정당한 first-loss 계층을 막았다 |
buffer_not_on_tranche_pools (0122) | buffer_rate_bps > 0이면 tranche_group_id IS NULL이어야 한다 | 트랜치 그룹의 NAV는 손실 워터폴에서 나오고 그건 이 산식을 돌리지 않으므로, 거기 버퍼는 여전히 무기력하다 |
cadence_requires_external_mapping (0122) | fund_report_cadence_days에는 external_fund_id가 필요하다 | 버퍼와 달리 이것은 리포트를 보낼 파트너의 의무를 서술한다. 피드가 없는 풀은 아무것도 받지 않는다 |
equity_buffer_prose_requires_layer (0121) | equity_buffer_rule에는 buffer_rate_bps > 0이 필요하다(빈 값은 미설정으로 친다) | 이 산문은 투자자에게 "Manager first-loss commitment"로 렌더된다. 0077이 구조화된 버퍼가 없던 시절에 추가했고, 이제는 설정된 계층을 대신하는 게 아니라 서술해야 한다 |
둘 다 pools.patch.update에 반영해서 API가 raw 23514 대신 문장을 반환한다. 새 풀에는 요율이 없으므로 생성은 equity_buffer_rule을 아예 거부한다.
nav_proposals — dismiss와 TTL(D2)은 새 컬럼 없이 나갔다. 계획했던 dismiss_reason과 ttl_expires_at을 추가하지 않았다. dismiss는 사유를 기존 reason 필드에 쓰고(서버가 요구한다) 만료는 유도한다. created_at + 48h이고 resolved_by IS NULL이 사람의 결정이 아니라 TTL 만료를 뜻한다. 별도 타임스탬프 컬럼으로는 어차피 그 둘을 구분할 수 없다. 에스컬레이션된 제안은 자동 만료되지 않는다.
⚠️ recovery_flag는 하루 동안 존재했다. 0118이 NAV를 올리는 제안을 표시하려고 추가했고 0120이 삭제했다. 그 근거는 상승이 타임락 없이 적용되므로 나쁜 상승을 예고 기간에 잡을 수 없다는 것이었는데, 그건 빠진 통제가 아니라 타임락 자체를 서술한다. 제품의 판단은 NAV 상승에는 승인 버튼 말고는 필요 없다는 것이다. 읽히지 않는 채로 두지 않고 삭제했다. 항상 false인 컬럼은 다음 사람에게 신호로 읽힌다.
pools.lp_total_supply — 온체인 미러(R9), 마이그레이션 0117. nullable로 추가했고 인덱서가 쓰지만 아직 산식이 쓰지 않는다. NULL은 "미러링되지 않았다"이지 공급량 0이 아니므로, 배포된 풀에서 미러가 실제로 도는 것이 확인될 때까지 전환을 미룬다. 분자를 DB에, 분모를 체인에 두면 인덱서 지연이 전부 NAV 점프가 되는데, 인덱서는 이달 초에 13일 동안 조용히 죽어 있었다.
nav_history / nav_proposals — 하락 원인 메타데이터(FE). 여전히 스펙 수준이고 계획에서 바뀐 것이 없다. 상각 델타, 그 기준일, 리포트 기간이고, 질의되는 상태가 아니라 표시용 출처이므로 JSONB 하나가 될 가능성이 크다.
settledUnclaimedLp — 온체인 전용(R10). ✅ 반영됨. 컨트랙트 상태 변수(PlatformPoolStorage.sol)이고 같은 이름의 getter가 있으며, 체결이 redemptionCommitted로 예약될 때 +, 청구나 소각에서 −이고, wind-down 분모는 온체인에서 계산한다. dev 배포는 2026-08-04다(팩토리 0xE1E2E974…DA90 → 풀 구현체 0x27D9948F…f968, 커밋 0abe168). ⚠️ 새 풀에만 해당한다. 풀은 Clones이고 구현체가 생성 시점에 고정되므로, 그 배포 이전에 만들어진 풀은 전부 옛 구현체로 돈다(그쪽에서는 getter가 revert한다). DB 컬럼은 없다. 화면이 필요로 할 때만 미러링할 것.
✅ 반영됨 — v3-110 풀 마감·아카이브·숨김 (마이그레이션 0123~0124)
2026-08-04 dev 적용. 여기 있던 스펙을 대체한다. 나온 모양이 스펙과 다른 곳은 조용히 덮어쓰지 않고 그 차이를 적어 둔다.
pools.is_hidden — BOOLEAN NOT NULL DEFAULT false(0123). 풀이 계속 돌아가는 채로 기본 어드민 목록에서만 뺀다. 기능 변화도, 온체인 구성요소도 없고, 자유롭게 되돌릴 수 있으며, deleted_at·is_paused 둘 다와 독립이다. ?include_hidden=true로 응답에 다시 들어온다. 요청할 방법이 없는 숨김은 삭제와 구분되지 않는다.
🔴 "투자자 목록과 PDP"라고 했던 스펙에서 좁혔다. 투자자 표면은 건드리지 않는다. 홀더는 상환하고, 수익을 청구하고, 상각 공지를 읽으려면 풀 상세에 도달해야 한다. 거기서 숨기면 운영자 편의로 내린 결정 뒤에 남의 돈을 두는 셈이다. 풀이 정말로 투자자 시야에서 빠져야 한다면 그건 lifecycle 문제이지(CLOSED나 아카이브) 표시 플래그가 아니다. deleted_at(아카이브. 포지션이 0인지를 게이트로 하고 온체인 pause()를 미러링하며 복구에 사유가 필요하다)과도, is_showcase(불변, 커스터디 수준)와도 다르다. 컬럼 셋, 의미 셋이다.
⚠️ lifecycle_status enum에 ARCHIVED 값은 없고 추가하지도 않았다. 아카이브는 deleted_at이고 ARCHIVED 라벨은 어드민 매퍼에서 유도한다.
activity_events.event_type — POOL_ARCHIVE와 POOL_RESTORE가 생겼다. 마이그레이션은 없다. 컬럼이 TEXT이고 union은 lib/shared/audit/activity.ts에 있다. 굳이 나눈 이유는 아카이브(되돌릴 수 있음)와 하드 삭제(영구)가 둘 다 POOL_DELETE였고 metadata.mode로만 갈렸기 때문이다. JSONB 필드는 타입처럼 거르거나 세거나 알림을 걸 수 없어서, 로그가 "무엇이 영구히 파괴됐나"에 답할 수 없었다. 이제 POOL_DELETE는 되돌릴 수 없는 쪽만 뜻한다. audit_feed는 바꿀 것이 없었다. ae.event_type을 그대로 통과시키고 POOL_* 분기가 없다.
pools.end_date — ⚠️ 0204 / v3-141이 대체했다. 아래 문단은 v3-110이 무엇을 했고 왜 되돌려졌는지의 기록이다. v3-110은 이 컬럼에 두 번째 일을 붙였다. lifecycle 스케줄러가 그것을 자동 마감 트리거(ACTIVE → CLOSED)로 읽었는데, 그 패스가 만기 패스 뒤에 돌았다. 그래서 종료일과 만기를 둘 다 지난 FIXED_TERM 풀은 MATURED로 남고 CLOSED로 강등되지 않았다. 그 순서는 의도한 것이고 지금도 그렇다. MATURED가 상환을 무패널티로 만들고, 그런 풀을 강등하면 스스로 빠져나갈 자격을 얻은 홀더에게 조기 이탈 패널티가 되살아난다.
🔴 0204가 되가져간 것은 그 두 번째 일이다. 두 패스가 같은 날짜를 비교했고 만기 패스가 먼저 돌았기 때문에 자동 마감 패스는 한 번도 발동하지 않았다. 그리고 두 날짜가 같았던 이유는 resolveMaturityAt이 end_date를 동시에 만기로 읽고 있었기 때문이다. 컬럼 하나가 두 질문에 답한다는 것은 풀이 만기가 오는 그날까지 입금을 받았다는 뜻이다. 이제 자동 마감 패스는 subscription_end_date를 읽고 end_date는 만기뿐이다. 같은 변경에서 만기 패스의 상태 필터에 CLOSED가 추가됐다. 기간이 끝나기 전에 닫히고 그다음 만기가 올 수 없는 풀은 상환이 영영 열리지 않는 풀이기 때문이다.
nav_proposals.escalate_flag — 주석만(0124). 에스컬레이션 조건인 전손을 한 곳에서 명시한다. 이미 적용된 0094를 고치는 대신 새 COMMENT ON COLUMN으로 냈다. 이 저장소는 적용된 마이그레이션을 역사로 다루기 때문이다(0119는 0122가 은퇴시켰고 파일은 그대로 뒀다).
폐기된 컬럼 (이력용으로 유지)
pools.escrow_model,pools.yield_distribution_model,pools.receipt_token_address(pools.lp_issuance_model과 그 enum은 마이그레이션 0050에서 삭제됐다)deposits.receipt_*/escrow_status/refund_eligible_at은 마이그레이션 0022에서 삭제됐고,deposits.fm_notified_at은 0050에서 삭제됐다redemption_requests.contract_validated_at,.auto_released_at,.approval_requested_at,.cosigned_at,.fund_*(.fm_notified_at과.fm_accepted_at은 마이그레이션 0050에서 삭제됐다)- 상태 값:
deposit_status.REFUNDED삭제(마이그레이션 0049).redemption_status.FM_ACCEPTED는 마이그레이션 0021/v3-34에서 제거됐고 더 이상 enum에 없다
마이그레이션 방식
- 새 컬럼은 nullable로 추가하고, 가능한 곳에서는 기존 값으로 백필한다(예: AS_POOL 레거시에서
fund_wallet=pool_wallet) - 폐기된 컬럼은 삭제하지 않고
COMMENT ON COLUMN ... IS 'deprecated v3.0'으로 표시한다(이력 보존) - 데이터를 파괴하지 않는다. 레거시 풀에 대한 읽기는 그대로 동작한다
현재 버전
v3.0(배포됨), 기반은 v2.17 · R18. 테이블 51개 · enum 27개 · v3.0 차원 컬럼 라이브 · 레거시 컬럼 유지. Supabase와 2026-08-06, 마이그레이션 0126까지 동기화. 최신: 0126 입금 경로 출처 기록과 도달 불가 함수 둘 삭제, 0125
portfolio_positions.entry_price(취득원가), 0124nav_proposals의 전손 문구. 개수는 dev 인스턴스의pg_class/pg_type과 대조해 확인했다. 최근 마이그레이션 표는 이 문서 맨 위 콜아웃에 있고 전체 이력은 17-changelog에 있다. 이 줄은 개수이지 이력이 아니다.