Skip to content

알림 시스템

플랫폼이 투자자, 펀드매니저, 운영자, 어드민에게 어떻게 알리는지를 다룬다. 채널, 이벤트, 수신자, 문구, 격리, 이메일·인앱 발송 모델이다. V1은 2026-06-25에 확정됐고(결정 v3-44 / v3-45), 2026-07-31에 이벤트 → 알림 → 발송 모델로 재구축됐다(v3-103, 마이그레이션 0110).

정본에 대해: 이 페이지는 알림에 대한 종합 레퍼런스이고, 문구 자체의 기준은 코드다(v3-103). apps/infra/lib/shared/notifications/catalog/에 있고 0110 이후 시트 섹션마다 파일 하나다. Notification Copy 시트는 그것의 제품 쪽 뷰다. 둘이 다르면 코드가 이기고, 오래 갈 해법은 두 벌을 손으로 고치는 대신 카탈로그에서 시트를 생성하는 것이다. 그렇다고 여기에 문구를 써도 된다는 뜻은 아니다. 작성된 텍스트가 없는 항목은 TODO(copy)이자 제품에 대한 질문이고(CLAUDE.md 규칙 2-b), 빌드 가드가 지어낸 보장 문구와 조작된 날짜에서 실패한다.

채널 (V1)

채널상태비고
인앱✅ V1. 항상 켜져 있는 기본선모든 알림이 앱에 나타난다(벨과 /notifications). 이벤트별 토글이 아니다. notifications 행이 곧 인앱 항목이라, 이메일에 무슨 일이 생기든 그것과 무관하게 존재한다.
이메일✅ V1트랜잭션성이고 영문 전용이며 no-reply@aset.finance에서 나간다. critical은 항상 보내고, optional은 두 대상 모두에 대해 환경설정을 따른다. 차단된 주소(하드 바운스나 불만)는 critical보다도 우선한다.
Webhook⏳ 뼈대만notification_channel enum 값(마이그레이션 0087)을 프로그램 방식 발송용으로 예약해 뒀다. 이제 이를 추가하는 것은 알림당 notification_deliveries 행 하나를 더하는 일이지 페이로드를 복제하는 일이 아니다.
Telegram / Slack⏳ V2도입할 때 채널 컬럼을 추가한다.

구조

테이블 셋이고 각각 질문 하나에 답한다. 무슨 일이 있었나 / 누구에게 알리나 / 어떻게 나갔나.

도메인 핸들러 | 인덱서 라이터
  → notify({ eventKey, idempotencyKey, subject, audience, variables, perRecipient? })
      1. 카탈로그 조회                       (catalog/, 이벤트당 항목 하나)
      2. notification_events INSERT          ← idempotency_key UNIQUE 가 곧 중복 제거
      3. 대상 해석 → 사람들                  (audience/resolve.ts)
      4. 사람별 채널 결정                    (preferences/decide.ts, 순수 함수)
      5. notifications + notification_deliveries INSERT
      6. PENDING 발송분 → SQS
  → 인앱 피드가 `notifications`를 읽는다     (GET /notifications · /admin/notifications)
  → notifications.worker.deliver (SQS)       이때 이메일을 렌더 → SES → SENT
  → notifications.webhook.ses (SNS)          반송·불만 → email_suppressions
  → notifications.scheduler.sweep (5분)      묶여 있는 것을 다시 큐에 넣는다

notify()가 유일한 진입점이다. 계약상 치명적이지 않다. 모든 생산자가 이미 커밋된 작업 위에서 돌기 때문에, 알림이 정산된 입금을 500으로 바꿔서는 안 된다. 실패는 로그로 남고 반환값으로 보고된다.

왜 테이블이 셋인가

이전 모델은 notification_logs 행 하나가 두 역할을 겸했다. 인앱 수신함 항목이자 이메일 발송 로그였다. 어색했던 모든 것이 그 겹침에서 나왔다.

증상원인
투자자가 읽을 수 있는 인앱 항목이 status = 'FAILED'를 달고 있었다그 상태는 이메일을 서술하고 있었다
SUPPRESSED를 만들어 내야 했다"이메일은 건너뛰고 인앱은 정상"을 뜻하려고 그랬는데, 그건 행 하나가 둘을 겸할 때만 생기는 상태다
WEBHOOK enum 값을 쓸 수 없었다행당 channel 컬럼이 하나라, 채널을 더하려면 페이로드를 복제해야 했다
fund_id가 없는 풀에 대한 FM 안내가 아무에게도 닿지 않았고 조용했다recipient_id가 행에 따라 user_id, 풀 id, 펀드 id를 뜻했고, 발송 워커가 80줄 뒤에서야 그것을 해석했다

생산 시점 팬아웃

ADMIN 알림은 예전에 브로드캐스트 행 하나였고 이메일을 보낼 때 대상이 해석됐다. 이제는 생산자가 사람당 notifications 행 하나를 쓴다. 결과가 셋이고 전부 의도한 것이다.

  • 읽음 상태가 사람별이다. 동료가 알림을 열어도 팀 전체에서 지워지지 않는다(이것이 "모델 A: 공유 읽음"을 대체한다).
  • 채널이 선택되는 곳에서 환경설정이 적용된다. 그래서 수신 거부는 발송 행 자체가 없는 것을 뜻하고, 실패 집계에 나타날 수 없다.
  • 해석할 수 없는 대상은 호출 지점에서 실패한다. 발송 시점에 조용히 사라지지 않는다.

이 변경으로 어드민 피드의 펀드 격리 로직도 통째로 사라졌다. FM이 다른 펀드의 행을 볼 수 없는 이유는, 그 행들이 다른 사람 앞으로 쓰여 있기 때문이다.

대상은 카탈로그에 있다

각 항목이 자기 수신자를 선언한다.

ts
{ eventKey: 'yield_distributed_ops', audience: ['opsTeam', 'fundManagers'], … }

investor · poolHolders · poolFollowers · fundManagers · opsTeam · adminUser가 있다. 예전에는 "누가 이걸 받는가"를 생산자가 어쩌다 호출한 헬퍼가 결정했다. 그래서 시트의 recipient 열이 코드가 부정할 수 있는 의도를 기록하고 있었고, 실제로 그랬다(NAV 이벤트 둘이 운영에 도달한다고 문서화돼 있었는데 실제로는 투자자에게만 갔다). 이제 생산자는 파라미터({ poolId })만 넘기고 대상은 절대 넘기지 않는다.

중복 조회 대신 멱등성

notification_events.idempotency_key가 UNIQUE이고, 그것이 메커니즘의 전부다. 각각 조회 자체가 오류를 내면 중복을 통과시키던 네 개의 열린 실패 조회(dedupByEntity, recentlyNotified, 그리고 손으로 짠 notification_logs 쿼리 둘)를 대체했다.

반복 알림은 시간 창을 넘기는 대신 주기를 키에 담는다.

형태예시이유
엔티티당 한 번deposit_confirmed:<depositId>행마다 한 번 일어나는 이벤트다
온체인 로그당 한 번deposit_confirmed:tx:<hash>:<logIndex>인덱서 재생이 다시 알리면 안 된다
주기당 한 번freeze_expired:<poolId>:<freezeStartedAt>연장하면 다시 찍히므로 정당하게 다시 알린다
참인 동안 하루 한 번epoch_gated:<pool>:<epoch>:2026-07-31조건이 지속되므로 매시간 크론이 도배하면 안 된다

렌더 시점이 채널마다 다른 것은 의도다

  • 인앱은 쓰는 시점에 렌더해 저장한다. 그러면 피드 쿼리가 카탈로그에 의존하지 않고, 문구를 고쳐도 누군가 이미 읽은 메시지가 다시 쓰이지 않는다.
  • 이메일보내는 시점에 카탈로그와 저장된 변수로 렌더한다. 그래서 문구 수정이 아직 큐에 있거나 재시도 중인 것에도 반영되고, 홀더 천 명짜리 분배가 같은 HTML 문서를 천 벌 저장하는 대신 변수 천 행을 저장한다.

⚠️ 인앱 문구는 스냅샷이라 문구 수정이 소급되지 않는다. dispatch()가 insert 시점에 renderInApp(def, vars)를 호출해 결과를 notifications.in_app에 저장하고, 피드는 그 컬럼을 골라 그대로 반환한다. 문구 변경 전에 쓰인 행은 옛 표현을 영원히 유지하고, 수정을 보려면 새 알림을 발생시키는 수밖에 없다. 기존 피드 행에서 확인하면 언제나 변경이 반영되지 않은 것처럼 보인다. (카드의 수치 블록은 예외다. notification_events.variables에서 실시간으로 읽으므로, 오래된 이벤트는 newNavissueValue가 그냥 없고 블록이 잘못 렌더되는 대신 생략된다.)

문구의 숫자는 템플릿이 아니라 생산자가 포맷한다

템플릿은 문자열 치환이다. '{oldNav} → {newNav}'는 이메일 헤드라인이자 상세 행이라, 생산자가 넘긴 것이 그대로 수신자가 읽는 것이 된다. nav_change_proposed가 raw NUMERIC 컬럼을 넘겨서 투자자에게 이렇게 나갔다.

0.9166666666666666 → 0.85

이걸 잡는 것이 없다. 숫자는 유효한 숫자이고 치환은 성공하며 tsc는 NAV의 자릿수에 아무 의견이 없고 문구 가드는 값이 아니라 표현을 검사한다. 이제 notifications/format-nav.ts가 유일한 포맷터이고($0.9167, 기호가 붙은 소수 넷째 자리) 모든 NAV 생산자가 이를 거친다. nav_change_proposed(와 _ops), nav_change_cancelled(와 _ops), nav_proposal_pending, nav_apply_failed, 그리고 원래부터 조용히 제대로 하고 있던 상각 안내다. 값이 없으면 $0.0000 대신 빈 값 글리프를 렌더한다. 전자는 주장이 되기 때문이다.

