# Social Recording Process — Service Overview

> 이 레포에는 프로젝트 전역 Service Overview 문서가 없어(root `README.md` 는 NestJS 스타터 기본형),
> 본 문서는 **Social Recording Process 기능 범위로 스코프한 as-built 요약**이다. 향후 전역 overview 가
> 도입되면 아래 내용을 그대로 이관한다. (T-005)
>
> 같은 성격의 선행 문서:
> [social-schema-definition/service-overview.md](../../docs/features/social-schema-definition/service-overview.md) ·
> [social-sync-pipeline/service-overview.md](../../docs/features/social-sync-pipeline/service-overview.md)

관련 문서: [be-system-design.md](../../docs/features/social-recording-process/be-system-design.md) · [index.html](../../docs/features/social-recording-process/index.html) ·
[tasks/overview.md](../../docs/features/social-recording-process/tasks/overview.md)

> **스키마 정체성 축이 바뀌었다 (2026-08-05).** `accounts`·`venues` 에서 `source_urls` 가 제거되고
> 정체성이 `platform_key` 하나로 통일됐다. `contents` 의 URL 유니크는 일반 인덱스로 강등됐다.
> 선행 문서(`social-schema-definition`)의 "`source_urls[]` 가 기존 행 찾기의 1차 수단이다(D-8)" 기술은
> **`contents` 에만** 유효하다.

---

## Key Features

- **AF 3명 이상 토큰의 소셜 기록 (토큰당 1회)** — `token.flow` 이벤트를 받아 그 토큰에 걸린 소셜 URL 을
  해석하고 `contents`·`accounts`·`venues`·`token_links`·`metric_series` 에 기록한다.
  (`object_raw` 는 2026-08-07 제거됐다 — 근거는 `docs/features/social-generator/decisions.md` G-8·G-12.)
  선행 기능이 만든 v4 스키마의 **최초 프로덕션 writer** 다.
  - **판정은 딱 하나다** — `totalInvestedAlphaFinders >= 5`(코드 상수 `AF_INVESTOR_THRESHOLD`,
    2026-08-13 에 3 → 5). 고빈도 스트림이라 이 필터가 없으면
    업스트림 조회가 유입량만큼 그대로 나간다. 판정은 `TokenSocialRecordHandler` 가 소유하고,
    저장·순서·외부호출 지식은 갖지 않는다.
  - **소셜 URL 은 이벤트에 없다** — `website`·`twitter`·`telegram`·`discord` 는 `SolTrackerApiSdk` 로
    받아온다. 그래서 이 기능이 그 SDK 의 첫 상시 소비자이며, SDK 주석이 "상시 소비자가 생기면 다시
    판단한다" 고 적어둔 서킷 브레이커 결정이 여기서 정산됐다(§4 E-1: 미도입 + timeout 30초 → 5초).
  - **저장 필드 이름을 판정에 쓰지 않는다** — 실데이터에서 `twitter` 필드에 TikTok URL 이,
    `website` 필드에 YouTube URL 이 들어온다. `SocialFetcherRouter` 가 URL 내용(host+path)으로만 분류하고,
    중복 제거는 정규화된 canonical URL 기준이다.
  - **순서가 곧 보장이다** — 락 → 멱등 판정 → 업스트림 조회 → URL 루프(순차) → 링크 append →
    **마커(`tokens`)는 가장 마지막**. 마커를 먼저 쓰면 중간 실패한 토큰이 영구 스킵되고, 마지막에 쓰면
    같은 토큰의 다음 flow 이벤트가 사실상 재시도가 된다(§6.5 F-4). URL 상태와 마커는 `tokens`
    한 번의 쓰기로 함께 나간다(R-002).
  - **실패해도 관측은 남는다 — 단 행은 안 만든다** — 게이트에 막혔거나 호출이 실패한 URL 은
    `tokens.social_urls[]` 에 사유·시각·시도횟수로만 남는다. "어떤 토큰이 이 URL 을 걸었다" 는
    fetch 성공 여부와 무관한 사실이지만, 그걸 `contents` 자리행과 `token_links` 로 표현하면
    "링크가 있다" 가 "수집됐다" 를 뜻하지 않게 되고 자리행이 승격되지 않는다(R-002).
    `SOURCE_TYPE_OBJECT` 상수가 응답 없이도 `{object, platform, subtype}` 을 주므로 이 경로엔
    소셜 지식이 필요 없다.
  - **부분 실패는 URL 단위로 격리된다** — 한 URL 이 던져도 나머지는 계속 처리되고, 마커는 그래도
    남는다(§4 F-3). ~~실패분은 백필 스코프 전까지 회수되지 않는다.~~
    **2026-08-10 부터 회수된다** — `POST /internal/social-recording/retry` 가 그 창구다(아래 참조).
  - **유료 게이트는 기본이 꺼져 있다 (E-3)** — TikTok·Instagram·Reddit 은 호출당 과금이라
    `enablePaid=false` 로 시작한다. ~~대가는 그 기간에 들어온 토큰의 해당 소셜 데이터가 **영구히 비는** 것이다.~~
    **2026-08-10 부터 영구가 아니다** — 그 URL 들은 `skipped_paid` 로 남고, 유료를 켠 뒤 `/retry` 가
    가져간다. `skipped_paid` 는 외부 호출이 0회라 `attempts` 가 오르지 않고, 따라서 상한(`exhausted`)에
    걸리지도 않는다 — 켜질 때까지 영원히 재시도 대상으로 남는 것이 맞다.
    `CREDIT_EXHAUSTED` 를 만나면 그 이벤트의 남은 유료 URL만 건너뛴다 — 전역으로 끄면
    다시 켤 경로가 없기 때문이다(E-4).
  - **Generator 는 인터페이스까지다** — `SocialGenerator.generate(routed, opts, observedAt)` 하나만
    노출하고, 소셜별 구현 8종은 draft Non-Scope 로 다음 작업이다. 그래서 지금 파이프라인을 돌리면
    모든 URL 이 "담당 Generator 없음" 으로 `tokens.social_urls[].status = unsupported` 에만 남는다 —
    **배선은 완성이고 수집은 미완성**이다.
  - **참조는 저장 순서가 만든다** — Generator 는 저장 전이라 `_id` 를 모르므로 참조를 지역 `ref`
    로만 표현한다. `SocialGraphWriter` 가 묶음을 `parentRef` 로 위상 정렬해 인용 원본을 먼저
    저장하고, 묶음 안에서는 `creator → venue → content` 순서로 `creator_id`·`venue_id`·
    `parent_content_id` 를 **생성 시점에** 채운다.
  - **기존 행은 갱신하지 않는다** — draft 요구 "이미 기록된 경우에는 업데이트하지 않고 link 만".
    `contents.source_urls` 누적만 예외다. **2026-08-13 에 이것이 최종 정책으로 확정됐다** —
    재관측 갱신은 0건이 정답이고, 변하는 값(`handles`·`outbound_urls`·`bio`·`subtype`·지표)은
    전부 주기성 갱신 프로세스가 맡는다(v5 §7 · R-009 확장).
  - **게이트 규칙이 한 벌이다** — `SocialFetchGate` 를 기존 임시 어댑터(`SocialFetcherService`)에서
    추출해 양쪽이 공유한다. 두 벌로 두면 "reddit 을 무료로 되돌린다" 같은 변경이 한쪽에만 반영된다.