소수 넷째 자리는 저장 정밀도가 아니라 표시 선택이다. NAV는 온체인에서 NAV_PRECISION = 1e6에 살고 지급액도 거기서 계산되지 이 문자열에서 계산되지 않는다.

발송

크론이 아니라 큐다. 옛 워커는 3분마다 100건씩 폴링했고 실행이 겹쳐 이중 발송되는 것을 claimed_at 컬럼으로 막았다. SQS가 그 역할을 하고(처리 중인 메시지는 다른 컨슈머에게 보이지 않는다) 시간당 약 2,000건이라는 상한도 없앤다. 그 상한 때문에 홀더 천 명짜리 분배가 다 나가는 데 30분이 걸렸다.

  • 재시도는 큐의 몫이다. 실패한 메시지는 배치 항목 실패로 돌아오고, SQS가 가시성 타임아웃 뒤 재전달하며, 재구동 정책이 5회 시도 뒤 DLQ로 보낸다. 행의 attemptsnext_attempt_at은 그 기록이고 sweep이 읽는 값이다.
  • 이제 백오프가 있다. 1 → 5 → 15 → 60 → 240분이다. 옛 워커는 실패한 행을 즉시 풀어 줘서 세 번의 시도가 9분 만에 소진됐고, 짧은 SES 장애에 걸린 것들이 영구 실패로 끝났다.
  • SENT은 SES가 접수했다는 뜻이지 전달됐다는 뜻이 아니다. 아래 참조.

반송과 불만 피드백

SendEmail이 성공했다는 것은 SES가 메시지를 받았다는 뜻뿐이다. 반송이나 불만은 몇 분 뒤 SNS 토픽으로 도착하는데, 0110 이전에는 아무도 듣고 있지 않았다. 설정 세트는 있었고 notification_failure_typeBOUNCEDSPAM_FILTERED도 이미 있었지만 SendEmailCommand가 그 세트를 지정한 적이 없었다. 그래서 SES가 만든 모든 반송이 버려졌고, DELIVERED는 시스템이 뒷받침할 수 없는 주장이었다.

  • notifications.webhook.ses가 발송 시 기록한 provider_message_id로 피드백을 대조해 발송 상태를 BOUNCED / COMPLAINED로 바꾼다.
  • 영구 반송이나 모든 불만은 주소를 email_suppressions에 넣는다. 일시적 반송은 넣지 않는다. 메일함이 찼다는 이유로 투자자를 영원히 침묵시키면 안 된다.
  • 차단된 주소는 우선순위보다 위이고 critical도 포함한다. 어차피 도착하지 않을 메시지이고, 계속 보내면 플랫폼의 모든 이메일이 공유하는 발송 평판을 해친다. 인앱 알림은 그대로 도착한다.

⚠️ 배포 단계: SesFeedbackTopicArn 스택 출력을 aset-<stage> SES 설정 세트의 Bounce와 Complaint 이벤트 대상으로 연결해야 한다. 그게 배선되기 전까지 피드백은 여전히 버려진다.

목록 관리. 차단은 critical 이벤트를 포함해 무엇보다 우선하므로, 목록에 오른 주소는 상환과 컴플라이언스 안내를 아예 받지 못한다. 대부분의 항목은 맞지만, 수신 서버가 일시적 실패를 영구로 잘못 보고하거나 "스팸"을 눌렀다가 마음을 바꾼 수신자는 돌아올 길 없이 끊기게 된다. 그래서 이 목록은 쓰기 전용이 아니다.

GET /email-suppressions목록을 읽는다(주소, 사유, 제공자 진단, 시점). 알림 로그와 같은 게이트다. OPERATOR는 notifications 페이지 권한이 필요하고 ADMIN·SUPER는 우회한다
POST /email-suppressions주소를 손으로 차단한다(reason = 'MANUAL'). ADMIN / SUPER_ADMIN 전용이고 note필수다. 이벤트별 토글로는 표현할 수 없는 "아예 메일을 보내지 말아 달라"는 요청, 잘못된 것이 확인된 주소, 컴플라이언스 보류에 쓴다. EMAIL_SUPPRESSION_ADD로 감사된다
DELETE /email-suppressions/{email}차단을 해제한다. ADMIN / SUPER_ADMIN 전용이다. 우리를 스팸으로 신고한 사람에게 이메일을 되살리는 것은 평판과 컴플라이언스 무게가 있어서 운영자 조작이 아니다. EMAIL_SUPPRESSION_REMOVE로 감사되고, 행을 지우면 사라지므로 감사 기록이 이전 사유와 진단을 보관한다

reason은 수동 엔드포인트의 파라미터가 아니다. HARD_BOUNCECOMPLAINT는 제공자가 관측한 것에 대한 진술이고 어드민은 그것을 관측할 수 없다. 둘 중 하나를 받으면 아무도 목격하지 않은 주장을 감사 기록에 넣게 된다. 이미 차단된 주소를 추가하면 조용한 성공이 아니라 기존 사유를 담은 409다. "하드 바운스로 이미 차단됨"은 "방금 내가 추가했다"와 다른 정보이고, 보통은 제공자가 관측한 더 강한 사유를 유지하는 게 맞다.

쓰기 방향 둘 다 감사되는 이유는 이것이 우선순위를 뒤집는 유일한 통제이기 때문이다. 전면 차단은 지급과 컴플라이언스 안내도 멈춘다. 한 사람한 이벤트를 끄는 것은 환경설정 API가 할 일이다.

차단을 해제해도 이미 SKIPPED된 것을 다시 시도하지는 않는다. 그때 보내지 않기로 결정된 것들이고, 무관한 어드민 조작에 옛 안내가 다시 나가면 수신자가 놀란다. 다음에 그 이벤트가 발생하면 정상적으로 도달한다.

⚠️ 미해결 제품·법무 질문: COMPLAINT는 수신자 본인이 표명한 의사라, 낡은 반송을 지우는 것과는 성격이 다르다. 현재 엔드포인트는 둘 다 허용하고 어느 쪽이었는지를 기록한다. 불만은 되돌릴 수 없어야 한다면 그건 코드로 추론할 게 아니라 정책으로 결정할 문제다.

수신자와 격리

notifications.recipient_kindINVESTOR | ADMIN이고 언제나 사람이다. 풀이나 펀드나 "팀"이 아니다. INVESTORusers.id를, ADMINadmin_users.id를 가리킨다(후자가 펀드매니저도 포함한다. FM은 role이 FUND_MANAGERadmin_users이다). 두 값이 notification_preferences.user_type과 정확히 일치하므로, 환경설정 조회가 매핑 계층 없는 단순 동등 비교다.

경계격리되나
다른 펀드의 FM끼리구조적으로 그렇다. 행이 개인 앞으로 쓰여 있어서 범위를 제한할 대상 자체가 없다. 엔드포인트가 recipient_id = me로 필터링한다.
FM과 어드민·운영자✅ 사람별로 행이 따로다
어드민과 운영자⚠️ 둘 다 opsTeam 대상에 들어가므로 둘 다 운영 이벤트를 받는다. 각자 자기 행과 읽음 상태를 갖는다. 이 둘을 나누는 것은 의도적으로 미뤘다(v3-103). 어드민 앱의 모든 키에 대해 이벤트별 대상 결정이 필요한데, 아직 운영자 전용 이벤트가 없어서 그 비용을 정당화할 수 없다.

대상 둘을 겨냥한 이벤트(['opsTeam', 'fundManagers'])는 합집합에 대해 사람당 행 하나를 쓰고 중복을 제거한다. 운영자이면서 펀드 구성원인 사람은 한 번만 듣는다.

Critical과 optional

  • Critical(D4): 중대 이벤트(NAV 상각, impairment, wind-down), 상환 플로우, 계정 안전(동결, KYC 취소), 그리고 대부분의 자금·신원 이벤트다. 항상 발송되고 끌 수 없다.
  • Optional: 운영·인지용 이벤트다. *_ops 변형, 회차 운영 알림, large_deposit, 수익 도래 리마인더, 팀·접근 변경, pool_lifecycle_active가 해당된다. notification_preferences를 따른다. 66개 키 중 39개가 어드민 앱을, 27개가 투자자 앱을 대상으로 한다. 19개가 optional이고 그중 16개가 어드민 앱이다. 그래서 투자자에게 도달하는 optional 이벤트는 셋뿐이다. pool_lifecycle_active, pool_update_important, epoch_request_window_open이다. (2026-08-06 기준 catalog/apppriority 필드에서 센 값이다.)
  • 심각도(우선순위와 별개인 시각 강조): 통상적이거나 긍정적인 것은 info(브랜드 파랑), 실패나 부정적인 것은 critical(코랄)이다. 문구 레지스트리에 이벤트별로 저장된다.
  • 스로틀(D8): critical은 즉시이고 이벤트당 하나, optional은 1시간 다이제스트 창이다(V2, 미구현). 재시도: SQS 재전달을 1/5/15/60/240분 백오프로 하고 그다음 DLQ로 간다. 최종 실패 시 워커가 운영팀에 fm_notification_failed를 발생시킨다.
  • 강제는 순수 함수(preferences/decide.ts)이고 일괄 로드된 사실을 입력받는다. 그래서 규칙을 DB 없이 단위 테스트할 수 있다. 우선순위는 이렇다. 주소가 없으면 NO_ADDRESS, optional인데 수신 거부면 OPTED_OUT, 차단된 주소면 SUPPRESSED_ADDRESS(critical보다 우선한다). 각각이 SKIPPED 발송으로 기록되므로 "이 사람에게 의도적으로 메일을 보내지 않았고 그 이유는 무엇인가"에 답할 수 있다.

이벤트 목록 (V1)

catalog/에 66개 항목이 있고 2026-08-06에 빌드 가드가 생산자 맵과 대조해 확인했다. 레지스트리 자신의 category로 묶었고 그것이 프론트엔드 필터이자 두 환경설정 카탈로그이기도 하다. FM·운영자용 인지 변형(*_ops)은 투자자 안내에 더해서 보낸다(D3). ⛔는 문구는 있는데 생산자가 없는 항목이다(아래 참조).

분류 (n)이벤트수신자
DEPOSIT (3)deposit_confirmed · deposit_confirmed_ops · ⛔ lp_minted투자자 (2) · FM/어드민 (1)
REDEMPTION (20)redemption_requested · redemption_completed · redemption_returned_unfunded / redemption_rejected(사유별 분리) · redemption_pending_reserve · epoch_settlement_complete · epoch_request_window_open · epoch_demand_finalized · over_funding_detected · fm_shortfall · redemption_escalated · redemption_exit_gate_blocked · epoch_funding_needed · epoch_partner_funding_reminder_48h / _12h · epoch_circuit_breaker · epoch_execute_failed · epoch_gated · epoch_low_fill · ⛔ redemption_funded투자자 (7) · FM/어드민 (14)
YIELD (6)yield_distributed · yield_distributed_ops · yield_distribution_due · yield_distribution_overdue · yield_distribution_stalled · yield_distribution_escalation투자자 (1) · FM/어드민 (5)
POOL (25)pool_matured(+ _ops) · impairment_executed(+ _ops) · winddown_executed(+ _ops) · impairment_proposed_ops · winddown_proposed_ops · impairment_execute_due · winddown_execute_due(거버넌스 타임락) · nav_change_proposed(+ _ops) · nav_change_cancelled(+ _ops) · nav_proposal_pending · nav_apply_failed · pool_frozen · freeze_exit_window_open · freeze_expired · pool_unfrozen · pool_lifecycle_active · epoch_funding_date_due · large_deposit · pool_update_important / pool_update_material(아래의 WO-6 래퍼)투자자 (10) · FM/어드민 (15)
KYC (4)kyc_approved · kyc_rejected · kyc_reverify_due · sbt_mint_failed_user투자자
ACCOUNT (4)kyc_revoked_aml · fund_invite_accepted · fund_member_changed · admin_user_changed투자자 (1) · FM/어드민 (3)
SYSTEM (3)sbt_mint_failed · fm_notification_failed · ⛔ lp_mint_failed어드민
풀 업데이트 (WO-6)운영자·시스템 공지다. 0110 이후 위의 POOL에 포함된다. pool_update_important / pool_update_material이 실제 레지스트리 항목이고, 운영자가 쓴 텍스트에 플랫폼이 씌우는 래퍼를 담으며 제목과 본문은 변수로 들어온다. INFO는 피드 전용이고 아무것도 보내지 않는다.홀더

freeze_extended는 레지스트리에서 사라졌다. 온체인 연장 자체가 제거됐기 때문이다. 7일 타임락이 7일 동결 수명과 같아서, 동결이 이미 끝난 뒤에야 발동하거나 소급해서 풀을 다시 잠그고 72시간 이탈 차단을 재시작할 수밖에 없었다. 그 이메일도 어차피 틀려 있었다. "Until" 라벨 아래에 새 시작 날짜를 렌더했다. 그리고 옛 13-operations 매트릭스에 실려 있던 pool_nav_update는 애초에 이벤트 키였던 적이 없다. 실제 키는 nav_change_proposed / _cancelled(와 _ops), nav_proposal_pending, nav_apply_failed다.

인앱 분류(프론트엔드 필터): DEPOSIT · REDEMPTION · YIELD · KYC · POOL · ACCOUNT · SYSTEM이다. 집합을 닫아 둔 것은 의도적이다. 프론트엔드 필터 두 환경설정 카탈로그를 함께 구동하므로 분류를 추가하면 표면 셋을 고쳐야 한다. holdback_release_deferred를 새 FUNDING이 아니라 REDEMPTION으로 둔 이유가 그것이다.

생산자가 없는 항목 셋

  • lp_minted: 의도적이다. v3-48의 입금은 atomic이라 deposit_confirmed가 이미 토큰이 생겼다고 알린다. 같은 트랜잭션에 대한 두 번째 안내는 중복이다. catalog/index.tsUNEMITTED에 선언해 뒀으므로, 설정 페이지가 "LP Tokens Issued"를 투자자가 받게 될 항목으로 더 이상 나열하지 않는다.
  • redemption_funded: 문구는 썼지만 배선되지 않았다. redemption_pending_reserve가 지급이 도착하면 알리겠다고 이미 약속하고 redemption_completed / epoch_settlement_complete가 그것을 전달하므로, 중간의 "펀딩됐고 처리 중" 단계에는 독자가 없다.
  • lp_mint_failed: atomic 입금에서는 관측할 수 없다. revert된 deposit()은 행도, 이벤트도, 부분 상태도 남기지 않아서 아무것도 그것을 감지할 수 없다. 되살리려면 클라이언트가 실패를 보고하는 엔드포인트가 필요한데, 그건 생산자가 없는 문제가 아니라 제품 결정이다. catalog/index.tsUNEMITTED에 선언돼 있고 패널에서 비활성("Not yet active")으로 렌더된다.

redemption_fundedlp_mint_failed삭제 예정이다(v3-103). 문구 시트가 이미 둘을 뺐으므로, 코드가 제품에 없는 이벤트를 주장하고 있는 셈이다. lp_minted는 남긴다.

이제 모든 생산자가 레지스트리를 거친다

이벤트 여덟 개가 예전에는 notification_logs직접 썼다. payload가 null로 남아 인앱 카드가 긴 문장 하나를 제목으로 렌더하고 본문도 CTA도 없이 SYSTEM으로 잘못 분류됐다. epoch_funding_needed, 파트너 펀딩 리마인더 둘, nav_apply_failed, epoch_circuit_breaker, epoch_execute_failed, epoch_gated, epoch_low_fill이다. 여덟 개 모두 이제 queueNotification을 거치고, 회차 관련 것들은 SYSTEM에서 REDEMPTION으로 재분류됐다.

회차 실행 어드민 알림은 (pool, event_type)별로 하루 단위 중복 제거도 한다. 핸들러가 지속 상태를 보고하는 매시간 크론이라, 걸린 서킷 브레이커가 모든 어드민에게 하루 24번씩 메일을 보내고 있었다. 그리고 멱등성을 주장하던 주석 뒤에는 아무 쿼리도 없었다.

create-system-update.ts는 여전히 직접 insert하지만 완전한 payload를 쓴다. 그리고 더 이상 priority: 'optional'을 하드코딩하지 않는다. MATERIAL_EVENTcritical로 강제되므로, 투자자 환경설정 강제가 살아 있는 지금 impairment나 wind-down 안내가 수신 거부 가능해질 수 없다.

빌드 게이트가 이 목록을 정직하게 유지한다

scripts/check-notification-producers.mjs(infra buildtest에 연결돼 있다)가 어긋남 세 형태 모두에서 빌드를 실패시킨다. 생산자 없는 문구, 레지스트리에 없는 키를 발생시키는 생산자, 그리고 더 이상 사실이 아닌 EVENTS_WITHOUT_PRODUCER 선언이다. 키는 따옴표로 감싼 문자열 리터럴로 매칭한다. 부분 문자열 스캔은 컬럼 이름 lp_minted_at이 그것을 포함하므로 lp_minted를 생산된 것으로 보고하는데, 그게 앞선 수동 감사를 틀리게 만든 원인이다. 런타임에 조립되는 키는 접두사와 함께 DYNAMICALLY_KEYED에 선언하고, 그 접두사도 지정된 파일과 대조한다. check-copy.mjs도 이제 infra를 덮으므로 알림 문자열이 두 프론트엔드와 같은 문구 가드 뒤에 있다.

⚠️ infra에는 CI 워크플로가 없다. 게이트는 pnpm buildpnpm test에서만 돌고, CDK 배포는 build를 거치지 않는다. 배포 전에 pnpm --filter @aset/infra test를 돌릴 것.

종료 안내는 사유별로 나뉜다 (v3-99)

Return position(POST /redemption-requests/{id}/reject)은 아주 다른 두 이유로 요청을 닫으므로 안내도 둘로 나뉜다. 운영자가 고른 failure_type 분류가 그것을 결정한다.

failure_type이벤트투자자가 읽는 내용
UNFUNDEDredemption_returned_unfunded"상환이 종료됐고 포지션이 돌아왔습니다." 자금이 도착하지 않았고 차감된 것이 없으며 언제든 다시 요청할 수 있다
COMPLIANCE / OTHERredemption_rejected"상환이 보류됐습니다." 포지션은 돌아왔고 다시 요청하기 전에 지원팀에 문의해야 한다

안내 하나로는 둘을 담을 수 없었다. 자금이 끝내 오지 않은 것은 투자자 잘못이 아니라, 옛 "거절됨" 표현이 거기서는 그냥 틀렸다. 그리고 두 표현 다 가장 중요한 사실을 언급하지 않았다. LP가 돌아왔다는 것이다. 두 안내 모두 돌아온 포지션({lpAmount} {lpSymbol})을 앞세운다.

운영자의 메모는 절대 렌더되지 않는다

필수인 자유 입력 reason은 나중에 검토할 사람을 위해 쓰는 것이고("파트너 3주째 미펀딩, FM과 확인함") 어드민 패널 밖으로 나가면 안 되는 내용을 담을 수 있다. 그래서 어느 안내에도 전달되지 않는다. 대신 분류가 표현을 고른다.