- **과거 미기록 토큰 수동 sync (2026-08-10 추가)** — `POST /internal/social-recording/reconcile`.
  스트림은 **살아 있는 이벤트만** 보므로 지나간 토큰은 큐에 남아 있지 않다. 기간을 주면 업스트림
  `GET /api/v1/tokens` 로 그 구간의 토큰 목록을 받아, 우리 DB 에 **없는 것만** 골라 기존 `record()` 를 돌린다.
  - **기간 축은 업스트림의 `last_transfer_at`(마지막 거래 시각)이다** — 토큰 생성 시각도, 우리가 처음 본
    시각(`created_at`)도 아니다. 오래된 토큰이라도 최근 거래가 있으면 최근 범위에 잡힌다.
  - **멱등 판정은 `findExistingAddresses` 단일 쿼리다.** 주소 N개를 `$in` 하나로 물어 차집합을 만든다.
    진행 상태를 담는 작업 컬렉션이 없는 이유가 이것이다 — **진행 상태는 `tokens` 문서의 존재 그 자체**이고,
    처리된 토큰은 다음 호출의 차집합에서 저절로 빠진다.
  - **응답의 `remaining > 0` 이 "같은 파라미터로 다시 부르라" 는 신호다.** 한 번에 다 끝내지 않고
    `limit`(토큰 수)과 `deadlineMs`(시간)로 끊는다. 중단 판정은 **토큰과 토큰 사이에서만** 한다 —
    토큰은 원자 단위라 중간에 자르면 마커 없이 일부 URL 만 기록되고, 다음 호출이 그 토큰을 통째로
    다시 돌며 이미 성공한 URL 에 외부 호출을 다시 낸다.

- **미수렴 URL 재시도 (2026-08-10 추가)** — `POST /internal/social-recording/retry`.
  스트림은 토큰당 1회 처리하고 돌아오지 않으므로, 한 번 실패한 URL 은 스스로 회복되지 않았다(H-009).
  - **멱등의 단위가 토큰이 아니라 URL 이다.** reconcile 이 토큰 축, retry 가 URL 축이고
    **어느 한쪽만으로는 수렴하지 않는다** — 둘이 함께여야 "그 시점에 처리 가능한 최대 집합" 에 도달한다.
  - **업스트림 호출이 0회다.** 대상 URL·상태·시도 횟수·심볼이 전부 우리 `tokens` 문서 안에 있다.
  - **빈 body 로 부르면 기본값이 적용되고, 그것이 곧 크론 동작이다.** 그래서 크론 전용 엔드포인트를
    만들지 않았다 — 운영자도 스케줄러도 같은 라우트를 부른다.
    > ⚠️ **2026-08-13 정정(`9cdf1c2`).** 원래 이 줄은 «이 레포에는 `@nestjs/schedule` 도 `@Cron`
    > 도 없고, 외부 스케줄러가 HTTP 로 부른다» 였다. 지금은 `SocialRetryCron` 이 인프로세스
    > `@Cron(EVERY_HOUR)` 으로 돌며 서비스를 직접 부른다. 크론 전용 **엔드포인트**가 없다는
    > 것만 그대로다.
  - **URL 은 저장된 분류가 아니라 `route()` 로 다시 분류한다.** 분류 규칙의 버그를 고쳐도 이미 잘못
    분류된 URL 에는 영영 닿지 않기 때문이다(v5 T-M1).
  - **링크는 append 가 아니라 교체다.** 재시도 전에 그 `(token_address, entry_url)` 의 기존 링크를
    지우고 다시 넣는다 — 유니크 인덱스는 재관측 이력을 구조적으로 없애므로 쓰지 않았다(H-013).