컴플라이언스 안내는 이유도 숨긴다. AML이나 검증 관련 사유를 당사자에게 알리면 tipping-off 위험이 있다. 표현은 법무 승인 대기 중이다(Notion "Legal Review Required" #25). 분기와 변수는 확정이고 문자열만 바뀔 수 있다.

redemption_exit_gate_blocked: 어드민 전용이고 자금 경보가 아니다 (v3-99)

온체인 게이트는 요청이 대기하는 동안 적격성을 잃은 홀더에게 지급을 거부한다. 그런데 그 revert는 Aset 쪽에서 조용하다. 파트너는 실패한 트랜잭션을 보고 우리는 아무것도 못 본다. PENDING_RESERVE 요청에 대한 매시간 sweep이 어드민에게 알려 그 사각지대를 메운다.

  • 수신자는 ADMIN뿐이다. 투자자에게는 아직 아무 일도 일어나지 않았다. 지급이 시도되지도, 상태가 바뀌지도 않았으므로 알릴 것이 없다.
  • 자금 위험 안내가 아니다. 문구는 요청이 지급될 수 없다 / 정산되지 않는다고 말하지 "동결됐다"거나 "위험하다"고 말하지 않는다. 컨트랙트가 지급을 막고 있어서 풀에서 나가는 것이 없기 때문이다. 요청을 닫으면 포지션이 돌아온다는 것도 함께 말하는데, 그게 rejectRedemption이 실제로 하는 일이다.
  • 날짜도, 검토 기한도, 약속한 다음 단계도 없다. 시스템에서 그런 것을 잡아 주는 게 없고, 지어내는 것이 CLAUDE.md 규칙 2-b가 막으려는 실패 양상이다.
  • 매시간이 아니라 하루 한 번 다시 알린다. 조건이 지속되므로(취소된 SBT는 취소된 채로 있다) 24시간 안에서 요청별로 중복을 제거한다. 풀이 아니라 요청을 키로 하는데, 한 풀의 막힌 홀더 둘은 별개의 결정 둘이기 때문이다.

종료 다이얼로그는 이 알림이 아니라 게이트를 실시간으로 읽는다

COMPLIANCE 미리 선택은 이제 배선됐지만, sweep의 알림이 아니라 다이얼로그를 열 때 호출하는 **읽기 전용 GET /redemption-requests/{id}/exit-gate**에 연결돼 있다. 매시간 sweep에서 가져온 미리 선택은 한 시간 낡았을 수 있고, sweep이 아직 도달하지 않은 요청에는 아예 없다. 이 조회는 열린 채로 실패한다(알 수 없으면 상태 기반 기본값을 그대로 둔다). 같은 헬퍼를 쓰는 쓰기 경로와는 반대인데, 여기서 잘못 짐작하면 펀딩 종료를 컴플라이언스 종료로 잘못 표시하게 되고 그 라벨이 투자자 안내를 고르기 때문이다.

holdback_release_deferred: 설명하던 레버와 함께 폐기됐다 (v3-100 → 0183)

이 안내는 hold-back 릴리스의 일부가 정산됐지만 미청구인 상환 청구권을 여전히 뒷받침하고 있어 미뤄졌다고 펀드매니저에게 알리는 것이었다. 한 번도 발송되지 않았다. 이 안내가 듣던 이벤트인 FundingRestrictedSet(restricted=false)가 발생할 수 없었기 때문이다. 제품에서 setFundingRestricted를 호출하는 것이 아무것도 없었다. 그래서 이 항목이 잠정 표현을 단 채 카탈로그에 ⏸️ 보류로 앉아 있었다.

레버, 버킷, 인덱서 라이터, 클램프 판정 모듈과 함께 0183에서 제거됐다(v3-112). 카탈로그 항목과 그 생산자가 사라졌으므로, 알림 생산자 가드는 예외 처리가 아니라 그것들의 부재로 만족된다.

메커니즘보다 기억할 만한 것은 이것이다. automatically라는 표현에 대한 정정이 여기서 나왔다. 배포된 문구가 잔여분이 "그 청구가 회수되면 자동으로" 지급된다고 약속했고, 단위 테스트가 /automatically/를 단언하고 있었다. 그래서 그 거짓 주장이 리뷰를 통과했다. 릴리스를 다시 돌리는 것이 아무것도 없었으므로 그 문장은 존재하지 않는 메커니즘을 서술하고 있었다. 라이브 문구에서의 CLAUDE.md 규칙 2-b 결함이고, 문자열을 GovernanceLib과 대조해 읽어서야 잡혔다. 교훈이 기능보다 오래 살아남았다.

풀 카운터에서 온 금액은 USDC의 6자리가 아니라 18자리다

풀은 모든 USD 수치를 18자리로 정규화한다(PoolCommonLib.normalizeAmount). 그래서 reserveBalance, epochFundTopUp과 거기서 파생된 모든 것이 18자리다. 이를 USDC_DECIMALS로 포맷하면 1e12만큼 과대 표시된다. 그리고 스케일이 잘못된 금액도 그럴듯한 숫자로 렌더되므로 리뷰를 통과한다.

같은 변경에서 이를 틀리게 하고 있던 안내 경로 넷을 고쳤다. epoch_funding_needed("Awaiting Funding" KPI 뒤의 funding_shortfall 컬럼 포함), epoch_settlement_complete의 지급액(redemption_requests.payout_amount와 모든 redemption_fills 행 포함), redemption_pending_reserve의 부족액, 그리고 D-2와 D-12h 파트너 펀딩 리마인더다. 마지막 것은 실제 풀에서 부족액이 0에 가깝게 계산돼서 아예 발동한 적이 없었다.

이제 축은 명명 관례가 아니라 타입이다(v3-101). contract/units.tsRawAmount / NormalizedAmount / NavPrice를 모든 온체인 금액 파라미터에 선언하고, scripts/check-onchain-units.mjs가 변환 모듈 밖의 parseUnitsformatUnits를 금지한다. 이를 적용하자 어떤 안내도 잡지 못했던 라이브 1e12 사례 둘이 더 드러났다. 이 값들에 대한 모든 온체인 검사가 상한선이라 너무 작은 숫자가 통과하고 트랜잭션이 성공하기 때문이다. 안내를 위해 풀 카운터를 읽는 것은 소수 자릿수 상수를 집는 대신 변환 함수를 거쳐야 한다.

pool_lifecycle_active: 첫 optional 투자자 이벤트, 그리고 왜 테이블이 필요했나

문구는 "팔로우하는 풀이 열렸습니다" / "열리면 알려 달라고 요청하셨습니다"로 읽힌다. 그 문장은 요청한 사람에게만 참이므로 수신자 목록이 명시적 옵트인이어야 한다. 모든 투자자에게 보내면 모든 수신자에게 문구가 거짓이 되고, 제품 안내가 원치 않는 신규 풀 광고가 된다. 이 이벤트가 V2로 미뤄져 있던 이유가 생산자가 아니라 그 목록이었다.

  • **pool_follows(마이그레이션 0109)**가 그 목록이다. (user, pool)을 PK로 하는 행 하나이므로 다시 팔로우하는 것은 멱등 upsert이고 언팔로우는 삭제다. 상태 컬럼이 없다. "팔로우했었다" 이력은 읽을 사람이 없고, 있으면 거기에도 보내고 싶어질 뿐이다. 두 FK에 ON DELETE CASCADE가 걸려 있는데, 풀은 보통 소프트 삭제되므로(CASCADE가 그것을 보지 못한다) 생산자는 이미 순회 중인 풀로 필터링하고 목록 엔드포인트는 살아 있는 풀까지 조인한다.
  • 생산자: pools.scheduler.lifecycleUPCOMING → ACTIVE 전이다. 거기의 다른 모든 생산자처럼 치명적이지 않다. 알림이 이미 온체인에 반영된 lifecycle 변경을 되돌려서는 안 된다.
  • 엔드포인트: GET / POST / DELETE /pool-follows다. 팔로우 컨트롤은 UPCOMING 배너에 있다.
  • 레지스트리에서 첫 optional 투자자 이벤트다. 그래서 투자자 환경설정 강제를 관측할 수 있게 됐다. 그 전에는 TOGGLEABLE_INVESTOR_EVENT_KEYS가 비어 있어서 게이트가 게이팅할 것이 없었다.

epoch_funding_date_due: 아무도 날짜를 정하지 않은 주기를 지켜보는 유일한 것

앵커된 회차 스케줄은 모든 경계를 주기당 숫자 하나에서 유도한다. 발행자가 오프체인으로 운영자에게 주는 펀딩일이다. 결정 C2가 그것을 열린 실패로 만들었다. 확정되지 않은 주기는 스스로 직전 펀딩일 + 주기로 전진하므로 운영자가 침묵해도 아무것도 깨지지 않는다. 깨지는 것은 기회다. windowOpen(n) 이전에는 setEpochFundingDate가 자유롭지만, 그 이후에는 WindowAlreadyOpen으로 revert하고 그 주기가 열린 실패 날짜에 잠기며 정산은 펀딩일 + recall_lead_days까지만 미룰 수 있다.

그래서 매시간 sweep(pools.scheduler.epoch-funding-date)이 요청 창이 열리기 전 리드 구간 안에서 (풀, 주기)당 한 번 알린다. notify()로 쓰는 알림 테이블 말고는 읽기 전용이다.

⚠️ 예전에는 epoch_cycle_mode로 게이팅됐다. SEMI_AUTO가 알림을 켜고 AUTO가 껐는데, 그 게이트가 없어졌다(v3-140). AUTO는 아무도 날짜를 정하지 않은 주기를 지켜보는 유일한 것을 끄면서 대체재를 지정하지 않았다. 그래서 풀이 체인의 직전 + 주기 유도로 흘러가는데 유일한 경고 신호가 꺼져 있었다. 컬럼은 남지만 읽지 않는다(0192, 주석만).

만기 후 상환형 풀에서 게이트는 선호가 아니라 상태다 (v3-135)

상환 계획에는 알려진 유한한 펀딩일 목록이 있고, 배포 워커가 시계 예산 안에서 쓴다(v3-133). 그 실행은 중간에 끊길 수 있는데, 그렇게 남은 것이 온체인에서는 누락으로 보이지 않는다. epochFundingDateAt직전 + 주기로 유도해 진짜 날짜만큼이나 확신 있게 답한다. 그래서 정직한 질문은 운영자가 알림을 요청했느냐가 아니라 아직 날짜를 유도 중인 주기가 있느냐다.

하나라도 쓰이지 않았으면 알리고, 전부 쓰였으면 침묵한다. redemption_epochs.funding_date IS NULL(0190)을 게이트로 삼고 unwrittenPlanCycles를 거친다. 회차 요약이 쓰는 것과 같은 함수라, 리마인더와 어드민 화면이 미완성 계획에 대해 서로 다른 말을 할 수 없다.

이와 함께 바뀐 것이 둘 더 있고, 둘 다 게이트보다 중요하다.

  • 안내가 제시하는 날짜는 저장된 규칙에서 온다. 펀딩 앵커에서 걸어 나온 값이지 직전 + 주기가 아니다. roll-15 계획에서 옛 유도는 12일을 제안했다. 계획에 존재한 적 없는 날짜를 확정하라고 운영자에게 말한 것이고, 그래서 이 풀들에 대해 리마인더를 고치는 대신 꺼야 했다. 여기서는 고쳤고, 이것이 v3-136이 허용하는 "사람에게 보여 주는 제안"이다. 확정하라고 날짜를 제안하는 것은 되고, 쓰는 것은 안 된다.
  • 중복 제거는 주기 날짜가 아니라 쓰이지 않은 주기의 집합을 키로 한다. 요구가 "목록을 마저 채워라"이기 때문이다. 그 집합이 바뀔 때만 다시 무장한다. 현재 주기를 키로 하면 목록이 미완성인 동안 매시간 반복된다.

🔴 확인 카드가 배포되기 전까지 이 알림이 유일한 복구 경로다

배포는 재시도하지 않는다. 쓰기가 돌 때 deploy_status는 이미 DEPLOYED이므로, 재전달된 메시지는 멱등성 가드에 막혀 거기까지 도달하지 못한다. 잘린 계획을 지켜보는 것이 그 밖에는 없다. 나중에 누군가 이를 "그냥 알림"이라고 판단해 꺼 버리면, 미완성 목록과 운영자가 승인한 적 없는 날짜로 지급하는 풀 사이에 마지막으로 서 있던 것이 사라진다.

다음 날 v3-140이 고쳤다. 처음 커밋됐을 때 sweep이 상태 게이트에 닿기 전에 epoch_cycle_mode !== 'SEMI_AUTO' → 건너뜀을 평가했는데, 위저드는 이 조합을 AUTO로 내보내고 있었다. 그래서 위의 복구 경로가 정작 그것을 위해 쓰인 풀에서 돌지 않았다. 이제 모드 검사가 게이팅에서 완전히 사라졌고, 위저드와 풀 수정 화면에서도 그 필드가 빠졌으며, 마이그레이션 0192가 컬럼을 읽지 않는 것으로 표시한다. 여전히 미배포이고 0192도 미적용이다.

이제 sweep은 분기가 둘이고 모드 검사가 없다. 서로 다른 두 종류의 근거로 같은 질문 하나를 던진다. 지급일을 아무도 정하지 않은 주기가 있는가?

풀 형태근거
상환 계획(redemption_term_epochs가 설정됨)redemption_epochs.funding_date에 대한 차집합이다. 시계가 관여하지 않는다. 목록이 유한하고 완성됐거나 아니거나다
요청 기반 회차달력이다. 행은 그 주기가 와야 생기므로, *"N+1 주기에 날짜가 없다"*와 *"N+1 주기가 아직 없다"*가 테이블에서 구분되지 않는다. 그래서 windowOpen 이전의 리드 구간이 남는 수단이다

🔴 over_funding_detected는 컨트랙트가 하는 일과 정반대를 펀드매니저에게 말한다

이건 계획이 아니라 살아 있는 문구다. 잉여가 남는 정산 뒤에 fundManagers에게 발생하고(lib/money/usecases/epoch.ts) 본문은 이렇게 읽힌다.

"남은 {excessAmount} {currency}는 다음 회차로 이월되므로 조치가 필요 없습니다."catalog/redemption.ts:319-324이고 emailPreheader: 'Excess funding carried to next epoch'"Excess carried" 상세 행이 함께 있다.

두 절이 모두 거짓이고 두 번째가 비싸다. epochFundTopUp은 주기를 키로 하고 정산은 자기 키만 읽는다(RedemptionLib.sol:955). reserveBalance +=는 저장소 전체에서 정확히 한 곳에 있으므로(PoolLedgerLib.sol:73) top-up을 어디로도 옮기는 것이 없다(v3-137). 잉여가 다음 주기에서 쓸 수 없으므로 올바른 안내는 다음 주기를 다시 전액 펀딩해야 한다는 것이고, 이는 *"조치가 필요 없습니다"*의 정확한 반대다. 상환 계획에서는 그 잘못된 안내가 주기마다 반복된다.

이것이 살아남은 경위 두 가지가 수정 자체보다 값어치가 있다.

  • 그 항목 위의 주석이 실제 줄 번호를 인용하면서 틀린 결론까지 논리를 전개한다. 그리고 epoch-redemption.ts에 정정 전까지 앉아 있던 것과 같은 v3-93 C10 오독에 기대고 있다. 이월 동작에 대해 v3-93을 인용하는 다른 모든 것도 같은 근거로 의심해야 한다.
  • catalog-integrity.test.ts:232/carries over/i를 단언하고 있었다. 그래서 그 문장이 테스트로 고정돼 있었다. v3-96의 조작된 *"automatically"*가 리뷰를 통과하게 만든 것과 똑같은 메커니즘이다. 수정하려면 그 단언을 옮겨야 했고, 그게 단언을 두는 이유다.

2026-08-18 수정됐다. 본문이 이제 그 돈을 들고 있는 주기를 지목하고 다음 주기가 그것을 끌어 쓸 수 없다고 말한다. preheader와 Excess carried 상세 행도 함께 바뀌었다. catalog-integrity.test.ts/carries over/i 단언을 잃고, 이월 표현이 돌아오면 실패하는 단언을 얻었다. 수요 쪽 문구까지 휩쓸지 않도록 이 이벤트 키로 범위를 좁혔다. 문구와 단언과 이 페이지가 한 커밋에서 함께 움직였다. 페이지만 고치고 메일을 두는 것이 둘 다 틀린 채로 두는 것보다 나쁘기 때문이다. 그러면 다음 사람에게 결함이 보이지 않게 된다.

⚠️ 이를 "고친다"고 모든 곳의 이월 표현을 지우지 말 것. 미체결 수요는 실제로 이월된다(epochCarryDemandLp, 신규보다 먼저 처리된다). 그렇게 말하는 투자자 대상 문구는 맞다. 이월되지 않는 것은 펀딩이다.

  • 수신자는 ADMIN이고 priority: critical, severity: info다. 잘못된 일이 일어난 게 아니라 창이 곧 닫힌다는 것이다.
  • 중복 제거는 주기별이고 리드 구간이 열린 뒤 생성된 행을 키로 한다. dedupByEntity를 쓰면 첫 주기 이후 모든 주기가 침묵한다.
  • LEAD_DAYS는 7이다. v3-100이 3에서 올렸다. 파트너가 정산일을 확정하기에 3일은 부족하다. 알림 파이프라인 재구축과 함께 반영됐다(2026-07-31).
  • 변수 넷(windowOpensAt, fallbackFundingDate, settleByLatest, poolName)은 코드에 있지만 이 이벤트는 문구 시트에 아직 행이 없다. 현황의 드리프트 주석 참조.

거버넌스 타임락: 시작과 끝, 그리고 아무도 안 보고 있음

NAV 하락은 스스로 적용된다. nav-changes.scheduler.apply-pending이 24시간 타임락이 지나면 applyPendingNavOnChain을 호출하는 예약 lambda다. impairment와 wind-down은 그렇지 않다. executeImpairmentOnChainexecuteWindDownOnChain에는 호출자가 각각 딱 하나씩 있는데 자기 POST 핸들러의 action === 'execute' 분기이고, 두 핸들러 다 HTTP paths:로 등록돼 있지 schedule:이 아니다. 이걸 본 사람들이 다들 반대로 가정했고, 아래 이벤트 넷이 그것을 막으려고 존재한다.

시점이벤트대상무엇을 말하나
타임락 시작impairment_proposed_ops · winddown_proposed_opsopsTeam + fundManagers이의 제기 창이 열렸고 얼마 동안 지속되는지
타임락 종료 임박impairment_execute_due · winddown_execute_dueopsTeam스스로 실행되는 것이 없으니 실행하라

제안(_ops). 이것들이 생기기 전에는 타임락이 시작될 때 투자자 카드가 유일한 푸시 신호일 수 있었다. 그래서 할 일이 있는 두 사람이 통지받지 못했다. 자기 펀드가 대상이고 실행 전에 오프플랫폼으로 이의를 제기하는 것이 유일한 수단인 FM, 그리고 실행을 손으로 돌려야 하는 운영자다. NAV의 제안 단계는 이미 다뤄져 있었고(v3-97 결정 B) 그대로다. fundManagers는 풀 범위다. resolveFundIdpools.fund_id를 읽고 그 펀드의 fund_membersstatus = 'ACTIVE'로 필터링한다. 온체인 tx를 키로 하므로 취소 후 재제안하면 그 자체가 별도 안내가 된다. 다른 모든 _ops 항목과 같이 app: 'admin'이고 /pools/{poolId}를 가리킨다.

🔴 두 기간이 다르고, 문구가 그것을 명시한다

GOVERNANCE_TIMELOCK = 7 days(impairment) · WIND_DOWN_TIMELOCK = 30 days(wind-down)다. PlatformPool.sol:208,211이다. 문구 시트가 둘 다 7이라고 적었고 그것이 그대로 옮겨져서, winddown_proposed_ops가 펀드매니저에게 "7일 타임락이 적용됩니다"를 내보냈다. 23일을 축소한 것이고, 하필 언제 이의를 제기해야 하는지를 말하는 것이 존재 이유인 안내에서 그랬다. 두 프론트엔드는 이미 7/30을 맞게 갖고 있었고 카탈로그만 틀렸다. 이제 각 항목이 자기 기간을 말하고 다른 쪽 기간을 말하지 않는지를 인앱 요약, 인앱 본문, 이메일 본문, preheader에 걸쳐 확인하는 테스트로 고정돼 있다.

이 숫자가 공유 상수 없이 네 군데에 있었다. 이제 백엔드 핸들러 둘은 business/governance-timelock.ts를 import한다. 문구는 여전히 말로 풀어 쓰고 숫자를 import할 수 없는데, 그래서 테스트가 있다. *_execute_due 쌍은 기간을 말하는 대신 계산된 만료 시각을 렌더해서 이 문제를 아예 비켜 간다.

실행 도래. 대기 중인 제안이 있는 풀에 대해 매시간 sweep(pools.scheduler.governance-execute-due)이 돌고, 판단은 business/governance-execute-due.ts에 있다. opsTeam뿐이다. 실행은 운영자의 조작이고, FM의 이해관계는 제안 안내가 이미 준 이의 제기 창이었다. 리드 타임은 impairment가 D-1(7일 창은 짧다), wind-down이 D-3이다.

  • 창에 상한을 두지 않은 것은 의도적이다. 리드 기간만큼만 창을 두면 impairment에서는 크론 한 주기가 되므로, 실행이 한 번 실패하면 그 제안이 받는 유일한 알림이 사라진다. 그리고 아무도 조치하지 않아 만료를 지나쳐 버린 제안이야말로 잡을 가치가 있는 경우다. 한 번만 발동하는 것은 멱등성 키({event}:{poolId}:{proposedAt})가 보장하고, 그 키가 취소 후 재제안 시 다시 무장하기도 한다.
  • 컨트랙트가 이제 거부할 제안은 건너뛴다. executeImpairment가 실행 시점에 lifecycleStatus == ACTIVE를 다시 확인하므로(PlatformPool.sol:1287), 이미 넘어간 풀에서는 버튼이 revert만 낼 수 있다. WIND_DOWN은 둘 다에 대해 종착이다.
  • 아무것도 실행하지 않는다. impairment는 되돌릴 수 없는 상태 변경이고 wind-down은 종착이다. 어느 쪽도 크론이 깨어났다는 이유로 일어나서는 안 된다.
  • ⚠️ pools.impairment_proposed_atwind_down_proposed_at을 읽으므로, 온체인에 직접 올린 제안(멀티시그가 proposeImpairment를 호출한 경우)은 보이지 않는다. ImpairmentProposedWindDownProposed는 인덱서가 감시하지 않는다. 실행 쪽은 이제 감시한다(아래).

제3자 실행이 이제 DB에 도달한다

executeWindDown()에는 role 모디파이어가 없고(PlatformPool.sol:1330) 이는 버그가 아니라 의도다. 문서가 "타임락이 지나면 누구나 Pool.executeWindDown()을 호출할 수 있다"고 명시하고 있고, 이미 승인되고 타임락을 거친 조치를 누구나 촉발할 수 있게 하는 표준 패턴이다. 제안과 취소는 어드민 게이팅이고 실행자는 어떤 파라미터도 바꿀 수 없다. role 모디파이어를 추가하지 말 것. 문서화된 liveness 설계가 깨진다.

빠져 있던 것은 미러였다. 인덱서가 ImpairmentExecutedWindDownExecutedLifecycleStatusChanged도 감시하지 않았고, lifecycle_status = WIND_DOWN의 유일한 라이터가 백엔드 실행 분기였다. 그래서 외부인이 온체인에서 wind-down을 실행해도 DB에서는 풀이 ACTIVE로 읽히고, UI가 컨트랙트는 revert하는 입금을 제안할 수 있었다. v3-92 부류의 결함이고, 무권한 경로가 있으니 가상의 이야기도 아니다. 이제 셋 다 감시한다(indexer/writers/governance.ts).

  • ImpairmentExecutedIMPAIRED가 되고 impairment_proposed_at을 지운다. WindDownExecuted(navPerToken)WIND_DOWN이 되고 블록 타임스탬프에서 wind_down_executed_at을 기록하며 제안 플래그를 지우고 pro-rata NAV를 미러링한다.
  • LifecycleStatusChanged(old, new)가 둘 뒤의 범용 미러이고, 온체인에 바로 올린 setLifecycleStatus에 대한 유일한 신호다. 아무것도 통지하지 않는다. 그건 위의 둘이 담당한다.
  • 둘 다 *_executed*_executed_ops백엔드 핸들러와 정확히 같은 멱등성 키로 발생시킨다. 그것이 중복 제거다. API를 거치면 이벤트 행이 이미 있어서 notify()가 아무 일도 안 하고, 아니면 인덱서가 유일한 발송자다. 로그를 키로 하면 두 번 보내게 된다.
  • ⚠️ is_paused는 의도적으로 건드리지 않는다. 핸들러는 unpause()를 보내고 그것이 성공한 경우에만 컬럼을 써서 이를 해제한다(v3-92). 제3자 실행은 그런 트랜잭션을 보내지 않으므로 풀은 여전히 온체인에서 paused()이고 미러도 계속 그렇게 말해야 한다.
  • ⚠️ 제3자 경로에는 pool_updates 피드 항목이 없다. createSystemPoolUpdate에는 멱등성 키가 없어서 인덱서에서 호출하면 백엔드 경로가 돌 때마다 중복이 쌓인다. 소식은 알림이 나르고, 지속되는 피드 항목이 남은 공백이다.

동결 알림 (v3-97)

v3-28의 동결은 비대칭이라 "동결됨" 안내 하나로는 서술할 수 없다. 입금은 동결 내내 막히고, 출금과 수익 청구는 첫 72시간만 막히며(FREEZE_EXIT_WINDOW), 동결 전체가 7일에 자동 만료된다(FREEZE_MAX_DURATION). 트랜잭션도 이벤트도 없이 그렇게 된다.

시점이벤트트리거구현
동결 시작pool_frozenEmergencyFrozen(인덱서 writers/freeze.ts)
+72시간, 가치 유출 재개freeze_exit_window_open시간 기반 sweep(온체인 이벤트 없음)pools.scheduler.lifecycle
+7일, 동결 자동 만료freeze_expired시간 기반 sweep(온체인 이벤트 없음)✅ 같은 sweep
수동 조기 해제pool_unfrozenEmergencyUnfrozen(인덱서)

두 날짜를 처음에 다 밝히고 각 시점마다 안내한다(v3-97). pool_frozen이 두 시각을 다 말해서 투자자가 짐작하게 두지 않고, sweep 이벤트 둘이 각 재개를 확인해 준다. 투자자가 달리 관측할 수 없는 재개다. 둘 다 온체인에서 아무것도 발생시키지 않기 때문이다. freeze_expiredpool_unfrozen과 구분해 둬서 "자동으로 해제됨"과 "운영자가 해제함"이 구분되게 한다(행위자 출처).

  • 자동 만료 sweep이 이제 존재한다. pools.scheduler.lifecycleis_emergency_frozen = true인 풀을 골라 7일 시점에 플래그를 지우고 freeze_expired를 큐에 넣으며, 72시간이 지났는데 여전히 동결인 풀에 대해서는 따로 freeze_exit_window_open을 큐에 넣는다. 이 절이 예전에 막혀 있던 실제 결함이 그렇게 닫혔다. 만료된 동결이 더 이상 입금을 잠근 채로 두지 않는다. 72시간 비대칭 자체는 한 곳에서 유도한다(business/freeze-window.ts). 그전에는 호출 지점 셋이 각자 frozen && now < start + 72h를 다시 구현하면서 실패 처리도 제각각이었다.
  • 중복 제거는 풀이 아니라 동결 주기를 키로 한다. (event_type, related_entity_id = pool, created_at >= freeze_started_at)이고 partner-funding.ts의 패턴이다. queueNotificationdedupByEntity쓰면 안 된다. 그건 풀당 영원히 한 번이라 이후의 모든 동결을 침묵시킨다.
  • sweep 주기가 안내 지연을 정한다. 매시간이므로 1시간 이내다.
  • 이 안내들은 formatDateTime()(UTC 명시)을 쓴다. formatDate()YYYY-MM-DD로 잘라서 72시간 시점을 표현할 수 없다. 날짜만 읽은 투자자가 02:00에 열리는 창에 09:00에 시도하게 된다. 이메일에는 브라우저가 없으므로 프론트엔드의 milestoneLabel()처럼 현지화할 수도 없다.

API도 이제 자기가 설명하는 72시간 창을 강제한다

POST /yield-claims가 유일하게 가드가 없는 계층이었다. 수익 청구는 가치 유출이라 YieldLib.claimYieldPoolCommonLib.checkExitNotBlocked가 첫 72시간 동안 거부한다. 그런데 이 엔드포인트는 청구를 기록만 하므로(트랜잭션은 투자자 지갑이 보낸다) 여기 게이트가 없어도 돈이 움직이지 않아 무해해 보였다. 실제로 한 일은 체인이 절대 정산할 수 없는 청구에 대해 PENDING yield_claims 행을 쓰는 것이었고, 결코 오지 않을 YieldClaimed 이벤트를 기다리는 유령을 남겼다. 웹 앱은 이미 모든 진입점을 게이팅하고 있었고(isExitFrozen) 이제 API도 그렇다. 의도적으로 동결에만 적용한다. is_paused는 자본 유입만 막고 IMPAIREDWIND_DOWN은 가치 유출을 열어 두므로, 어느 것도 청구를 막아서는 안 된다.

freeze_extended는 폐기됐고 온체인 연장도 마찬가지다

v3-97 표에는 예전에 "동결 재설정" 행이 있었다. 그 뒤 FreezeExtended 이벤트가 컨트랙트에서 제거됐다. 7일 타임락이 7일 동결 수명과 같아서, 연장은 동결이 이미 끝난 뒤에야 들어오거나, 소급해서 풀을 다시 잠그고 72시간 이탈 차단을 재시작할 수밖에 없었다. 후자는 v3-28이 막으려는 바로 그것이다. 이메일도 함께 사라졌고, 어차피 틀려 있었다. Until 행이 새 시작 날짜를 렌더했다.

이메일: Direction B 템플릿

브랜드 템플릿 하나(renderDirectionBEmail)가 이벤트별 슬롯을 채운다. 심각도 강조 바, eyebrow 칩, 히어로(자금 이벤트는 금액, 상태 이벤트는 상태 단어), 2칸 상세 카드, 브랜드 퍼플 CTA, 로고 헤더다. 모든 이메일(알림, 펀드 초대, 이메일 인증)이 이를 쓴다.

로고: 프로덕션은 EMAIL_LOGO_URL로 호스팅된 PNG를 쓴다(Gmail이 인라인 SVG를 막는다). 없으면 텍스트 워드마크로 폴백한다.

투자자 이메일 등록과 인증

지갑 로그인 투자자는 합성 자리표시 이메일(<address>@wallet.aset.io)을 갖고 있으므로 실제 주소를 등록하고 인증해야 한다. 법적으로 요구되는 중대 안내에는 도달 가능한 채널이 필요하다. 그래서 이것은 선택이 아니라 컴플라이언스 요건이다.

  1. 투자자가 설정에서 이메일을 입력하면 POST /users/me/emailpending_email과 일회용 토큰(24시간)을 저장하고 인증 메일(Verify CTA)을 보낸다.
  2. 링크가 /verify-email?token=…을 열고 POST /auth/verify-email(공개, 토큰 게이팅)로 간다. 성공하면 email = pending_email, email_verified_at = now가 된다.
  3. 그다음부터 알림이 인증된 이메일로 간다. 이메일을 바꾸면 다시 인증을 거친다. 새 주소가 확인될 때까지 이전 인증 주소가 계속 수신한다.
  4. 발송 워커는 합성 @wallet.aset.io 주소를 건너뛴다. 투자자 이메일은 실제 주소가 인증돼야 발송되고, 인앱 행은 언제나 전달된다.

법무 후속 사항(미해결)

(a) 중대 안내의 기준과 기한(NAV % / T+N), (b) 투자 전에 인증된 이메일을 요구할지, (c) 중대 안내에 입증 가능한 전달이 필요한지(SES 전달 이벤트 / SNS). 이메일이 채택되기 전까지 투자자 중대 안내는 인앱 전용이고, 법적 통지로는 충분하지 않다.

어드민 알림 센터(모델 A)

admin-web의 벨과 피드이고 어드민·운영자·FM이 쓰며 payload.in_app을 렌더한다.

  • 모델 A(대상 공유 피드): 어드민과 운영자가 ADMIN 피드를 공유하고, FM은 자기 펀드의 FM 행만 받는다(엄격한 펀드 범위). read_at이 행별이므로 읽음은 팀 공유다(운영 작업 큐 모델). 사람별 읽음 상태(notification_reads 테이블)는 V2 개선 항목이다.
  • 엔드포인트: GET /admin/notifications(그리고 /unread-count, PATCH /{id}/read, /read-all)이고 resolveAdminFeedScope가 FM 펀드 격리를 강제한다.

환경설정

행이 없으면 이메일이 켜져 있는 것이다(옵트아웃 모델). category가 이제 두 대상 모두에 대해 문구 레지스트리의 event_key를 담는다. 강제를 가능하게 만든 네임스페이스 통일이다(마이그레이션 0101 어드민, 0108 투자자).

투자자: GET/PUT /users/me/notification-preferences이고 2026-07-30부터 강제된다(마이그레이션 0108). 카탈로그는 이벤트 카탈로그에서 파생되고(preferences/catalog.tsapp: 'web' 항목), 패널은 API에서 렌더하며, decideChannels()가 생산 시점에 이를 참조한다. 생산자가 없는 항목은 비활성으로 보여 주는 대신 투자자용 목록에서 아예 걸러 낸다. 오지 않을 메시지를 약속하는 것이 침묵보다 나쁘고, 그래서 페이지가 더 이상 "LP Tokens Issued"를 제공할 수 없다.

  • 0108 이전에 패널은 지어낸 분류 둘을 썼다. **NAV_UPDATE**는 이벤트 키도 카탈로그 분류도 아니고(가장 가까운 실제 이벤트 둘이 모두 critical이라 키를 제대로 맞췄어도 끌 수 없었을 것이다), **POOL_PERFORMANCE**는 무기력한 것보다 나빴다. 주간 성과 이벤트라는 것이 어디에도 없으므로 만들어진 적 없는 기능을 스위치로 광고한 셈이다. 둘 다 이벤트 키로 번역할 수 없어서 0108이 마이그레이션하지 않고 행을 삭제한다. 과거 발송에 대해서는 아무것도 달라지지 않는다. 그전에는 워커가 투자자 환경설정을 읽은 적이 없다.
  • 현재 토글 가능한 투자자 이벤트는 pool_lifecycle_active뿐이고, 그래서 게이트를 관측할 수 있게 됐다. 다른 모든 투자자 이벤트는 critical이고 켜진 채 잠겨 있다.

어드민 / 운영자 / FM: GET/PUT /admin/me/notification-preferences이고 2026-07-29부터 강제된다.

  • 게이트는 매핑 계층 없이 notification_preferences.category === notification_events.event_key다. 토글 가능한 집합은 카탈로그에서 파생되고(preferences/catalog.tspriority: 'optional'인 어드민 앱 이벤트), critical 키는 임의로 바꾸지 않고 쓰기에서 거부한다. 패널도 GET 응답의 events를 통해 같은 카탈로그에서 토글을 렌더한다.
  • 0110 이후 생산 시점에 강제된다. 팬아웃이 사람당 행 하나를 쓰므로 채널이 선택되는 곳에서 환경설정이 적용된다. 수신 거부는 발송 행 자체가 없는 것을 뜻하고, 그래서 실패 집계에 나타날 수 없으며 SUPPRESSED가 폐기된 이유이기도 하다. 옛 모델은 ADMIN 행이 발송 시점에야 대상이 해석되는 브로드캐스트였기 때문에 이를 워커로 미룰 수밖에 없었다. 환경설정 조회 실패는 여전히 열린 채로 실패한다(원치 않는 이메일이 놓친 알림보다 낫다).
  • ADMIN 행과 FM 행 둘 다 게이팅된다. 여러 optional 이벤트가 대상별로 행을 하나씩 쓰므로(redemption_requested, *_ops 안내들), ADMIN 행만 게이팅하면 FM의 스위치가 무기력해진다. ADMIN 행은 admin_users.id로 환경설정을 매칭하고, FM 행은 소문자로 바꾼 주소로 매칭한다. fund_members가 이메일을 담고 admin_users로의 FK가 없기 때문이다. FM의 fund_members.email이 어드민 계정 이메일과 다르면 그 사람의 수신 거부가 FM 행에는 조용히 적용되지 않는다(열린 실패).
  • recipient_name이 설정된 ADMIN 행은 그 주소로만 간다. ADMIN 행의 recipient_id는 사람이 아니라 관련 엔티티이므로, 특정 개인을 뜻하는 생산자는 그 주소를 recipient_name으로 넘긴다. 워커가 이를 존중하기 전까지 그런 행이 팀 브로드캐스트로 흘러갔다. admin_user_changed("귀하의 role과 권한이 변경됐습니다")가 권한이 바뀐 당사자가 아니라 모든 어드민과 운영자에게 발송됐다. 그래서 어드민 이벤트에 2인칭 문구를 쓰려면 recipient_name이 필요하다. 인앱 행은 영향이 없다. recipient_type='ADMIN' 행은 공유 운영 피드이므로(위의 모델 A), 이메일이 한 사람 앞으로 가더라도 인앱 요약은 3인칭을 유지한다(fund_member_changed가 그렇게 쓰여 있다).
  • 이메일만 해당된다. 인앱 행은 생산자가 insert하고 절대 게이팅되지 않는다. My Settings의 "인앱: 항상 켜짐"은 정확한 서술이다.
  • 대상 전원이 수신 거부한 경우 행은 FAILEDINVALID_RECIPIENT가 아니라 **SUPPRESSED**로 표시된다(0100). 실패한 것이 없고 FAILED는 운영 큐로 흘러가기 때문이다. 재발송은 FAILED를 요구하므로, 차단된 행을 수신 거부를 넘어 강제로 메일 보낼 수 없다.
  • ⚠️ 그전에는 어드민 패널이 지어낸 분류 이름 13개(new_redemption, deposit_anomaly 등)를 썼다. 어떤 이벤트도 그 이름을 쓰지 않았고 어떤 발송 경로도 읽지 않았다. 토글은 저장되고 스위치에 다시 반영됐지만 모든 이메일은 그대로 나갔다. 마이그레이션 0101이 그 행들을 삭제한다. 설정 전용 분류 체계를 다시 만들지 말 것. 실제 event_key를 키로 하지 않는 토글은 강제될 수 없고, 그 실패는 조용하다.
  • 카탈로그에 있지만 생산자가 없는 이벤트는 패널에 emitted: false로 전달돼 살아 있는 컨트롤이 아니라 비활성("Not yet active")으로 렌더된다. 어드민 쪽에는 **lp_mint_failed**만 남아 있고 삭제 예정이다(v3-103).
  • fund_id가 없는 풀의 FM 행은 아무에게도 닿을 수 없다. 구성원을 해석할 펀드가 없어서 그 행이 SENDING으로 영원히 앉아 있고 쫓을 사람도 없다. 워커는 이벤트를 새로 만들지 않고 기존 fm_notification_failed 알림에 이를 흡수한다(failureType: 'NO_FUND'). 여기서 dedupByEntity가 안전한 이유는 그 막힌 행 자체를 키로 하기 때문이다. sweep마다가 아니라 이 고아에 대해 알림 하나다. 알림에 대한 알림을 보내지는 않는다.
  • fund_member_changed는 의도적으로 두 관점으로 쓰인다(v3-103). 이메일은 2인칭으로 권한이 바뀐 구성원에게 가고(recipient_name으로 라우팅된다), 인앱 요약은 3인칭이다. {memberName} is now {role} on {fundName} 형태인데, 모델 A에서 ADMIN 유형 행은 개인 수신함이 아니라 공유 운영 피드이기 때문이다. 정리해야 할 불일치가 아니다.

large_deposit 임계값

large_deposit은 입금이 platform_config.largeDepositThreshold를 넘을 때 deposits.post.create에서 발생한다(PUT /admin/settings, admin-web의 Platform Configuration). 기본값도 폴백 상수도 없다. 설정이 비어 있는 동안에는 이 알림이 아예 발동하지 않는다. "크다"는 것은 그 펀드의 딜 규모에 달려 있으므로 어드민이 정한다. 옛 My Settings 패널은 대신 하드코딩된 > $100,000을 표시했는데, 그건 useState 기본값에서 온 것이고 서버 쪽 어떤 것에도 대응하지 않았다. 문구("설정된 알림 임계값을 초과했습니다")에 맞춰 초과할 때만 발동한다. ADMIN 전용이다. 투자자와 FM은 같은 입금에 대해 이미 deposit_confirmed / deposit_confirmed_ops를 받는다. 개별 어드민은 My Settings에서 이메일을 끌 수 있다.

스키마

테이블담는 것
notification_events발생당 행 하나다. event_key, idempotency_key(UNIQUE), 배치 variables, subject_type/_id
notifications(이벤트, 사람)당 행 하나다. recipient_kind/_id, 수신자별 variables, 렌더된 in_app, category, read_at. 상태가 없다. 수신함 항목은 있거나 없다
notification_deliveries(알림, 채널)당 행 하나다. destination, status, attempts, next_attempt_at, provider_message_id, failure_type
email_suppressionsSES가 하드 바운스나 불만으로 보고한 주소들
notification_preferences그대로다. (user_type, user_id, category)이고 category가 이벤트 키다
dashboard_alert_counts.failed_notificationsfailure_type = 'INVALID_RECIPIENT'를 제외한다(마이그레이션 0107)
pool_follows(PK user_id,pool_id)pool_lifecycle_active 뒤의 옵트인 목록(마이그레이션 0109)
users.email_verified_at · pending_email · email_verification_token · email_verification_expires_at투자자 이메일 인증(마이그레이션 0033)

전체 컬럼 목록은 11-db-schema에 있다. 마이그레이션 0110이 이 테이블들을 만들고 notification_logs, notification_status, recipient_type을 삭제한다. 옛 행은 마이그레이션하지 않는다(출시 전 운영 이력이고, 병행 레거시 테이블을 유지하면 이 분리가 없앤 모호함을 그대로 보존하게 된다).

발송 상태

delivery_statusPENDING | SENT | BOUNCED | COMPLAINED | FAILED | SKIPPED다.

SKIPPED는 실패가 아니다(시도 자체가 없었다). BOUNCED는 운영자가 아니라 차단 목록이 처리한다. 그래서 어드민의 실패 알림 KPI는 FAILED만 센다. 옛 집계에서 INVALID_RECIPIENT를 예외 처리해야 했던 마이그레이션(0107)을 이것이 대체한다.

API 호환성

읽기 엔드포인트는 새 테이블에서 다시 만들되 늘 반환하던 형태를 의도적으로 그대로 반환한다(notifications/feed.ts, notifications/delivery-log.ts). 저장 방식 변경이 클라이언트 변경이 되면 안 된다. 결과가 둘이다.

  • 피드 행은 status: 'DELIVERED'를 보고한다. 피드를 읽는 사람에게 그 값이 뜻하던 것("이 알림은 당신 것이다")이 그것이다. 실제 결과는 발송 쪽에 있다.
  • 운영 로그는 실제 발송 상태를 번역 없이 보고한다. 반송과 불만을 FAILED로 뭉치는 것이 첫 시도였고 틀렸다. 운영 UI가 상태를 보고 Resend 버튼을 게이팅하는데 FAILED만 재발송 가능하므로, 반송을 FAILED로 보여 주면 API가 거부하는 재시도를 제안하게 된다. NotificationLog 위젯은 상태 어휘를 props로 받으므로, 여섯 값이 축약되지 않고 주입된다.

현황

배포되고 검증됐다. dev는 2026-07-31부터 0110을 돌리고 있고, infra tsc 통과, 196/196, 문구·생산자·단위 가드 모두 통과다. 반송 경로는 실제 SES에 대고 확인했다. 반송과 불만 각각이 발송 → SNS → webhook을 4초 안에 왕복했고, provider_message_id로 대조됐으며, 올바른 사유로 주소를 차단했다. 그 피드백을 나르는 이벤트 대상은 이제 설정 세트 옆의 SesStack에 있다. ApiStack에 만들어져 있던 동안에는 환경별 수동 단계였고, dev는 완전히 배포된 것처럼 보이는 파이프라인 뒤에서 모든 반송을 버리고 있었다(v3-108).

미해결: 코드

  • redemption_fundedlp_mint_failed(그리고 lp_minted)는 문구는 있는데 생산자가 없다. catalog/index.tsUNEMITTED에 선언돼 있고 로드 시점 assert와 생산자 가드가 둘 다 이를 확인하므로 조용히 반쯤 살아 있을 수는 없다. 다만 시트가 이미 그중 둘을 뺐으므로 항목도 따라가야 한다.
  • epoch-execute가 여전히 MATURED 풀을 후보 집합에 넣는다(epoch_duration_days > 0, pool_address, deleted_at으로 필터링하지만 lifecycle_status는 보지 않는다). 무해한 상태가 실패로 알림을 낸다. 그 경로가 알림을 내는 대신 건너뛰어야 하는지는 미정이다.
  • epoch_funding_date_dueLEAD_DAYS를 3에서 7로.
  • 이벤트 넷이 deepLink에서 {poolId}를 치환하면서 variables에 선언하지 않는다(fm_shortfall, yield_distribution_due, yield_distribution_overdue, yield_distribution_escalation). 생산자가 실제로 넘기고 있으므로 깨진 CTA는 없다. 빠진 것은 선언이고, 그 사실을 읽는 유일한 것이 현재 그것을 찾아내는 카탈로그 테스트다.

미해결: 문구·제품

  • 시트가 이벤트 12개에서 코드보다 뒤처져 있다. 기준은 코드이므로(v3-103) 시트 쪽으로 동기화하는 일이다. 가장 중요한 것은 redemption_rejected인데, 시트 행이 상세 항목에 여전히 {reason}을 담고 있다. 그건 운영자의 내부 메모이고, 그것을 렌더하는 것은 PII 유출이자 tipping-off 위험이다. 오래 갈 해법은 두 벌을 손으로 고치는 대신 카탈로그에서 시트를 생성하는 것이다.
  • redemption_exit_gate_blockedepoch_funding_date_due는 아직 시트 행이 없다(둘 다 코드에는 살아 있다).
  • epoch_demand_finalizedover_funding_detected. v3-103은 이 둘이 회차 머지로 막힘이 풀렸다고 기록했고 v3-100이 초과 펀딩 동작을 정리했는데, 재구축 항목(v3-108)은 여전히 수요 스냅샷에 막혀 있다고 서술한다. 둘 중 무엇을 만들기 전에 이 불일치를 정리할 것. 이 다툼은 안내 시점에 지급 총액이 확정됐는지에 대한 것이고, 그게 이 이벤트들이 보류된 이유 전부다. ⚠️ 그럼에도 over_funding_detected는 이미 만들어졌고(카탈로그 항목과 정산 후 발동하는 lib/money/usecases/epoch.ts의 생산자) 본문이 이월 동작을 거꾸로 말한다. 위의 문구 결함 참조.
  • em dash: em dash 금지 규칙이 여기에도 적용된다(v3-103). 대체는 공백을 둔 하이픈이다. 시트는 이미 전환됐고 코드 정리는 남아 있으며 이제 copy.ts가 아니라 catalog/를 대상으로 한다. 카탈로그 파일 여덟 개에 em dash가 102개 남아 있고(pool.ts 25개, redemption.ts 34개가 대부분이다) 손질이 아니라 실제 작업이다.
  • 재구축 중에 시트와 코드의 수신자 불일치 넷이 발견됐고 의도적으로 그대로 뒀다(fm_shortfall, holdback_release_deferred(0183에서 hold-back과 함께 제거됨), yield_distribution_escalation, nav_change_*_ops). 구조적 리팩터링 중에 이메일 수신자를 바꾸는 것은 잘못된 위험이고, 그중 하나는 reserve 부족에 대한 어드민 가시성을 없앨 뻔했다.
  • 불만을 차단 해제할 수 있는지는 미정이다. 관리 엔드포인트는 지금 그것을 허용한다. 불만은 메일 제공자가 실제로 반영하는 동의 철회이므로, 지원 요청으로 그것을 되돌리는 것이 우리 권한이 아닐 수 있다.
  • redemption_rejected / redemption_returned_unfunded 문자열은 법무 승인 대기 중이다(Notion "Legal Review Required" #25). 분기, 변수, failure_type 라우팅은 확정이고 문자열만 바뀔 수 있다.

의도적으로 미룬 것: 운영자 범위 분리(어드민과 운영자가 모두 opsTeam 대상에 있다. 각자 자기 행과 읽음 상태를 가지므로 공유 피드는 없어졌고, 남은 것은 별도 대상을 두는 일이다) · Telegram/Slack · 1시간 optional 다이제스트 · SES DELIVERY 이벤트(SENT가 이미 접수를 기록하고, 전달 이벤트는 어떤 운영자도 조치하지 않는 쓰기를 이메일마다 추가할 뿐이다) · 이메일 인증 아래의 법무 항목들.