- **두 창구가 공유하는 안전장치 (2026-08-10)**
  - **락이 2층이다** — 작업 락(`social-reconcile:{reconcile|retry}`)이 *같은 종류의 배치가 겹치는 것*을,
    토큰 락(`social-record:{address}`)이 *배치와 실시간 스트림이 같은 토큰을 건드리는 것*을 막는다.
    **둘 다 `tryLock` 이다** — 기다리지 않는다. 이미 도는 배치가 그 후보들을 이미 처리하고 있기 때문이다.
  - **락 경합은 에러가 아니다** — 작업 락 미획득은 `200` + `meta.skipped`(`409` 아님, 스케줄러가 정상
    겹침을 장애로 읽으면 안 된다), 토큰 락 미획득은 그 URL 하나만 건너뛴다.
  - **`ApiKeyGuard` — 이 레포 최초의 guard.** 두 엔드포인트가 유료 외부 호출을 유발하므로 무인증으로
    두면 `app.module.ts` 가 경고하는 오픈 프록시 모양에 청구서가 붙는다. `SolTrackerTestApiModule` 이
    쓰는 "prod 에서 모듈 제외" 대안은 쓸 수 없다 — reconcile 은 애초에 프로덕션 데이터를 채우려고 존재한다.
    설계는 이것을 **조건부 판정**으로 남겼었다 — "네트워크 경계가 더 강하고 앱 코드가 0줄이므로,
    가드는 **엔드포인트가 공개 인터넷에 노출될 때만** 옳다"(§2.3). **2026-08-10 에 공개 노출로
    확정돼 도입했다.** 배포 경계가 내부망 전용으로 바뀌면 이 가드는 순수 추가분이 되어 걷어낼 수 있다.

---

## API Overview

| Method | Path | 설명 | 인증 |
|---|---|---|---|
| POST | `/api/v1/internal/social-recording/reconcile` | 기간 범위의 미기록 토큰을 기록한다 (토큰 축) | `x-api-key` |
| POST | `/api/v1/internal/social-recording/retry` | 수렴하지 않은 URL 을 다시 연다 (URL 축) · **빈 body = 크론 동작** | `x-api-key` |

두 endpoint 는 2026-08-10 에 추가됐다. **상시 진입점은 여전히 HTTP 가 아니라 메시지 큐**이고,
위 둘은 스트림이 구조적으로 할 수 없는 일(지나간 토큰 · 재시도)만 맡는 운영 창구다.

| 항목 | 값 |
|---|---|
| Exchange | `solTokenFlowTopicExchange` (producer 소유) |
| Routing key | `token.flow` |
| Queue | `af-social-scanner.token-flow.social-sync[.{env}]` — prod 만 접미사 없음 |
| 배달 보장 | at-least-once · 중복 허용 · **순서 미보장**(prefetch 10) |
| 핸들러 | `TokenSocialRecordHandler` — 기존 `TokenFlowLogHandler` 와 **같은 큐를 공유**한다(`setHandler` 가 push 라 둘 다 호출됨) |

~~수동 재수집(백필) endpoint 는 **사용자 결정으로 다음 스코프**다. 그때까지 실패한 토큰을 되살리는
경로는 DB 직접 조작뿐이다.~~ → **2026-08-10 해소.** 위 두 endpoint 가 그 창구다
([reconcile-token 설계](./reconcile-token.md)).

---

## Domain Model

**신규 컬렉션 0개.** 선행 기능이 만든 v4 컬렉션 7종을 그대로 쓰되 **정체성 축을 재정의**했다.

| # | 변경 | 이유 |
|---|---|---|
| C-1 | `accounts.source_urls[]` **필드 제거** | 응답에서 파생된 계정(트윗 작성자·인용원본 작성자)은 자기 URL 이 없다. 부모 URL 을 물려받으면 한 트윗에서 나온 계정 둘이 같은 URL 로 unique 인덱스에서 충돌한다 |
| C-2 | `venues.source_urls[]` **필드 제거** | 같은 이유 — 베뉴는 트윗의 `communityInfo` 로 도착한다 |
| C-3 | `uniq_contents_source_url` → `idx_contents_source_url` (**unique 해제**) | 트윗과 그 인용 원본이 한 URL 에서 파생돼 서로 충돌했다 |
| C-4 | `accounts`·`venues` 의 `platform_key` **필수화** · `partialFilterExpression` 제거 · `idx_venues_platform_key` → `uniq_venues_platform_key`(**unique 승격**) | `source_urls` 가 사라져 이 값이 유일한 정체성 축이 됐다 |

인덱스 23개 → **21개**(`uniq_accounts_source_url`·`uniq_venues_source_url` 제거).

**행 찾기 순서가 컬렉션마다 갈린다.**

| 컬렉션 | 정체성 축 |
|---|---|
| `accounts` · `venues` | `platform_key` 하나 |
| `contents` | `source_urls.url` → 없으면 `platform_key` |

**새 인터페이스 계약**: Generator 가 반환하는 계정·베뉴 객체는 `platform_key` 를 반드시 가져야 한다.
키를 만들 수 없는 소셜(무료 경로의 Instagram 계정 등)은 **그 객체를 아예 반환하지 않는다** — 그래야
저장 계층이 "식별할 수 없는 객체" 를 만날 일이 없다([설계 §1.2](../../docs/features/social-recording-process/be-system-design.md#12-interface-contract-introduced-by-c-4)).

### 의도적으로 비어 있는 필드

버그로 오해하지 않도록 명시한다. 셋 다 **채우는 주체가 이번 범위에 없어서** 비는 것이다.

| 필드 | 비는 이유 | 회수 가능성 |
|---|---|---|
| ~~`tokens.first_transfer_at`~~ ✅ | **2026-08-13 부터 채운다.** 예전에는 *"`firstFlowTimestamp` 는 그 AF 의 첫 유입이라 D-4 를 어긴다"* 로 비워 뒀는데 **그 판정이 틀렸다** — 그 값은 토큰 단위 최초 유입이다 | 실시간·backfill 은 채워지고 **수동 수집 경로만 `null`** 이다. 그 경로로 처음 만들어진 토큰은 나이 앵커가 빈다 |
| `accounts.known_wallets[]` | `bio` 에서 추출하는 파생 키인데 추출 로직이 범위 밖 | `bio` 원문이 저장되므로 **소급 산출 가능** |
| `contents.link_stats{}` | 재계산 배치가 범위 밖 | 파생값이라 언제든 재생성 가능. `computed_at` 없이 읽으면 안 되는 값이라 비어 있는 편이 안전 |

`tokens.fingerprints[]` 도 빈 배열로 남는다(지문 산출 로직 없음).

### 2026-08-10 — reconcile-token 이 더한 것

**신규 컬렉션 0개 · 신규 필드 0개 · 신규 인덱스 0개 · 마이그레이션 스크립트 0개.**
기존 필드 하나(`tokens.social_urls[].status`)의 **값 도메인이 둘 넓어진 것**이 전부다.

마이그레이션이 필요 없는 이유는 MongoDB 가 스키마리스이고 이 enum 이 TypeScript 와 mongoose
`@prop({ enum })` 밸리데이터에만 존재하기 때문이다 — 저장된 문서 쪽에는 건드릴 제약이 없다.
기존 행의 재해석도 필요 없다: 상한 도달분이 `error` 로 남아 있어도 재시도 루프가 `maxAttempts`
까지 두드린 뒤 `exhausted` 로 얼리므로 결국 수렴한다.

**`FetchStatus` — 9값, 흡수 6 / 재시도 3.** 분해 축은 **"재시도로 결과가 바뀌는가" 하나뿐**이다.

| 값 | 성질 | 뜻 | 그 성질인 이유 |
|---|---|---|---|
| `ok` | 흡수 | 수집됨 · 그래프 기록됨 | 멱등 정의 그 자체다 — 한 번 `ok` 면 몇 번을 더 불러도 값이 안 변한다. 바깥 세상의 변화는 이 정의 밖이다 |
| `not_found` | 흡수 | 대상이 없다(삭제·잘못된 핸들) | 다시 물어도 같은 답이다 |
| `blocked` | 흡수 | **대상 쪽** 사정 — 비공개·초대 전용·IP 차단 | 우리 사정이 아니라 우리가 바꿀 수 없다 |
| `suspended` | 흡수 | 계정 정지·탈퇴라 `platform_key` 를 만들 수 없다 | 신호가 여기에만 남는다 — `accounts.unavailable` 은 키를 못 만들어 행 자체가 안 생긴다 |
| **`invalid`** ＋신규 | 흡수 | Generator 가 계약을 어긴 응답을 냈다(`plan()` 이 던지는 5종) | **계약 위반은 100% 같은 실패다.** 바꾸는 것은 재시도가 아니라 코드 수정뿐이다. 이 값이 없어서 `error` 에 섞여 있던 것이 H-009 였고, 그래서 배치가 영영 안 열릴 URL 을 상한만큼 두드렸다 |
| **`exhausted`** ＋신규 | 흡수 | `attempts` 가 `maxAttempts` 에 도달했다 | **인덱스를 위해 존재한다** — 상한 도달분이 `error` 로 남으면 `idx_tokens_social_url_status` 가 안 열릴 행을 계속 돌려주고, 그 행들은 시간이 지나도 줄지 않는다 |
| `error` ~수정 | 재시도 | 외부 호출 실패 · 일시적 DB 오류. **계약 위반이 이 통에서 빠졌다** | 둘 다 재시도로 바뀐다. `attempts` 가 오르고 상한에서 `exhausted` 로 언다 |
| `unsupported` | 재시도 | 아직 담당 Generator 가 없다 | Generator 가 나오는 순간 처리 가능해진다. **외부 호출이 0회라 `attempts` 가 안 올라 `exhausted` 가 될 수 없다** — 영원히 재시도돼야 하므로 그게 맞다 |
| `skipped_paid` | 재시도 | 유료가 꺼져 있다 | `SOCIAL_RECORD_ENABLE_PAID` 를 켜면 처리 가능해진다. 역시 호출 0회 |

**쪼갠 것이 실제로 사는 것.** 재시도 조회가
`{ 'social_urls.status': { $in: ['error','unsupported','skipped_paid'] } }` 라는 **순수 인덱스 술어**가
된다. `$ne: 'ok'` 로 쓰면 부정 조건이라 인덱스 범위를 좁히지 못하고 컬렉션 풀스캔이 된다.
그래서 코드는 이 3종을 `RETRYABLE_FETCH_STATUSES` 상수 **하나로만** 정의하고, 조회 술어와
상한 판정이 같은 목록을 본다.

**대가 — 흡수 상태는 설계상 막다른 길이다.** 배치는 `invalid`·`exhausted` 를 다시 보지 않는다.
Generator 버그를 고쳐도 과거 `invalid` 는 자동 복구되지 않고, `maxAttempts` 를 올려도 `exhausted` 는
소급되지 않는다. **유일한 탈출구는 사람이 `/retry statuses=[invalid]`(또는 `[exhausted]`)를 부르는 것**이고,
이것은 운영 런북에 남아야 하는 절차다.

---

## Recent Changes

- **2026-08-10 — Reconcile Token 추가** (`docs/features/reconcile-token/`, tasks/ T-001 ~ T-005)
  - **신규 endpoint 2개** — `POST /internal/social-recording/{reconcile,retry}`. 위 API Overview 참조.
  - **스키마 변경 0건** — 컬렉션·필드·인덱스·마이그레이션 전부 0. `FetchStatus` 에 `invalid`·`exhausted`
    두 값이 붙어 7값 → **9값**이 된 것이 유일한 변경이다.
  - **신규 클래스 3종** — `SocialReconcileService`(두 플로우의 오케스트레이션 · `exhausted` 전이의
    유일한 지점) · `SocialReconcileController` · **`ApiKeyGuard`(이 레포 최초의 guard)**.
  - **기존 클래스 수정 3종** — `SocialRecordProcessor` 에 `recordOneUrl`(URL 축 진입점) 추가 및
    `fetchStatusOfError` 시그니처가 `code` → `error` 로 변경(계약 위반에는 대응하는 에러 코드가 없다) ·
    `SocialGraphWriter.plan()` 이 `SocialContractError` 를 던지고 `write()` 가 append 앞에 삭제를 넣음 ·
    `TokenRepository` 에 `findExistingAddresses`·`findByUrlStatuses`·`recordSocialUrlResult` 추가 ·
    `TokenLinkRepository` 에 `deleteByTokenAndEntryUrl` 추가.
  - **신규 예외** `SocialContractError` — `entryUrl` 을 싣는다. 위반은 그래프 안쪽에서 걸리지만
    상태가 기록되는 자리는 URL 이라, 어느 URL 을 `invalid` 로 내릴지가 이 필드에서만 나온다.
  - **신규 SDK 메서드** `SolTrackerApiSdk.getTokensByRange` — 이 SDK 는 그전까지 **이미 아는 주소만**
    물어볼 수 있었다. reconcile 의 후보는 정의상 우리 DB 에 없는 토큰이라 그 경로로는 만들 수 없었다.
  - **신규 설정 9종** — `app.secretKey`(Joi required) · `socialRecord.{afGroupIds(required), maxAttempts=5,
    reconcileLimit=20, retryLimit=50, deadlineMs=45000, retryMinIntervalMinutes=60,
    reconcileLockTtlMs=180000, retryLockTtlMs=180000}`.
  - **닫힌 결함 3건** — H-009(재시도 경로 부재) · H-013(링크 중복) · H-015(`$set` 통째 덮어쓰기).
    상세: [recording-holes.md](../../docs/features/social-recording-refactor/recording-holes.md).
  - **테스트**: 단위 615개(+43) + HTTP 계약 22개.
  - **⚠️ 미해소 2건** — ① 업스트림 `GET /api/v1/tokens` 의 와이어 포맷이 설계에 없어 가정으로
    구현했고 **실호출 검증이 아직 안 됐다**(틀리면 예외가 아니라 조용한 0건이 된다).
    ② 재시도 **배치** 안에서 `CREDIT_EXHAUSTED` 가 전파되지 않아, 크레딧 소진 후에도 남은 유료 URL 이
    계속 호출을 시도한다. 상세: `docs/features/reconcile-token/tasks/overview.md`.

- **2026-08-05 — Social Recording Process 추가** (tasks/ T-001 ~ T-005)
  - **스키마 변경 4건**: 위 Domain Model 표 참조. 이 스키마는 아직 어느 환경에도 배포된 적이 없어
    마이그레이션 대상 데이터가 존재하지 않는다 — 인덱스는 부팅 시 `autoIndex` 가 만든다.
    (로컬 DB 에 옛 인덱스가 남아 있다면 그 DB 를 지우면 된다.)
  - **신규 클래스 6종** — `TokenSocialRecordHandler`(수신 경계) · `SocialRecordProcessor`(오케스트레이션) ·
    `SocialGraphWriter`(그래프 1건의 모든 연결 기록 — R-003 이전 이름은 `SocialObjectResolver`) · `SocialGeneratorRouter`(등록표) ·
    `SocialGenerator`(인터페이스) · `SocialFetchGate`(비용·지원 게이트, 기존 어댑터에서 추출).
  - **신규 상수** `SOURCE_TYPE_OBJECT` — `sourceType` 29종 → `{object, platform, subtype}`.
    fetch 없이 미해석 관측을 만들 수 있어야 하므로 소셜 지식 없이 동작한다.
  - **신규 설정** `socialRecord` — `afInvestorThreshold=3` · `enablePaid=false` ·
    `enableCommunity=false` · `lockTtlMs=120000`.
    > ⚠️ **이 줄은 당시 기록이다(덮지 않는다).** 2026-08-13 에 `afInvestorThreshold` ·
    > `lockTtlMs` 는 설정에서 빠져 코드 상수(`AF_INVESTOR_THRESHOLD` · `RECORD_LOCK_TTL_MS`)가
    > 됐고, 임계값은 **3 → 5** 로 올랐다. 현재 값은 §3.6 표를 본다.
  - **기존 계약 수정 2건**: `TokenRepository.upsertByAddress` 가 `discovered_at` 을 `$setOnInsert` 로
    분리(재호출이 백필 진입점을 리셋하던 문제) · `SolTrackerApiSdk` 의 `TIMEOUT` 30초 → 5초
    (업스트림 사망 시 호출당 최대 대기 약 93초 → 약 18초).
  - **제거**: `AccountRepository.findBySourceUrl` · `VenueRepository.findBySourceUrl` —
    필드가 사라져 항상 `null` + COLLSCAN 인 죽은 경로였고, 주석이 "행 찾기 1단계" 라 다음 구현자가
    집어들 함정이었다.
  - **테스트**: 단위 526개 + 통합 110개. 통합은 **브로커에 실제 발행해 DB 기록까지 도달**하는 배선
    테스트를 포함한다 — `setHandler` 를 지우면 타임아웃으로 죽는다.
  - **코드 리뷰 반영 (2026-08-05)**: `metric_series` 를 트리 전체(`resolved.all`)로 확장 —
    fan-out 노드의 지표가 저장 직전에 버려지던 것을 고쳤다. 게이트 거절 사유를 `skipped_paid` 와
    `unsupported` 로 분리해(`source_urls[].status`) "설정을 켜면 회수되는 것" 과 "켜도 안 되는 것" 이
    나중에 갈린다. 같은 `_id` 가 트리에 두 번 나타나면(작성자 = 베뉴 개설자) `token_links` 가 2행
    생기던 것을 Resolver 에서 중복 제거.
  - **R-002 반영 (2026-08-05) — 실패 링크 폐지.** 수집에 실패한 URL 이 더 이상
    `contents(subtype=unknown)` 자리행도 `token_links(unresolved)` 도 만들지 않는다. 그 관측은
    `tokens.social_urls[]` 에 `{url, source_field, source_type, source_key, platform, subtype,
    status, attempted_at, attempts}` 로 남는다. 성공한 URL 도 함께 남겨, 재수집 배치가
    `token_links` 와 차집합을 계산하지 않고 이 문서 하나만 읽게 했다.
    `LinkStatus.UNRESOLVED` 와 `UnresolvedReason` 은 제거했다 — `token_links` 는 이제 **성공
    관측만** 담고, 남은 `deleted`·`unavailable` 은 "수집 후 대상이 사라졌다" 는 상태 변화다.
    인덱스 `idx_tokens_social_url_status` 1개 추가(21 → 22).
    상세: [R-002](../../docs/features/social-recording-refactor/refactoring-direction.md)
  - **R-003 반영 (2026-08-05) — 쓰기 경계 재정렬.** `SocialObjectResolver` 를
    **`SocialGraphWriter`** 로 개명하고, 그래프 1건이 만드는 모든 연결(`contents`·`accounts`·
    `venues`·`metric_series`·`token_links`)을 여기로 모았다. Processor 는 `tokens`
    만 쓴다 — 겹치는 컬렉션이 없다. 경계 규칙은 "연결하는 모든 것 = Writer / 성공·실패를 통합해
    남기는 것 = Processor" 이고, 실패는 Writer 에 도달조차 하지 않는다.
    그래프 안의 쓰기 순서를 **행 → 원본 → 지표 → 링크**로 고정했다(트랜잭션이 없어 순서가
    유일한 도구다). 대가로 `token_links` append 가 1회 → URL 수만큼 늘었다.
    상세: [R-003](../../docs/features/social-recording-refactor/refactoring-direction.md)
  - **R-005 반영 (2026-08-05) — Generator 계약을 묶음 리스트로.** `SocialObjectGraph` 가
    트리+유니온에서 `{ groups, rootRef, raw }` 로 바뀌었다. 묶음 1건은
    `{ ref, creator, venue, content, parentRef }` 이고, 안에서 `creator → venue → content` 는
    타입으로 고정된 순서다. 유니온 판별(`'parent' in node`)·후위 순회 재귀·`parent_content_id`
    백패치가 전부 사라졌고, `Content.linkParent()` 도 함께 제거됐다.
    `groups` 의 **순서에는 의미가 없다** — Writer 가 `parentRef` 로 위상 정렬한다.
    Generator 실수는 Writer 의 검증 6종(중복 ref · 빈 묶음 · rootRef/parentRef 미해결 ·
    한 원본 이중 인용 · 순환)에서 예외로 드러난다.
    상세: [R-005](../../docs/features/social-recording-refactor/refactoring-direction.md)
  - **R-007 반영 (2026-08-05) — `token_links` 의 상태 사본 제거.** `status`(`LinkStatus`)와
    `name_match` 를 없앴고 `LinkStatus` enum 자체도 제거했다. R-002 로 URL 단위 결과가
    `tokens.social_urls[]` 로 옮겨간 뒤 링크의 `status` 는 `accounts.moderation` 의 **낡은
    사본**이었다 — 재관측 시 `entity`(저장된 행)에서 읽어 이번 fetch 값을 반영하지 못했다.
    `name_match` 는 `venues.name` + `tokens.symbol` 로 재계산 가능한 파생값이다.
    `token_links` 는 이제 **"언제 이 토큰이 이 객체에 걸렸나" 만** 담는다.
    상세: [R-007](../../docs/features/social-recording-refactor/refactoring-direction.md)
  - **R-008 반영 (2026-08-05) — `lineage` 제거, `link_depth` 를 묶음 단위로.**
    `token_links.lineage` 와 인덱스 `idx_links_lineage_linked` 를 제거했다(22 → 21).
    `"A 를 인용한 토큰"` 류 질의는 `{ object_id, link_depth }` 로 답하고, 그래서
    `idx_links_object_linked` 를 `{ object_id, link_depth, linked_at }` 로 확장했다.
    **계정·베뉴도 자기 묶음의 깊이를 물려받는다** — 예전에는 콘텐츠에만 계산해 인용 원본의
    작성자가 직접 홍보한 작성자와 구분되지 않았다. `LINEAGE_MAX_DEPTH` 는 `LINK_MAX_DEPTH` 로
    개명했다. "한 원본을 두 묶음이 인용" 검증은 `lineage` 가 없어져 **정당한 그래프를 막게 되므로**
    함께 제거했다.
    상세: [R-008](../../docs/features/social-recording-refactor/refactoring-direction.md)
  - **R-009 반영 (2026-08-05) — 지표는 최초 1회만.** `appendPoint` → **`recordFirstPoint`**
    (`$push` → `$setOnInsert`). `contents.metrics_latest` 도 `Content.of()` 가 생성 시점에
    채운다. 재관측 때 행을 갱신하지 않는다는 규칙이 이제 지표에도 적용된다 — 전에는 캐시는
    덮이고 시계열은 계속 쌓여 정책이 세 갈래였다. 발견될 때마다 점을 쌓으면 불규칙 표본이 되고,
    그런 계열로 증가율을 내면 틀린 답이 에러 없이 나온다.
    부수 효과로 지표가 **멱등**이 되면서 `resolve()`/`write()` 분리 · `ResolvedGraph` ·
    `ResolvedNode` 가 사라졌다. 중복 제거는 링크에만 남는다.
    상세: [R-009](../../docs/features/social-recording-refactor/refactoring-direction.md)
  - **External Validation 오버라이드 1건**: `contents` 유니크 제거로 중복 방지가 Redlock 단독에
    걸리는 것에 **반대** 판정이 나왔으나 사용자 결정으로 현행 유지. 근거와 재검토 트리거는
    [설계 §1.3](../../docs/features/social-recording-process/be-system-design.md#13-override-recorded-against-external-validation).

---

## 알려진 제약

우선순위 순이다. 위 두 개는 **배포 전에 판단이 필요하다.**

- **🔴 `dist` 부팅이 되지 않는다 (기존 문제, 앱 전체 영향).** `tsconfig` 에 `paths` 가 없고 런타임
  `tsconfig-paths` 등록도 없는데 여러 파일이 `from 'src/common/...'` 절대 import 를 쓴다. 컴파일은
  통과하지만 산출물에 `require("src/common/...")` 가 그대로 남아 `Cannot find module` 로 죽는다.
  `AppModule` 이 거치는 `apis/v1/social-fetcher/social-fetcher.module.ts` 와
  `common/social-fetcher/social-fetcher.service.ts` 가 그 경로다. 이번 기능의 신규 파일은 전부
  상대경로로 정리했으나 **기존 파일이 남아 있어 여전히 부팅되지 않는다.**
  → **조치**: `tsconfig` `paths` + 런타임 등록을 넣거나, 해당 import 를 상대경로로 바꾼다.
- **🔴 Redis 장애가 조용한 전량 스킵이 된다.** `RedlockSDK.tryLock` 이 인프라 오류를 삼키고 `null` 을
  돌려주므로, Redis 가 죽으면 모든 토큰이 "락 못 잡음 → 종료" 로 빠진다. 에러도 안 나고 데이터도
  안 쌓인다. 실제로 통합 테스트에서 비밀번호가 틀렸을 때 이 모드가 그대로 재현됐다.
  → **조치**: 락 실패 사유를 "경합" 과 "인프라 오류" 로 구분해 후자는 던지도록.
- **임계값 config 가 없으면 게이트가 열리는 방향으로 실패한다.** `config.get<number>(...)` 가
  `undefined` 면 `x < undefined` 는 항상 `false` 라 **모든 이벤트가 통과**한다. 지금은 기본값 3 이
  있어 안전하지만, 네임스페이스 키가 오타·삭제되면 "전량 처리 + 유입량만큼 외부 호출" 로 실패한다.
  비용 1차 필터가 비싼 쪽으로 열리는 실패 모드다.
- **락에 자동 연장이 없다.** TTL 120초는 있으나 자동 연장은 redlock 의 `using()` 경로에만 있다.
  유료 소셜을 켜서 수집이 120초를 넘기면 락이 먼저 풀려 두 워커가 동시에 쓸 수 있다.
- **갱신 프로세스가 없으면 `metric_series` 는 `metrics_latest` 의 중복이다.** R-009 로 지표를
  최초 1회만 남기므로 객체당 점이 영원히 하나다. 그러면 컬렉션 하나와 인덱스 두 개가 값 하나를
  담으려고 존재하게 된다. → **조치**: 규칙적으로 재는 갱신 프로세스를 만들거나, 안 만들 거라면
  `metric_series` 를 접고 `metrics_latest` 만 남긴다.
  ⚠️ **2026-08-13 — 뒤쪽 선택지가 약해졌다.** 그 프로세스에 걸린 것이 이제 지표만이 아니다
  (`handles`·`outbound_urls`·`bio`·`subtype` 도 재관측에서 안 덮기로 확정). 안 만드는 것은
  `metric_series` 하나를 접는 것이 아니라 **재관측 갱신 전체를 포기하는 것**이 됐다.
- **계정 지표가 화이트리스트에 전부 걸린다.** `METRIC_SPEC` 이 플랫폼별로만 있고 내용이 콘텐츠
  지표라(X 는 `viewCount`·`likeCount`…), 계정의 `followerCount` 는 `pickKnownMetrics` 가 통째로
  버린다. fan-out 계정의 지표가 저장 경로를 타도 빈 객체가 된다.
  → **조치**: 화이트리스트를 `플랫폼 × 객체종류` 로 나눈다.
- **`token_links` 에 보존 정책이 없다.** append-only 인데 지우는 규칙이 아무데도 없다. 설계에서
  "Data lifecycle" 을 명시적으로 제외했다. URL 당 fan-out 개수만큼 행이 쌓이므로 증가가 빠르다.
  → **조치**: 재검토 트리거를 정한다(예: 1000만 행). 지금 정책을 만들 필요는 없다.
- ~~**재수집 배치가 `social_urls` 를 부분 갱신할 수 없다.**~~ → **2026-08-10 해소** (H-015).
  `TokenRepository.recordSocialUrlResult(address, url, status, attemptedAt)` 가 `arrayFilters` 로
  원소 하나만 갱신한다. 쓰기 경로가 전부 `social_urls.$[target].*` 접두를 벗어나지 않아
  `fingerprints`·`web` 과 나머지 URL 이 손대지지 않는다.
  ⚠️ **`upsertByAddress` 의 통째 `$set` 은 실시간 경로에 그대로 남아 있다.** 그쪽은 최초 1회만
  쓰이므로 지금 드러나지 않지만, **지문을 채우는 주체가 생기면 여전히 `$addToSet` 전환이 필요하다.**
- **`contents` 중복 행을 DB 가 막지 않는다.** C-3 의 대가다. 토큰 단위 락 밖에서 같은 URL 이 동시에
  들어오면 두 행이 생기고, `findBySourceUrl` 이 단건을 반환하므로 이후 조회가 비결정적이 된다.
  External Validation 이 반대한 지점이며 사용자 결정으로 수용됐다.
- ~~**미수집 URL 을 회수할 주체가 아직 없다.**~~ → **2026-08-10 해소** (H-009).
  `POST /internal/social-recording/retry` 가 `tokens.social_urls[].status` 를 읽어 재시도한다.
  ⚠️ **회수되지 않는 것이 둘 남았다** — `invalid`(Generator 계약 위반)와 `exhausted`(상한 도달)는
  흡수 상태라 배치가 다시 보지 않는다. 코드를 고치거나 상한을 올려도 **소급되지 않으며**,
  탈출구는 사람이 `/retry statuses=[invalid]`(또는 `[exhausted]`)를 부르는 것뿐이다 — 런북 항목이다.
- **업스트림에 없는 토큰은 마커를 남기지 않는다.** 그 토큰의 flow 이벤트가 올 때마다 업스트림 조회가
  반복된다 — D-1 이 소셜 0건에 대해 막으려던 비용 패턴과 같은 모양이다. 설계에 정의가 없는 구간이라
  구현 판단으로 남겨 뒀다.
- **`SOURCE_TYPE_OBJECT['unknown'].platform` 은 근거 없는 선택이다.** `SocialPlatform` 에 `unknown` 이
  없어 `web` 으로 받는다. 웹 작업이 별도로 진행되면 웹 통계를 오염시킨다.
- **`AppModule` 전체 부팅 테스트를 만들 수 없다.** 위 절대 import 문제 때문에 vitest 에서
  `SocialFetcherControllerModule` 이 뜨지 않는다(그 모듈만 단독으로 띄워도 같은 오류). 그래서
  부팅 검증 범위를 `SocialRecordingModule` 로 좁혔고, `AppModule` 등록 여부는 소스 문자열로 확인한다.
- **통합 테스트에서 `venues` 저장 경로가 검증되지 않았다.** Generator stub 의 그래프에 베뉴가 없다.
  Resolver 단위 테스트만 이 경로를 덮는다.

---

## 운영 전제

- **마이그레이션이 없다 — 필요 없기 때문이다.** `social-graph` v4 는 아직 어느 환경에도 배포된 적이
  없어 옮길 데이터도, 지울 옛 인덱스도 존재하지 않는다. 인덱스는 부팅 시 `autoIndex` 가 만든다.
  이 전제는 **첫 배포 시점에 소멸한다** — 그 뒤의 스키마 변경부터는 마이그레이션이 필요하다.
- **이 파이프라인은 아직 소셜 데이터를 만들지 않는다.** Generator 구현 8종이 다음 작업이라, 지금
  배포하면 모든 URL 이 `tokens.social_urls[].status = unsupported` 로만 쌓이고 `contents`·
  `token_links` 는 한 행도 안 생긴다.
  배선과 멱등·순서 보장을 먼저 검증하기 위한 의도된 중간 상태다.
- **유료 소셜을 켜는 시점이 곧 비용 발생 시점이다.** `SOCIAL_RECORD_ENABLE_PAID=true` 하나로 켜지며,
  TikTok 실측 기준 호출당 약 $0.038 이다. 이벤트 유입량 실측이 없어 월 비용을 계산할 수 없다.
- **통합 테스트가 `npm run test:integration` 으로는 돌지 않는다** — `.env.test` 가 레포에 없어
  env 를 인라인 주입해야 한다. 이번 기능의 통합 110건은 다음 명령으로 실행·검증했다:
  ```
  docker compose -f docker-compose.test.yml up -d   # package.json 스크립트는 v1 문법이라 갈린다

  APP_ENV=test APP_NAME=af-social-scanner \
    MONGODB_HOST=mongodb://localhost:27019/af-social-scanner-v4-integration-test \
    REDIS_HOST=localhost REDIS_PORT=6380 REDIS_PASSWORD=testpassword \
    RABBITMQ_HOST=localhost:5673 RABBITMQ_USERNAME=testuser RABBITMQ_PASSWORD=testpassword \
    SOL_TRACKER_BASE_URL=http://localhost:1 SOL_TRACKER_API_KEY=x \
    TWITTER_API_KEY=x GITHUB_TOKEN=x YOUTUBE_API_KEY=x URLSCAN_API_KEY=x APIFY_TOKEN=x \
    npx vitest run test/integration
  ```
- **Out of Scope 6건**은 [설계 §7](../../docs/features/social-recording-process/be-system-design.md#7-out-of-scope-explicit-exclusions) 에
  재검토 트리거와 함께 기록돼 있다 — Observability · Lifecycle · Access control · Data lifecycle ·
  ~~백필 endpoint~~ · 소셜별 Generator 구현.
  **백필 endpoint 는 2026-08-10 에 해소됐다**(reconcile-token). 나머지 5건은 그대로이며,
  reconcile-token 도 자기 §7 에서 Observability·Data lifecycle 등을 다시 제외했다 —
  `meta.converged`·`meta.stoppedBy` 가 유일한 건강 신호이고 그것도 pull 방식이다.
