지금 대화에서 기본적으로 Schema 를 한번 전체 점검할 필요가 있다고 판단했고, 모든 스키마에 대해서 전수조사, 한번 정리하려고해. 각 스키마에 해당하는 model 객체에 메모를 남겼고,지금 결정하는게 최종 결정안이기때문에 수정하고나면 문서도 업데이트해야해.

우선 token 에 명시된 모든 memo 를 체크, 각 모델별로 한번에 하나씩 질의를 통해 스키마를 수정해 나갈거고, 최종 수정전 전체 점검 진행
수정할 때 히스토리는 아래 참조하고 당연히 다른 문서도 참조해도됨
- docs/features/social-schema-definition/schema-v4.html
- docs/features/social-url-structure-audit/system-ideations/schema-structure-v3.html

최종 OK 할 때까지는 수정진행하지말고 계획만 디테일화 하고, 해당 문서의 "## Plan By Model" 예 방향성 하나씩 append. 완전 확정되면 그때 한번에 수정 적용


## 관련 문서

| 문서 | 담는 것 |
|---|---|
| [schema-v5.html](../social-schema-definition/schema-v5.html) | **무엇이 됐나** — 최종 필드 구성과 판정 규칙. 이 문서의 결론을 명세로 정리한 것 |
| [url-pipeline.html](./url-pipeline.html) | **URL 이 어떻게 가공되나** — 정규화·분류 단계별 계약. P-2 의 근거 |
| [schema-v4.html](../social-schema-definition/schema-v4.html) | 이전 판. v5 가 대체했다 |
| [refactoring-direction.md](./refactoring-direction.md) | 선행 리팩토링(R-001~R-009). 이 문서가 여러 곳에서 참조한다 |

**이 문서는 판정의 원본 기록이다.** 각 항목이 무엇을 정했는지뿐 아니라 **무엇을 기각했고 왜 기각했는지**를 담는다. 같은 논의를 다시 하지 않기 위해서다.

## 적용 상태

**판정 26건이 코드에 반영됐다.** 단위 테스트 510개가 이 계약을 고정한다.

| 커밋 | 범위 |
|---|---|
| `750190b` | 문서 — v5 명세 · 판정 근거 · URL 파이프라인 |
| `574acbc` | 스키마 — 모델 7종 + 입력 타입 |
| `86e33bf` | 리포지토리 7종 |
| `1a2f3b3` | 분류 레이어(R-M1) · Writer · 프로세서 |
| `6864dbd` | 테스트 |

**구현하며 초안에서 바뀐 것 두 가지** — 둘 다 R-M1 이며 해당 항목 본문에 반영돼 있다.
1. 유료·미지원 정책 집합을 각 fetcher 로 내리지 않고 `social-fetcher` 패키지에 남겼다.
2. `platform: SocialPlatform | null` 대신 `route()` 자체가 `null` 을 반환한다.

**구현 중 추가로 고친 것 세 가지** (판정에 없던 것)
- `SocialPlatform` enum 정의를 `common/social-fetcher` 로 옮겼다. `UrlClassifier.platform` 때문에 `common` → `modules` 역방향 의존이 생길 뻔했다.
- `AccountRepository.findByHandle` 이 `platform` 을 받는다. A-M1 이 인덱스 선두를 `platform` 으로 바꿨는데 쿼리가 핸들 단독이라 prefix 를 놓치고 있었다.
- `normalizeUrl()` 을 `url-normalizer.ts` 로 독립시켰다. 소비자가 라우터·fetcher·Generator 셋이 됐다.

**미결 3건은 손대지 않았다** — H-1(동시 생성) · T-M3(웹) · A-M1-a(재관측 업데이트).
→ **A-M1-a 는 2026-08-13 에 결정됐다**(아래 축 ⑤). 재관측은 아무것도 안 덮고, 변하는 값은
전부 **주기성 갱신 프로세스**가 맡는다 — R-009 의 그 프로세스와 같은 것이다.

⚠️ **통합 테스트는 실행하지 못했다.** `.env.test` 와 이 레포의 test 컨테이너가 필요하다(`npm run test:integration:full`). 컴파일과 타입 계약은 통과한다.

## Plan By Model

### 공통 원칙 (전 모델에 적용)

#### P-1 · 시각 필드의 존치 기준 (확정)

**`created_at` / `updated_at` 이 답할 수 없는 질문에만 별도 시각 필드를 둔다.**

판정 절차는 두 단계다.

1. 이 필드가 `created_at` 과 **구조적으로** 갈리는가. 갈리지 않으면 제거한다.
2. 갈린다면, 그 차이가 **우리 인프라의 성질**인가 **세상의 성질**인가. 인프라의 성질(큐 지연·재시도 간격·처리 소요)이면 제거한다. 세상의 성질(체인 최초 전송 시각·소셜 게시 시각)이면 남긴다.

**근거:** 필드가 핵심적인 시점 정보를 주지 못하면서 매번 재현도 안 되면, 남겨 두는 대가는 읽는 사람이 "이건 저것과 뭐가 다른가" 를 매번 되묻는 인지 비용이다. 그 비용이 저장 비용보다 크다.

**전제:** 백필은 이벤트 재생이 아니라 **실행 시점 기준**으로 수행한다. 따라서 과거 이벤트를 오늘 재처리해 `created_at` 이 이벤트보다 크게 뒤로 밀리는 상황 자체가 없다.

**감수하는 대가:** 큐가 크게 밀린 구간(배포·장애·크레딧 소진)에서는 `created_at` 이 실제 사건보다 늦게 찍힌다. 분석 해상도가 분 단위면 문제가 되지만, 이 데이터의 소비처는 토큰 간 상대 평가라 시간·일 단위로 본다. 그 전제가 바뀌면 이 원칙부터 다시 연다.

**적용 대상:** 남은 모델(`contents`·`accounts`·`venues`·`metric_series`·`object_raw`)의 시각 필드도 이 기준으로 평가한다.

#### P-2 · URL 필드의 표준 (확정)

**저장되는 URL 은 예외 없이 정규화(`SocialFetcherRouter.normalize`)를 거친 값이다. 정규화되지 않은 URL 은 저장하지 않는다.**

예외 필드도, `raw_` 류 접두어도 두지 않는다. **`url` 로 끝나는 필드에 값이 있다는 것 자체가 "정규화에 성공했다" 를 뜻한다** — 불변식이 주석이 아니라 데이터로 강제된다.

**정규화 실패는 버린다.** 실패하는 실제 경우는 우리 버그가 아니라 **쓰레기 입력**이다. tracker 의 소셜 필드는 토큰 발행자가 채우는 자유 텍스트라 `"none"` · `"TBA"` · `"soon"` 같은 값이 온다. `normalize()` 의 `if (!host.includes('.'))` 가 여기서 걸린다.

- **예외를 던지지 않는다.** 고칠 것이 우리 쪽에 없고, 던지면 쓰레기 필드 하나 때문에 그 토큰의 나머지 URL 처리가 통째로 죽는다.
- **그 URL 만 건너뛰고 경고 로그를 남긴다.** `routeSocialUrls` 가 이미 `if (!rawUrl) continue` 로 빈 값을 그렇게 처리하므로 같은 자리에 조건이 하나 붙는다.
- `social_urls[]` 엔트리 자체를 만들지 않는다. R-002 의 "실패는 행을 만들지 않는다" 와 같은 규칙이다.

**두 실패를 구분한다.**

| | 예시 | 처리 | 이유 |
|---|---|---|---|
| 정규화 실패 | `"none"` · `"TBA"` | **버린다** | 회수 가능성이 없다 |
| 플랫폼 미상 | `https://randomsite.com` | **기록한다** (`platform = null` → `unsupported`) | 소셜을 하나 더 지원하면 회수 대상이 된다 |

**원본 URL 은 저장하지 않는다 (기각).** 검토했으나 근거 둘이 모두 무너졌다.
- *"정규화 규칙 변경 시 재처리"* — P-U3 채택으로 **저장된 URL 이 더 이상 정체성 축이 아니다**. 형태가 옛것이어도 아무것도 갈라지지 않으므로 재처리할 이유가 없다.
- *"파싱 실패 URL 이 갈 곳"* — 갈 곳이 필요한 게 아니라 버리면 된다. 로직 보완을 위해 필드를 추가하는 것은 하지 않는다.

**URL 필드 최종 목록과 명칭**

| 필드 | 값 | 변경 |
|---|---|---|
| `tokens.social_urls[].url` | 토큰이 건 URL | 유지 |
| `token_links.entry_url` | 그 링크의 진입 URL | **개명** (구 `source_url`) |
| `contents.outbound_urls[]` | 본문 외부 링크 | **개명** (구 `links`) |
| `accounts.outbound_urls[]` | 프로필 소개란 링크 | **개명** (구 `links`) |

**개명 근거 —**
- `source_url` → `entry_url` : `source_` 접두어를 쓰던 필드가 이번에 전부 사라진다(`source_field`·`source_key`·`source_type`). 하나만 남으면 없어진 가족의 생존자가 되어 더 헷갈린다. `entry_url` 은 fan-out 된 객체들이 왜 같은 값을 갖는지도 이름으로 설명한다.
- `links` → `outbound_urls` : `token_links` 컬렉션과 겹치는 모호함이 사라지고(링크 행인가 URL 목록인가), 방향이 드러난다. 이 레포는 URL 배열에 `_urls` 를 쓴다(`social_urls`).

**`outbound_urls` 가 정규화돼야 하는 이유는 별도로 있다.** 이 값의 쓸모는 "이 콘텐츠가 토큰의 사이트를 가리키는가" 를 보는 것이고, 그러려면 `tokens.social_urls[].url` 과 **문자열 비교**를 한다. 한쪽만 정규화돼 있으면 같은 대상이 안 맞는다. **현재 위반 상태다** — 제너레이터가 준 값을 그대로 넣는다(`content.model.ts:257`). 제너레이터 계약에 정규화를 추가한다.

### tokens

#### T-M1 · `TokenSocialUrl` 에서 파생 필드 4개 제거 (확정)

**메모:** `token.model.ts:113` — "source field, type 이거 string 으로 관리하는게 맞나?"

**판정:** 표현(string vs enum)의 문제가 아니라 **존재**의 문제였다. 필드를 없애기로 해서 원 질문이 소멸한다.

**근거 — 4개 필드가 전부 `url` 의 순수 파생이다.**

`social_urls[]` 항목을 만드는 곳은 `social-record.processor.ts` 의 `sourceOf()` 하나뿐이고, 입력은 `urlRouter.route(url, field)` 의 결과다. 라우터는 I/O 없는 순수 함수이며, fetch 이후 이 값이 갱신되는 경로가 없다.

| 필드 | 유래 | 판정 |
|---|---|---|
| `url` | 정규화된 canonical URL | **유지** |
| `source_field` | 업스트림 응답의 칸 이름 (`website`/`twitter`/`telegram`/`discord`) | **제거** |
| `source_type` | `route(url).sourceType` | **제거** |
| `source_key` | `route(url).sourceKey` | **제거** |
| `platform` | `SOURCE_TYPE_OBJECT[route(url).sourceType].platform` | **제거** |
| `subtype` | `SOURCE_TYPE_OBJECT[route(url).sourceType].subtype` | **제거** |
| `status` · `attempted_at` · `attempts` | 관측 결과 — 재구성 불가 | **유지** |

**확정 후 모양**

```
TokenSocialUrl {
  url          // 정규화된 canonical URL
  status       // 마지막 처리 결과
  attempted_at // 외부 호출을 실제로 한 마지막 시각 (안 불렀으면 null)
  attempts     // 외부 호출 횟수
}
```

**기각한 반론 3건**

1. *"쿼리 효율을 위해 비정규화했다"* — 근거가 성립하지 않는다. 주석은 "플랫폼별 질의가 인덱스로 끝난다" 고 적혀 있으나 `tokens` 의 인덱스 4종에 `social_urls.platform` 이 없다. 존재하는 것은 `idx_tokens_social_url_status` 뿐이다.
2. *"재시도 배치가 URL 을 다시 분류하지 않게 박제한다"* — 재시도 목적에는 **박제가 오히려 손해**다. 라우터 분류 버그를 고쳐도 박제된 옛 판정으로 재시도하면 수정이 반영되지 않는다. 재시도는 현재 라우터의 답을 써야 맞다.
3. *"판정 이력 보존"* — 포렌식 가치만 남는데, 그 정보는 `object_raw` 와 실제로 생성된 객체가 이미 갖고 있다.

**`source_field` 를 함께 제거하는 이유**

판정에 쓰지 않는다고 코드에 못박혀 있고(실데이터에서 `twitter` 칸에 TikTok URL 이 온다), 읽는 코드가 한 줄도 없다. 유일하게 상상 가능한 용도인 "칸 이름을 판정 지름길로 써도 되는가" 는 `social-url-structure-audit` 이 이미 측정해서 "못 믿는다" 로 결론 났다. 측정도 결정도 끝난 값이다.

- 잃는 것: tracker 의 소셜 정보는 발행자가 나중에 고칠 수 있어 재조회로 복원되지 않는다. 다만 그 값을 알아야 풀리는 문제가 현재 없다.

**크론(재시도 배치)에 넘기는 조건**

- 후보 선정은 `social_urls.status ≠ ok` 로 하고, 각 URL 은 읽는 시점에 `route()` 로 재분류한다.
- ⚠️ **대가:** "유료를 켰으니 tiktok 만" 같은 플랫폼 한정 조회가 인덱스로 끝나지 않는다. `status ≠ ok` 인 토큰을 전부 읽고 앱에서 걸러야 한다. 후보 규모가 커지면 `platform` 만 되살리는 절충을 재검토한다.
- ⚠️ **함정 (필드를 되살릴 경우에 한함):** 배열 안 필드를 두 조건으로 거는 `{ 'social_urls.platform': 'tt', 'social_urls.status': 'error' }` 는 "틱톡이면서 에러인 URL 이 있는 토큰" 이 아니라 "틱톡 URL 이 있고 (다른) 에러 URL 도 있는 토큰" 을 뽑는다. `$elemMatch` + 복합 multikey 인덱스가 필요하다.

**영향 범위 (최종 적용 시)**

- `token.model.ts` — `TokenSocialUrl` 4필드 삭제, `toSocialUrl()` 축소
- `social-graph.types.ts` — `TokenSocialUrlInput` 축소
- `social-record.processor.ts` — `sourceOf()` 가 `SOURCE_TYPE_OBJECT` 를 더 이상 쓰지 않음
- `social-recording.consts.ts` — `SOURCE_TYPE_OBJECT` 의 유일한 소비자가 사라지므로 존치 여부 재확인 필요
- 문서: `service-overview.md` 스키마 표, `schema-v4.html`

#### T-M2 · `Token.address` 네이밍 — 현행 유지 (확정)

**메모:** `token.model.ts:159` — "네이밍 통일 token_address"

**판정: 바꾸지 않는다.**

**근거 — 레포에 이미 규칙이 있고, 지금 상태가 그 규칙이다.**

| 위치 | 필드 | 성격 |
|---|---|---|
| `tokens` | `address` | 자기 식별자 (접두어 없음) |
| `token_links` | `token_address` | 외래 참조 (접두어 있음) |
| `contents`·`accounts`·`venues` | `_id` | 자기 식별자 |
| `token_links` | `object_id` | 외래 참조 |

"자기 것은 짧게, 남의 것은 접두어" 가 이미 서 있다. `object_id → _id` 와 `token_address → address` 가 같은 모양이다. `tokens.token_address` 로 바꾸면 `tokens` 만 이 규칙의 예외가 되고, 컬렉션명과 필드 접두어가 겹쳐 더듬거린다.

**포기하는 이득:** `token_address` 로 grep 하면 조인 양쪽이 한 번에 잡힌다. 지금은 따로 찾아야 한다. 작지만 실재하는 이득이며, 규칙에 예외를 만들 값은 아니라고 판단했다.

**비용은 판단 근거가 아니었다** — v4 미배포라 데이터 마이그레이션이 없고 코드 접점이 8곳뿐이라 어느 쪽이든 비용은 사실상 0 이다.

**함께 기각한 안 — `address` 를 `_id` 로 승격**

mint 주소는 불변·유일이라 `_id` 조건을 만족하고, 채택하면 `uniq_tokens_address` 인덱스와 필드 1개가 사라진다. 그러나 `BaseModel` 을 상속하는 전 컬렉션이 `_id: Types.ObjectId` 를 공유하고 있어, `tokens` 만 string `_id` 가 되면 그 전제를 아는 사람과 모르는 사람이 갈린다. 인덱스 하나와 맞바꿀 값이 아니다.

#### T-M3 · `TokenWeb` 블록 — 현행 유지 (확정, 단 미결 상태로 명시)

**메모:** `token.model.ts:180` — "나중에 web 을 처리할 때 진행해야 함"

**판정: 블록을 그대로 둔다. 웹 수집이 별도 작업으로 진행될 때 스키마를 재검토한다 — 이번 전수조사에서 정해진 것은 없다.**

**현재 상태:** `TokenWeb` 17필드 · `TokenWebInput` 17필드 · `updateWeb()` 이 존재하나 **프로덕션 코드에서 `input.web` 을 채우는 호출자가 0** 이다. `Token.of()` 의 배선과 단위 테스트 1건(`social-graph.model.spec.ts:419`)이 전부다.

**유지 근거:** 이 필드 목록은 urlscan 계열 실측을 근거로 추려진 결과다. 지우면 코드에서 그 조사 결과가 사라지고, 되살릴 때 "왜 인증서 필드 중 `cert_valid_from` 만 남겼나" 류의 판단을 다시 해야 한다. 반면 두는 비용은 읽을 때 눈에 걸리는 것뿐이며 `web?` 이 optional 이라 실제 문서에는 생기지 않는다.

**보류 중인 것:** 필드 구성·`updateWeb()` 의 통째 교체 정책·`TokenWeb` 이 `tokens` 에 붙는 게 맞는지 자체가 전부 미결이다. 웹 작업이 시작될 때 이 항목부터 다시 연다.

#### T-M4 · `Token.discovered_at` 제거 (확정)

**메모:** 없음 — `token_links` 시각 논의(TL-M1)에서 같은 판단이 걸린다는 것이 드러나 함께 결정했다.

**판정: 제거한다. `created_at` 이 대신한다.**

**근거:** `upsertByAddress` 가 이 필드를 `$setOnInsert` 로 쓴다(`token.repository.ts:47`). 즉 **문서가 처음 만들어지는 순간에만** 찍히는데, `created_at` 도 정확히 그 순간에 찍힌다. 값이 `event.timestamp` 라 `created_at` 과 큐 지연만큼 다르지만, 그것은 인프라의 성질이다 — P-1 기준으로 제거 대상이다.

**영향:**
- `token.model.ts` — 필드 삭제, `Token.of()` 축소
- `token.repository.ts` — `upsertByAddress` 의 `$setOnInsert` 분해가 불필요해져 `findOneAndUpdate` 가 단순해진다
- 인덱스 `idx_tokens_discovered` → `created_at` 기준으로 이관
- `social-graph.types.ts` `TokenInput.discoveredAt` 삭제, `social-recording.types.ts` `RecordCommand.discoveredAt` 삭제
- `token-social-record.handler.ts` — `discoveredAt: event.timestamp` 전달 삭제

**유지되는 것:** `first_transfer_at` 은 체인의 사건 시각이라 세상의 성질이다. 남긴다.

### token_links

#### TL-M1 · `linked_at` · `observed_at` 제거 (확정)

**메모:** `token-link.model.ts:74` — "이게 사실상 created_at 아닌가? 필요 여부 체크" / `token-link.model.ts:79` — "observed_at linked_at 정확히 차이 파악 필요"

두 메모가 같은 질문이라 함께 판정했다.

**현재 이 컬렉션의 시각은 셋이다.**

| 필드 | 출처 | 뜻 |
|---|---|---|
| `linked_at` | `event.timestamp` | 업스트림 이벤트 시각 |
| `observed_at` | `new Date()` (처리 시작) | 소셜을 보러 간 시각 |
| `created_at` | mongoose timestamps | 문서를 쓴 시각 |

**판정: `linked_at` · `observed_at` 둘 다 제거. `created_at` 이 유일한 시각축이 된다.**

**`observed_at` 근거 —**
- 쓰기만 하고 **읽는 코드가 한 줄도 없다**. 인덱스도 없다.
- `created_at` 과의 차이는 fetch 소요 시간(수백 ms ~ 수 초)뿐이다. 그 간격을 알아야 풀리는 질문이 없다.
- 대조군: `accounts.observed_at` · `contents.observed_at` 은 `isObservedLaterThan()` · `updateObservedAt()` 이 "들어온 관측이 저장된 것보다 최신인가" 를 판정하는 데 실제로 쓴다. 그 컬렉션들은 덮어쓰기가 있어 최신 판정이 필요하다. `token_links` 는 append-only 라 덮어쓸 일이 없고, 따라서 최신 판정도 필요 없다.

**`linked_at` 근거 —** P-1 을 그대로 적용했다. 백필이 이벤트 재생이 아니므로 `created_at` 과 갈리는 경우가 큐 지연 하나로 좁혀지고, 큐 지연은 인프라의 성질이다.

**영향:**
- `token-link.model.ts` — 필드 2개 삭제, `of()` 축소
- 인덱스 3종의 정렬축 `linked_at` → `created_at` 이관
- `social-graph.types.ts` `TokenLinkInput` 에서 `linkedAt`·`observedAt` 삭제
- `social-recording.types.ts` `LinkContext.linkedAt` 삭제 (`observedAt` 은 `object_raw`·`metric_series` 가 아직 쓰므로 그 모델 판정 후 결론)
- `social-recording.types.ts` `RecordCommand.linkedAt` 삭제, 핸들러의 전달 삭제

#### TL-M2 · 비정규화 복사본 3개 — `first_transfer_at` 만 제거 (확정)

**메모:** 없음 — 전수조사 중 발견.

`token_links` 는 다른 컬렉션에서 복사해 온 값을 셋 갖고 있다. 셋을 한 기준으로 판정했다: **복사를 안 하면 무엇을 대신 해야 하는가.**

| 필드 | 원본 | 판정 | 이유 |
|---|---|---|---|
| `platform` | 대상 객체 | **유지** | `idx_links_platform_linked` 가 실제로 이 필드를 쓴다 |
| `subtype` | 대상 객체 | **유지** | 같은 인덱스의 두 번째 축 |
| `first_transfer_at` | `tokens` | **제거** | 읽는 코드가 없고, 원본조차 채워지지 않는다 |

**`platform`·`subtype` 유지 근거 —** `tokens` 의 파생 필드(T-M1)와 상황이 다르다. 거기서는 "쿼리 효율" 이 주석뿐이고 인덱스가 없었으나, 여기는 인덱스가 실재한다. 복사를 없애면 대안이 **다형 조인**이다 — `object` 값에 따라 `contents`/`accounts`/`venues` 중 어디로 `$lookup` 할지 갈리므로 집계가 `$match` 3회 + `$lookup` 3회로 쪼개진다. 두 필드가 그것을 없앤다. 값이 불변이라(객체의 플랫폼은 바뀌지 않는다) 복사본이 원본과 갈릴 위험도 없다.

**`first_transfer_at` 제거 근거 —**
- 읽는 코드가 없다.
- 주석이 말하는 "Δt 계산"(링크 시각 − 토큰 최초 전송 시각)의 소비처가 아직 없다.
- 대안이 다형 조인이 아니라 `token_address` 단일 조인이라 저렴하다.
- **원본이 비어 있다** — `TokenInput.firstTransferAt` 를 넘기는 호출자가 0이라 `tokens.first_transfer_at` 자체가 항상 `undefined` 다. 즉 지금은 빈 값을 링크마다 복사하고 있다.

**함께 확인하고 유지한 것 — `object` 필드.** ObjectId 는 전역 유일이라 이론상 세 컬렉션을 순차 탐색하면 대상을 찾을 수 있으나, `$lookup.from` 이 상수여야 하므로 집계가 이 값으로 먼저 갈라야 한다. 성능상 필수다.

**영향:** `token-link.model.ts` 필드 1개 삭제, `of()` 축소, `social-graph.types.ts` `TokenLinkInput.firstTransferAt` 삭제. `tokens.first_transfer_at` 자체의 존치는 `tokens` 를 채우는 주체가 생길 때 재검토한다(현재 미결).

### 분류 레이어 (router · gate · generator)

> 스키마가 아니라 코드 구조지만, `tokens.social_urls[]` 의 필드 구성(T-M1)과 같은 뿌리에서 나온 결정이라 여기 둔다.
> URL 가공 단계의 정의와 각 단계 값의 계약은 [url-pipeline.html](./url-pipeline.html) 이 정본이다.

#### R-M1 · `sourceType` 을 런타임 계약에서 제거, 라우터는 `platform` 만 반환 (확정 · 구현 완료)

> **구현하며 초안에서 바뀐 것 두 가지** — 아래 본문에 반영돼 있다.
> ① 유료·미지원 정책을 각 fetcher 로 내리지 않고 `social-fetcher` 패키지에 남겼다.
> ② `platform: SocialPlatform | null` 대신 `route()` 자체가 `null` 을 반환한다.

**판정: `route()` 는 `{ 정규화 URL, platform: SocialPlatform | null }` 만 반환한다. subtype 판정과 키 추출은 제너레이터(그 소셜의 fetcher)가 내부에서 한다. 게이트는 제거하고 유료·미지원 정책을 각 소셜로 내린다.**

**문제 인식 —** `sourceType` 은 판정 축이 **둘로 갈려 있어서** 생긴 배관이다. 자기가 무슨 타입인지 아는 주체는 이미 소셜 fetcher 이고(`x.fetcher.classify()`), 라우터는 host → 소유자 조회만 한 뒤 그 결과를 위로 흘려보낸다(`router.ts:96`). 위로 올린 값을 쓰는 소비자는 **게이트 하나뿐**이다.

**게이트를 내려도 되는 근거 —** 게이트가 판정에 쓰는 입력(`enablePaid` · `enableCommunity`)은 `FetchOptions` 에 담겨 **이미 fetcher 까지 그대로 흐른다**. 즉 정책 입력은 fetcher 손에 있는데 판정만 한 단계 앞에서 하고 있었다.

**정리 후 각 계층의 책임**

| 계층 | 책임 |
|---|---|
| `router` | 정규화 + host → platform 조회. 그 이상 모른다 |
| `generator` | platform 으로 선택된다. 내부에서 fetcher 를 호출하고, fetcher 가 subtype·키를 판정하고 자기 정책(유료·미지원)을 스스로 적용한다 |
| `processor` | 결과를 받아 `tokens` 에 기록하고 Writer 를 호출한다. 소셜 지식 없음 |

**`generate()` 반환 계약이 커진다.** 지금의 `SocialObjectGraph | null` 로는 "유료라 안 불렀다" · "지원 안 해서 안 불렀다" · "불렀는데 대상이 없다" 셋을 구분할 수 없는데, 이 셋이 `tokens.social_urls[].status` 에서 `skipped_paid` · `unsupported` · `not_found` 로 갈려야 한다. `attempts` 집계도 같은 문제다 — 외부를 실제로 불렀는지 아는 주체가 이제 제너레이터뿐이다.

```ts
generate(url, opts, observedAt): Promise<{
  status: FetchStatus;
  attempted: boolean;              // 외부 호출을 실제로 했는가
  graph: SocialObjectGraph | null;
}>
```

이 편이 정직하다. 현재는 processor 의 `skipped()` / `attempted()` 헬퍼가 "안 불렀을 것이다" 를 추정하는데, 새 구조에서는 부른 쪽이 직접 보고한다.

**"모르겠다" 의 표현 — `SocialPlatform | null` (A안 확정)**

`SocialPlatform` enum 에 `unknown` 이 없다. 기존 코드도 이 구멍을 알고 있고 `SOURCE_TYPE_OBJECT.unknown` 에 경고가 붙어 있다. `platform` 만 반환하기로 하면 이 구멍이 정면에 온다.

- **채택: `route()` 자체가 `RoutedUrl | null` 을 반환한다** (구현 시 정제). 초안은 `platform: SocialPlatform | null` 이었으나, 정규화 실패도 판정 실패도 결국 "쓸 수 없는 URL" 이라 **두 null 을 하나로 합쳤다.** 담당 fetcher 를 못 찾은 정상 URL 은 `web` 으로 떨어지고 담당 Generator 가 없어 `unsupported` 로 끝난다. 결과는 같고 호출부의 분기가 하나 줄었다.
- **기각: `SocialPlatform` 에 `UNKNOWN` 추가.** `platform` 은 `token_links` 에 저장돼 집계 축으로 쓰인다. 거기 `unknown` 이 섞이면 플랫폼별 집계에 정체불명 버킷이 생긴다. "판정 실패" 는 값이 아니라 **값의 부재**라서 `null` 이 뜻에 맞다.
- R-002 로 실패가 행을 만들지 않으므로 `null` → 제너레이터 없음 → `unsupported` → `tokens` 에만 기록으로 끝난다.

**정책 집합은 `social-fetcher` 패키지에 남긴다 (구현 시 변경).**

초안은 유료·미지원 정책을 각 소셜 fetcher 로 내리려 했으나, 구현하면서 `social-fetcher` 안에 두기로 바꿨다. 근거 셋이다.

1. **소비자가 이미 그쪽에 있다.** 초안은 "게이트를 주입받는 곳이 `SocialRecordProcessor` 하나뿐" 이라고 적었는데 **사실이 아니었다** — `SocialFetcherService` 도 게이트를 주입받아 쓴다(`social-fetcher.service.ts:45`·`:76`). 게이트 주석이 말한 "두 곳" 이 맞았고, 수집 경로가 빠지면 어댑터 한 곳이 남는다. 그 자리가 곧 정책의 자리다.
2. **얻는 것이 파일 경계뿐이다.** "TikTok 은 Apify 라 유료" 가 TikTok fetcher 옆에 있으면 좋지만, 흩어 놓으면 "지금 뭐가 유료지" 를 보려고 소셜 7곳을 봐야 한다.
3. **fetcher 7개를 동시에 고치는 위험이 얻는 것보다 크다.** 대부분 개별 테스트가 얇다.

**R-M1 의 목적은 이것으로 달성된다** — `sourceType` 이 프로세서·스키마로 새지 않는 것이 목적이었고, 게이트가 수집 경로에서 빠지면서 그렇게 됐다.

**정리 대상 상수 (전수 확인 결과)**

| 상수 | 현재 소비자 | 처리 |
|---|---|---|
| `PAID_SOURCE_TYPES` | 게이트 | **현행 유지** — 게이트가 어댑터 쪽에 남는다 |
| `UNSUPPORTED_SOURCE_TYPES` | 게이트 | **현행 유지** |
| `COMMUNITY_SOURCE_TYPES` | 게이트 | **현행 유지** |
| `NONE_OBJECT_SOURCE_TYPES` | **없음** | 삭제 (이미 죽은 상수) |
| `UNRESOLVED_SOURCE_TYPES` | **없음** | 삭제 (이미 죽은 상수) |
| `SOURCE_TYPE_OBJECT` | processor 의 `sourceOf()` 만 | T-M1 로 소비자 소멸 → 삭제 |

**`SocialSourceType` 자체는 남는다.** 각 소셜 fetcher 가 내부에서 자기 URL 종류를 가르는 데 계속 쓴다. 사라지는 것은 **그 값이 소셜 밖으로 새어 나가 라우터·게이트·프로세서·스키마를 지나던 경로**다.

**영향 범위 (최종 적용 시)**
- `social-fetcher.router.ts` — `route()` 반환 축소. 남아 있던 `classify`·`classifyTelegram` 은 telegram·website fetcher 로 이동(기존 T-010 계획과 같은 방향)
- `social-fetcher.types.ts` — `RoutedSource` 축소
- `social-fetch.gate.ts` — **존치.** 수집 경로에서는 빠지고 `SocialFetcherService` 에만 남는다
- `social-fetcher.consts.ts` — 죽은 상수 2종만 삭제 (위 표)
- `url-normalizer.ts` — **신설.** `normalize()` 를 라우터에서 뽑았다. 소비자가 셋이 됐다 — 라우터, fetcher 의 `classify`, 그리고 Generator(`outbound_urls` 를 저장 전에 정규화해야 한다)
- `social-fetcher.types.ts` — `SocialPlatform` enum 정의를 여기로 옮기고 `social-graph.consts` 가 재수출한다. `UrlClassifier.platform` 때문에 `common` → `modules` 역방향 의존이 생길 뻔했다
- `social-generator.interface.ts` — `handles: SocialSourceType[]` → `platform: SocialPlatform`, `generate()` 반환 타입 확장
- `social-generator.router.ts` — 등록표 축 변경 (29개 키 → 플랫폼 7개)
- `social-record.processor.ts` — 게이트 주입 제거, `skipped()`·`attempted()`·`sourceOf()` 제거
- `social-recording.consts.ts` — `SOURCE_TYPE_OBJECT` 삭제

### contents

#### C-M1 · `source_urls` 제거, `platform_key` 를 정체성 단일축으로 (확정)

**메모:** `content.model.ts:27` — "그냥 url string list 으로 관리해도 문제가 없을 것 같은데 이렇게 객체로 관리하는 이유는? ... 제거도 고려중"

**판정: `source_urls` 를 제거하고 `platform_key` 를 필수로 승격한다.** 세 객체(`contents`·`accounts`·`venues`)가 같은 정체성 규칙을 갖게 된다.

**근거 ① — 정규화 URL 은 정체성 축이 될 수 없다.**

`normalize()` 가 접지 못하는 것이 셋이다. 상세는 [url-pipeline.html](./url-pipeline.html) §2.

| 못 접는 것 | 예 |
|---|---|
| 경로 접미사 | `/status/123456/photo/1` 과 `/status/123456` 이 갈린다 |
| 모르는 쿼리 파라미터 | `?s=20` 이 남는다 (denylist 방식이라 의도적) |
| 경로 대소문자 | `canonicalUrl()` 이 `u.pathname` 을 손대지 않는다 |

**같은 차이를 분류(단계 3)는 흡수한다.** `segments.indexOf('status')` 로 위치를 찾아 그 다음 값만 읽으므로 앞뒤에 무엇이 붙든 결과가 같다. 즉 **URL 문자열로 중복을 판정하면 놓치고, 키로 판정하면 잡는다.** 현재는 놓치는 축(`findBySourceUrl`)이 먼저 돌고, 잡는 축(`findByPlatformKey`)이 나중에 돈다.

**근거 ② — 객체일 이유가 이미 없어졌다.**

`ContentSourceUrl` 이 `url` 외에 갖는 두 필드가 모두 죽어 있다.
- `status` — R-002 이후 실패는 행을 만들지 않는다. 생성 시점에 항상 `ok` 이고, `addSourceUrl` 을 부르는 유일한 곳(`writer.ts:284`)도 fetch 성공 이후 경로다. **구조적으로 `ok` 외의 값이 들어올 수 없다.**
- `observed_at` — P-1 기준 제거 대상(처리 시각).
- 둘을 읽는 `isAlive()` 는 **프로덕션 호출부가 0** 이다(테스트만 호출).

**근거 ③ — 계정·베뉴는 이미 키 단일축이다.**

`SocialGenerator` 인터페이스가 이미 못박고 있다: *"반환 트리의 계정·베뉴 객체에는 `platform_key` 가 반드시 있어야 한다. 키를 만들 수 없는 소셜은 그 객체를 아예 넣지 않는다."* 콘텐츠만 예외로 둘 이유가 없고, 예외를 두면 `Content.of()` 가 "sourceUrls 가 비면 throw" 하는 방어를 계속 들고 있어야 한다.

**기각한 메모의 근거 —** 메모는 *"어차피 token link 에 이 정보가 있는데"* 라고 적었으나 **두 값은 다르다.** `token_links.entry_url` 은 "우리가 무엇을 fetch 했나" 이고 한 응답에서 나온 객체들이 **전부 같은 값을 공유한다**(fan-out). 트윗 A 가 트윗 B 를 인용했으면 B 의 링크에도 A 의 URL 이 박히며, **B 자신의 URL 은 어디에도 없다.** 결론은 같지만 근거는 다르다 — 대체재는 `token_links` 가 아니라 `platform_key` 다.

**감수하는 위험 (단 하나) —** 제너레이터가 아직 하나도 구현되지 않아 "`platform_key` 를 항상 만들 수 있다" 가 실측이 아니다. 다만 이 위험은 **첫 제너레이터를 만들 때 즉시 드러나고**, 그때 되살리는 것은 필드 추가라 비싸지 않다. 반대로 지금 두 축을 유지하면 제너레이터 8종이 전부 두 축을 채우는 코드를 갖는다.

**영향 범위 (최종 적용 시)**
- `content.model.ts` — `ContentSourceUrl` 클래스 삭제, `source_urls` 필드 삭제, `platform_key` 를 `required` 로, `of()` 의 빈 배열 throw 삭제, `addSourceUrl()` 삭제, `isAlive()` 삭제
- 인덱스 `idx_contents_source_url` 삭제, `idx_contents_platform_key` 의 `partialFilterExpression` 삭제(필수가 되므로)
- `content.repository.ts` — `findBySourceUrl()` 삭제
- `social-graph.types.ts` — `ContentSourceUrlInput` 삭제, `ContentInput.sourceUrls` 삭제, `platformKey` 를 필수로
- `social-graph.writer.ts` — `findContent()` 가 `findByPlatformKey` 단일 호출로 축소, 재관측 시 `addSourceUrl` 루프 삭제

#### C-M2 · `ContentLinkStats` 블록 전체 삭제 (확정)

**메모:** `content.model.ts:88` — "중요! 이 link count 는 의미상 token 단위로 count 해야함" / `:95`·`:99` — "이게 존재하는 이유는? 굳이 없음 제거"

**판정: `ContentLinkStats` 클래스와 `link_stats` 필드, `updateLinkStats()` 를 전부 삭제한다.**

**현재 상태:** `updateLinkStats()` 도 `link_stats` 도 **프로덕션 호출부가 0** 이다(단위 테스트 1건만 호출). 채우는 주체인 집계 잡이 이 스코프에 없다.

**삭제 근거 — "언젠가 쓸 것 같다" 는 남길 근거가 아니다.**

되살리는 비용이 필드 추가 + 인덱스 하나이고, 스키마 미배포라 마이그레이션도 없다. 미리 둔다고 나중 작업이 쉬워지지도 않는다 — 필드 모양은 집계 잡을 설계하면서 정해지지, 미리 박아 둔 모양에 잡을 맞추면 오히려 손해다.

**그리고 지금 박혀 있는 정의가 틀렸다.** `token_links` 는 append-only 이고 유니크 제약이 없어 **행 수 ≠ 토큰 수** 다. 한 토큰이 여러 이벤트를 받으면 같은 객체에 링크가 여러 번 쌓인다. 틀린 정의를 코드에 남겨 두면 나중에 잡을 만드는 사람이 이걸 보고 행을 셀 위험이 실재한다.

**`TokenWeb`(T-M3) 과 판단이 갈린 이유** — 남길 근거는 "언젠가 쓴다" 가 아니라 **"지금 없으면 지식이 사라진다"** 다.

| | `TokenWeb` | `ContentLinkStats` |
|---|---|---|
| 필드가 담은 것 | urlscan 실측으로 추려낸 17개 필드 | 개수 하나 + 시각 셋 |
| 지우면 잃는 지식 | "왜 이 필드만 남겼나" 라는 조사 결과 | 없음 |
| 되살리는 비용 | 조사 재현 | 자명 |

**검토했고 기각한 반론 — "나래비 세울 때 매번 계산하면 되지 않나"**

성립하지 않는다. 나래비 쿼리는 `object_id` 로 묶고 `token_address` 를 중복 제거해 세는 형태인데, **정렬 대상이 저장된 값이 아니라 계산 결과라 인덱스가 돕지 못한다.** 상위 50개만 필요해도 전체를 다 세야 한다. 하루 토큰 1,000건 × URL 4개 × 객체 3개면 1년에 약 440만 행이다. **즉 캐시는 선택이 아니라 필연이다** — 다만 그것이 지금 이 필드를 남길 이유는 되지 못한다. 아래 요구사항 ③ 참조.

**집계 잡을 만들 때 지켜야 할 요구사항 (지식 보존)**

1. **`link_count` 는 `distinct token_address` 다.** 행 수를 세면 재처리 횟수가 인기도로 둔갑한다.
2. **파생값에는 `computed_at` 과 `input_cutoff` 를 반드시 함께 세팅한다.** 소비 측이 그 값을 신뢰할 근거가 이 둘뿐이다. 하나라도 빠지면 "언제 계산했고 어디까지 봤는지 모르는 숫자" 가 된다.
3. **라이브 캐시와 백테스트 재계산은 다른 경로다.** 캐시된 숫자는 "지금 기준" 한 벌이라 as-of 질의(T 시점 기준)에는 쓸 수 없다. 배치 형태는 잡 설계 시점에 정한다 — 후보는 (가) `contents` 의 필드, (나) `(cutoff, object_id, count)` 별도 컬렉션, (다) 둘 다.

**영향 범위 (최종 적용 시)**
- `content.model.ts` — `ContentLinkStats` 클래스 · `link_stats` 필드 · `updateLinkStats()` 삭제
- `test/unit/social-graph.model.spec.ts` — 해당 테스트 1건 삭제

#### C-M3 · `Content.observed_at` 제거 (확정)

**메모:** `content.model.ts:186` — "동일하게 created_at 과 차별점?"

**판정: 제거한다.** P-1 의 두 단계를 모두 통과하지 못한다.

**1단계 — `created_at` 과 구조적으로 갈리는가: 갈리지 않는다.**

R-009 이후 재관측이 기존 행을 덮지 않는다(`social-graph.writer.ts` — "재관측이라도 내용은 덮지 않는다"). 즉 `contents` 행은 **생성 후 불변**이고, `observed_at` 은 영원히 생성 시각에 머문다. `created_at` 과의 차이는 fetch 소요 시간뿐이며 이는 인프라의 성질이다.

**읽는 코드도 없다.** 유일한 독자는 `updateMetricsLatest()` 의 역행 방지 가드인데, R-009 로 "지표는 생성 시점 1회" 가 되면서 **`updateMetricsLatest()` 자체의 프로덕션 호출부가 0** 이다(테스트만 호출).

**나중에 지표 갱신 프로세스가 생기면 무엇이 대신하는가** — 그때 필요한 값은 "마지막으로 관측한 시각" 이고, `updated_at` 이 이미 그 일을 한다(mongoose 가 저장 시마다 갱신). 이 행에는 관측 말고 쓸 일이 없어 두 값이 같다.

**재검토 트리거:** 관측이 아닌 이유로 `contents` 행을 쓰는 주체가 생기면 `updated_at` 과 "마지막 관측 시각" 이 갈린다. 그때 이 항목을 다시 연다.

**함께 사라지는 것:** `ContentSourceUrl.observed_at` 은 C-M1 의 `source_urls` 삭제로 같이 없어진다.

**같은 판정이 걸리는 곳:** `accounts.observed_at` · `venues.observed_at` 도 동일 상태다. `accounts.isObservedLaterThan()` 역시 프로덕션 호출부가 0 이다. 각 모델 항목에서 확정한다.

**영향 범위 (최종 적용 시)**
- `content.model.ts` — `observed_at` 필드 삭제, `of()` 에서 대입 삭제, `updateMetricsLatest()` 의 역행 가드 삭제(메서드 존치 여부는 지표 논의에서 결정)
- `social-graph.types.ts` — `ContentInput.observedAt` 삭제

#### C-M4 · `ContentAuthorSnapshot` 전체 삭제 (확정)

**메모:** `content.model.ts:207` — "이게 추가된 이유가 뭐지? 헷갈리는데 이미 id 가 있는데. creator 와 author 라는 개념의 차이"

**판정: `ContentAuthorSnapshot` 클래스와 `author` 필드를 삭제한다. `creator` 와 `author` 는 개념 차이가 없다 — 같은 대상이다.**

**근거 ① — 이 블록은 구조적으로 채울 수 없다.**

`ContentAuthorSnapshotInput.accountId` 가 필수인데, `ContentInput` 을 만드는 주체는 **제너레이터**이고 제너레이터는 `_id` 를 볼 수 없다. `_id` 는 Writer 가 `resolveAccount()` 로 계정 행을 만들거나 찾을 때 생긴다. 그리고 **Writer 는 `author` 를 건드리지 않는다**(`social-graph.writer.ts` 에 `author` 가 한 번도 등장하지 않는다). 유일한 생산자는 값을 만들 수 없고, 유일한 조립자는 손대지 않는다. 영원히 `undefined` 다.

**근거 ② — 담으려던 값 5개 중 4개가 이미 다른 곳에 있다.**

| 필드 | 다른 곳 | 판정 |
|---|---|---|
| `account_id` | `creator_id` | 명백한 중복 |
| `handle` | `accounts.handles[]` 의 소유 **구간** (`hadHandleAt()`) | 중복 — 그쪽이 더 강하다 |
| `account_created_at` | `accounts.account_created_at` (불변) | 중복 (조인 회피용 복사) |
| `followers` | `metric_series` 가 담당할 값 | 원리상 중복 |
| `display_name` | `accounts.display_name` 은 현재값만 | 여기에만 있는 값 |

`handle` 이 특히 그렇다. `accounts.handles[]` 는 값 하나가 아니라 소유 구간(`first_seen`~`last_seen`)을 담고 `hadHandleAt(handle, at)` 이 "그 시점에 이 계정이 그 핸들을 갖고 있었나" 를 판정한다. 스냅샷의 값 하나보다 강하며, TikTok 처럼 핸들을 회전시키고 재할당하는 경우까지 다룬다.

**근거 ③ — 클래스 주석의 존재 근거가 무너졌다.**

주석은 *"마스터는 덮이므로 이 값이 없으면 그때 팔로워 수를 잃는다"* 고 적혀 있으나, **R-009 에서 재관측이 기존 행을 덮지 않도록 바꿨다.** 시점별 지표는 `metric_series` 가 담당하는 구조다.

**잃는 것:** `display_name` 의 시점값 하나. 그것을 위해 임베디드 문서를 유지할 값은 아니며, 애초에 지금 채워지지도 않는다.

**영향 범위 (최종 적용 시)**
- `content.model.ts` — `ContentAuthorSnapshot` 클래스 · `author` 필드 · `of()` 의 대입 삭제
- `social-graph.types.ts` — `ContentAuthorSnapshotInput` 삭제, `ContentInput.author` 삭제

### 지표 구조 (contents · accounts · venues · metric_series 동시)

#### M-M1 · `Mixed` + 화이트리스트를 버리고 명시적 타입 객체로 (확정)

**메모:** `content.model.ts:211` — "record 로 관리 vs metrics 객체를 하나 정의하고 모든 종류의 소셜 field 를 1뎁스로 정의하고 채우는 방식. 후자가 더 나은 것 같긴한데 (MIX type 을 피하고 싶은 것도 있고, 관리가 안 되니)" / `account.model.ts:112` · `venue.model.ts:73` · `metric-series.model.ts:28` — 같은 질문

**판정: 객체 종류별로 타입 클래스를 셋 두고, `Mixed` 와 `METRIC_SPEC`·`pickKnownMetrics` 를 제거한다. `metric_series` 는 콘텐츠 전용으로 좁힌다.**

**현재 상태가 이미 갈려 있었다.**

| 위치 | 현재 |
|---|---|
| `contents.metrics_latest` | `Mixed` |
| `accounts.metrics` | 타입 객체 `AccountMetrics { followers? }` |
| `venues.metrics` | 타입 객체 `VenueMetrics { members? }` |
| `metric_series.points[].metrics` | `Mixed` |

계정·베뉴는 이미 명시적 객체였고, 콘텐츠와 시계열만 `Mixed` 였다.

**근거 ① — 화이트리스트는 타입이 이미 막는 구멍을 한 번 더 막고 있다.**

`METRIC_SPEC` 의 존재 이유는 "소셜이 새 지표를 추가한 것을 놓치지 않기" 인데, **그 발견은 저장 계층이 아니라 fetcher 계층에서 일어난다.** 이 레포는 fetcher 응답을 이미 명시적으로 타이핑한다(`XTweet`·`TiktokVideo`·`RedditPost`). 소셜이 지표를 추가하면 그 타입을 고치는 사람이 먼저 본다. 지금은 저장 쪽 갱신을 놓치면 런타임에 조용히 버려지고 로그만 남지만, 타입 객체면 **컴파일이 깨진다.**

**근거 ② — 지금 구조는 스키마를 포기하고 그 역할을 함수로 재구현한 것이다.**

`Mixed` 를 쓰는 순간 mongoose 검증이 0이 되고, 그것을 메우려고 레지스트리 하나와 필터 함수 하나가 생겼다. DB 가 공짜로 해주는 일을 코드로 다시 만든 것이며, 재구현이라 버그가 났다.

**근거 ③ — 실제 버그가 이 구조 때문에 생겼다.**

`METRIC_SPEC` 의 8개 집합이 **전부 콘텐츠 응답 모양**이다(`// XTweet`·`// TiktokVideo`). `followers` 는 어느 집합에도 없다. 그래서 계정 지표가 전량 버려지고 빈 점 `{ at, metrics: {} }` 이 저장된다. `recordFirstPoint` 가 `$setOnInsert` 라 **나중에 화이트리스트를 고쳐도 그 빈 점은 채워지지 않는다.** 판정축이 "플랫폼 × 키" 였고 **객체 종류** 축이 빠져 있었는데, 그 사실을 아무것도 알려주지 않았다.

**클래스를 셋으로 나누는 이유 — 시계열을 좁히는 결정과 한 몸이다.**

`metric_series` 가 다형(콘텐츠·계정·베뉴)인 한 `points[].metrics` 는 세 모양을 다 담는 하나의 타입이어야 한다. 배열 안에서 판별 유니온을 쓰려면 discriminator 를 동원해야 하고 복잡도가 얻는 것보다 크다. **즉 다형을 유지하면 결국 단일 뭉치 클래스로 돌아간다.** 시계열을 콘텐츠 전용으로 좁혀야 클래스를 나눌 수 있다.

**확정 구조**

```ts
export class ContentMetrics {          // 23필드, 전부 optional
  // X — viewCount·likeCount 는 YouTube 공용
  viewCount?  likeCount?  replyCount?  retweetCount?  quoteCount?  bookmarkCount?
  // TikTok — commentCount 는 YouTube 공용
  playCount?  diggCount?  commentCount?  shareCount?  collectCount?  repostCount?
  // Instagram — commentsCount 는 Reddit 공용
  likesCount?  commentsCount?  videoViewCount?  videoPlayCount?
  // Reddit
  upVotes?  score?  upvoteRatio?  numCrossposts?
  // GitHub — 러그 검증 채널이라 도달·호응 축에 매핑하지 않는다
  stars?  forks?  openIssues?
}
export class AccountMetrics { followers?: number }   // 현행 그대로
export class VenueMetrics   { members?: number }     // 현행 그대로
```

키 이름은 지어내지 않고 **각 fetcher 반환 타입의 실제 필드명**을 그대로 쓴다(§1.8). 통일하는 순간 TikTok 의 자동재생과 YouTube 의 30초 시청이 같은 칸에 들어간다.

**`metric_series` 는 콘텐츠 전용이 된다.**

```ts
export class MetricPoint {
  at: Date;                  // P-1 로 제거되지 않는다 — 배열 요소라 부모의 created_at 이 대신할 수 없고,
                             // 값의 출처가 소셜의 크롤 시각(rd·tt 의 crawledAt)이라 세상의 성질이다
  metrics: ContentMetrics;   // Mixed → 타입 객체
}

@index({ content_id: 1 }, { unique: true, name: 'uniq_series_content' })
export class MetricSeries extends BaseModel {
  content_id: Types.ObjectId;   // (object, object_id) 다형 → 단일 참조
  points: MetricPoint[];
}
```

`object` 필드와 `ObjectKind` 다형이 이 컬렉션에서 사라진다. `platform` 도 함께 사라진다 — M-M3 참조.

**네이밍 — 현재 이름이 그대로 맞고, 이제 근거가 생긴다.**

| 컬렉션 | 필드명 | 이유 |
|---|---|---|
| `contents` | `metrics_latest` | 시계열이 있으므로 "최신 한 점의 캐시" 가 정확하다 |
| `accounts` · `venues` | `metrics` | 시계열이 없으므로 `_latest` 가 가리킬 대상이 없다 |

**감수하는 대가 ① — 런타임 경고 상실.**

mongoose strict 모드가 스키마에 없는 경로를 **조용히 버린다**. 그리고 타이핑이 이를 100% 막지 못한다 — 제너레이터가 `metrics: { ...response.stats }` 처럼 스프레드로 넘기면 TS 의 초과 속성 검사가 적용되지 않아 컴파일을 통과한다.

**대응은 계약으로 한다: 제너레이터는 지표를 필드 단위로 명시 매핑한다. 스프레드로 넘기지 않는다.** 스프레드는 숫자가 아닌 필드까지 딸려 들어와 현재 구조에서도 잘못된 코드다.

`strict: 'throw'` 로 시끄럽게 만드는 안은 기각했다 — 소셜이 지표 하나 추가했다고 수집 파이프라인이 멈추는 것이 더 나쁘다.

**감수하는 대가 ② — 팔로워·멤버 수의 시간 변화를 담을 곳이 없어진다.**

"큰 계정이 이 토큰을 밀었다" 에서 팔로워 **증가율**을 볼 수 없고 현재값만 본다.

**다만 지금은 실질 손실이 0 이다.** R-009 로 지표가 생성 시 1회만 기록되므로 계정 시계열이 있어도 점이 하나뿐이고, 그 값은 `accounts.metrics.followers` 와 같다. 손실은 **계정 폴링 주체가 생기는 시점**에 비로소 발생한다.

**재검토 트리거:** 계정·베뉴를 주기적으로 폴링하는 주체가 생기면 시계열 배치를 다시 정한다(콘텐츠 시계열에 합칠지, 별도 컬렉션을 둘지). C-M2 와 같은 논리 — 되살리는 비용이 작고, 지금 두면 빈 구조만 남는다.

**영향 범위 (최종 적용 시)**
- `metric-spec.ts` — **파일 삭제** (`METRIC_SPEC` · `pickKnownMetrics`)
- `content.model.ts` — `metrics_latest` 타입을 `ContentMetrics` 로, `of()` 의 `pickKnownMetrics` 호출 삭제, `updateMetricsLatest()` 의 필터 삭제
- `metric-series.model.ts` — `MetricPoint.metrics` 타입 변경, `object`·`platform` 필드 삭제, `object_id` → `content_id`, 인덱스 2종 → 1종(M-M3)
- `metric-series.repository.ts` — `recordFirstPoint` 시그니처에서 `object` 제거
- `social-graph.writer.ts` — `recordFirstMetrics()` 가 콘텐츠에만 적용되도록 축소
- `social-graph.types.ts` — 지표 입력 타입 3종 명시
- `test/unit/social-graph.metric-spec.spec.ts` — 삭제

### 보류 항목

#### H-1 · 동시 생성 중복 — 별도 순차 처리 방식으로 해결 예정 (이번 스코프 제외)

**상태: 사용자가 인지하고 있으며, 별도의 순차 처리 방식을 도입할 예정이다. 이번 전수조사에서는 스키마를 바꾸지 않는다.**

**발생 케이스**

1. 락이 **토큰 주소 단위**라 서로 다른 두 토큰이 동시에 처리되면 락이 서로를 막지 못한다.
2. 그 둘이 같은 트윗이나 같은 계정을 가리키면(인기 트윗을 여러 토큰이 걸거나, 한 셀럽 계정이 여러 토큰에 등장) 두 처리가 같은 행을 동시에 만들려 한다.

**현재 동작이 컬렉션마다 다르다**

| 컬렉션 | 인덱스 | 동시 생성 시 |
|---|---|---|
| `accounts` · `venues` | unique (`uniq_accounts_platform_key` 등) | 예외가 터져 그 토큰 처리가 통째로 죽는다 |
| `contents` | non-unique (`idx_contents_platform_key`) | 조용히 두 행이 생긴다 |

**E11000 처리 코드가 레포 어디에도 없다.** `content.repository.ts:18` 이 이미 그 사실을 기록해 뒀다 — *"E11000 복구 경로는 존재하지 않는다."* 즉 `accounts`·`venues` 의 unique 인덱스는 현재 방어가 아니라 예외 발생 지점이다.

**이번에 결정하지 않은 것**

- `idx_contents_platform_key` 를 unique 로 올릴지 — **순차 처리 방식이 정해진 뒤에 판단한다.** 순차화가 중복을 원천 차단하면 unique 는 이중 방어가 되고, 그렇지 않으면 필요해진다.
- E11000 재조회 경로를 리포지토리에 넣을지 — 위와 같은 이유로 함께 판단한다.

**함께 기억할 것 — 웹 작업 시 충돌 (T-M3 조건)**

`website` 의 `platform_key` 는 apex 도메인이다(`apexDomain(host)`). 같은 도메인의 서로 다른 페이지가 `(web, site, example.com)` 으로 충돌한다. 지금은 `site`·`unknown` 콘텐츠가 생성되지 않아(R-002 로 실패가 행을 만들지 않고, 담당 제너레이터도 없다) 드러나지 않는다. **웹 수집을 켤 때 `platform_key` 정의를 페이지 단위로 바꿔야 한다.**

#### M-M2 · `data` 도 타입 객체로 — `Mixed` 를 스키마에서 완전히 제거 (확정)

**메모:** `content.model.ts:219` — "메트릭과 동일한 고민. 객체로 관리 vs Record 관리"

**판정: `contents.data` · `accounts.data` · `venues.data` 를 타입 클래스로 바꾼다. 필드는 미리 다 열거하지 않고 제너레이터를 추가할 때마다 늘린다.**

**"지금 타입화하면 추측이 된다" 는 반대 근거가 이 방식으로 해소된다.** 전부 열거하는 것이 아니라 채우는 주체가 생길 때 함께 늘리므로 추측할 일이 없다.

**성격이 바뀐다:** "검증 안 되는 잡동사니 통" → **"플랫폼별 부가 필드의 네임스페이스"**.

**중첩을 유지하는 이유** — 타입이 붙으면 `contents.data.badges` 와 `contents.badges` 를 가르는 원래 근거("구조가 없는 나머지")가 사라진다. 그럼에도 중첩을 유지하는 것은 **읽는 사람** 때문이다. 모델을 열었을 때 소셜 공통 의미축(`platform`·`subtype`·`creator_id`·`published_at`)과 플랫폼별 부가 정보가 눈으로 갈린다. 전부 최상위로 올리면 핵심 필드가 긴 꼬리에 묻힌다.

**어디에 둘지 가르는 규칙 3가지**

| 값의 성격 | 자리 |
|---|---|
| 쿼리·정렬·비교의 축이거나, 소셜 공통 의미를 갖는다 | **최상위 필드** |
| 플랫폼별 부가 정보이고, 문서를 쥔 뒤 읽는다 | **`data` 안의 타입 필드** |
| 구조가 복잡하고(중첩 객체·객체 배열) 쓸 계획이 없다 | **버린다.** 루트 객체면 `object_raw` 가 원문을 갖고 있다 |

세 번째가 경계를 지킨다. **타입화의 비용은 필드 개수가 아니라 중첩에서 온다** — 어떤 소셜이 객체 배열을 돌려주면 서브클래스를 또 만들어야 하고, 그러면 부가 정보 하나에 클래스 트리가 생긴다.

**시작점은 비우지 않고 설계 감사 결과로 채운다.** 빈 클래스로 시작하면 감사가 확인한 목록이 코드에서 사라진다(T-M3 `TokenWeb` 과 같은 논리).

| 컬렉션 | 씨앗 | 근거 |
|---|---|---|
| `accounts.data` | `account_type` · `source_ref` · `badges` · `content_count` | 설계 §1.4 |
| `venues.data` | `moderators` · `admin_handle` | 설계 §1.5 |
| `contents.data` | **없음 — 빈 클래스로 시작** | 설계 문서에 목록이 없다. 첫 제너레이터가 채운다 |

**`link_stats`(C-M2) 와 판정이 갈린 이유 — 생산자의 유무다.**

`link_stats` 는 채우려면 별도의 집계 잡이 필요한데 그것이 없었다. `data` 는 **객체를 만드는 그 코드가 같이 채운다** — 제너레이터가 생기는 순간 자동으로 찬다. "채울 주체가 없는 빈 구조" 와 "채울 주체가 확정돼 있는데 아직 만들지 않은 것" 은 다르다.

**얻는 것**
- **`Mixed` 가 `object_raw.payload` 하나만 남는다.** `data` 3곳이 마지막이었다(M-M1 이 지표를, C-M1 이 `source_urls` 를 이미 걷어냈다). `payload` 는 정의상 무손실 덤프라 `Mixed` 가 유일하게 옳은 자리다 — 아래 O-M1 참조.
- 지표와 같은 계약이 선다 — 제너레이터는 필드 단위로 명시 매핑하고 스프레드로 넘기지 않는다.
- 클래스 자체가 "이 소셜에서 무엇을 가져오는가" 의 문서가 된다.

**감수하는 대가:** M-M1 과 동일하다. mongoose strict 모드가 스키마에 없는 경로를 조용히 버리며, 스프레드로 넘기면 TS 가 막지 못한다. 대응도 동일하게 계약으로 한다.

**영향 범위 (최종 적용 시)**
- `content.model.ts` · `account.model.ts` · `venue.model.ts` — `data` 타입을 각 클래스로 교체
- `social-graph.types.ts` — `*Input.data` 타입 3종 명시

### accounts

#### A-M1 · `AccountHandle.last_seen` 제거 · 핸들 조회를 플랫폼으로 좁힘 (확정)

**메모:** `account.model.ts:18` — "first, last seen 은 뭐야? last 는 의미도 없는 것 같은데. 그리고 handle 이라는 개념이 어떤 social 에서 사용되는지 체크필요"

**판정: `last_seen` 을 제거하고 `first_seen` 만 남긴다. `handles[]` 배열 자체는 유지한다.**

**먼저 사실 확인 — 어떤 소셜이 핸들을 쓰는가**

| 소셜 | 핸들 | 성격 |
|---|---|---|
| X | `screen_name` | 가변. 안정 id 는 숫자 |
| TikTok | `username` | **가변 + 재할당** — 옛 핸들이 다른 사람에게 간다 |
| Instagram | `username` | 가변 |
| YouTube | `@handle` | 가변. 안정 id 는 channel id |
| GitHub | `login` | **가변 + 반납된 이름 재사용 가능** |
| Reddit | `username` | **불변·재사용 불가** → `platform_key` 와 같은 값 |
| Telegram | — | 계정 객체 자체가 없다 (Venue 전용) |

**Reddit 도 `handles[]` 에 넣는다.** `platform_key` 와 값이 중복되지만, 빼면 핸들 조회가 소셜마다 다르게 동작한다. 중복 몇 바이트보다 일관성이 낫다.

**`last_seen` 제거 근거 — 소유 구간이 아니라 관측 구간이다.**

우리는 토큰이 그 계정을 걸 때만 관측한다. 표본이 희소하고 임의적이라 "마지막으로 본 시각" 이 소유의 끝을 알려주지 않는다. 정밀해 보이지만 정밀하지 않으면서, 읽는 사람에게 "이건 소유 구간이다" 라고 오해시킨다.

**`first_seen` 배열이 필요한 것을 이미 다 준다.**

| 알고 싶은 것 | `first_seen` 만으로 |
|---|---|
| 현재 핸들 | 배열 마지막 |
| 개명 이력 | 배열 순서 |
| 개명 시점 | 다음 엔트리의 `first_seen` |
| 대략의 소유 구간 | `[first_seen[i], first_seen[i+1])` |

**잃는 것:** "이 구간은 모른다" 를 표현하지 못하고 "다음 핸들이 나타날 때까지 이전 핸들이었다" 로 메우게 된다. **그 구분이 필요한 곳이 없다** — 그것을 쓰라고 만든 `hadHandleAt()` 과 `findByHandle()` 이 프로덕션 호출부 0 이고, 정체성 축은 `platform_key` 로 확정됐고, 멘션은 계정으로 해석하지 않으며(D-6), 베뉴 개설자는 핸들 fallback 을 금지한다. **핸들로 계정을 찾는 경로 자체가 설계상 없다.**

**확정 모양**

```ts
/** 핸들 관측 이력. 배열 순서가 개명 순서다. */
export class AccountHandle {
  value: string;
  /** 이 핸들을 처음 본 시각. 소유의 시작이 아니라 **관측의 시작**이다. */
  first_seen: Date;
}
```

- `addHandleObservation` — 마지막 값과 같으면 아무것도 하지 않고, 다르면 push
- `hadHandleAt()` — **삭제**
- `currentHandle()` — 배열 마지막을 반환 (`last_seen` 비교 불필요)

**인덱스 — `idx_accounts_handle` 을 `(platform, handles.value)` 복합으로 바꾼다.**

지금은 `handles.value` 단독이라 `findByHandle('elonmusk')` 이 X·TikTok·GitHub 를 전부 긁는다. `platform_key` 조회와 같은 모양으로 맞춘다.

**핸들 누적은 이번 스코프가 아니다 (A-M1-a, 보류)**

`resolveAccount` 가 기존 계정을 찾으면 그대로 반환하고 아무것도 갱신하지 않는다(`writer.ts:326`). 그래서 `addHandleObservation` 은 **생성 시 1회만** 불리고, `handles[]` 엔트리는 항상 1개다.

이것을 고치는 것은 **재관측 업데이트 작업**에 속한다 — `urls`·`bio`·`metrics` 를 어디까지 덮을지 함께 정해야 하는 별도 결정이며, `writer.ts` 의 `// MEMO: entity 가 있을 때 ... 업데이트` 가 그 자리를 가리키고 있다. 이번 전수조사는 스키마까지만 다룬다.

> **2026-08-13 — 그 결정이 났다(축 ⑤). 답은 "재관측에서는 안 고친다" 다.** `addHandleObservation` 이 생성 시 1회만 불리는 것은 결함이 아니라 최종 동작이고, `handles[]` 누적은 **주기성 갱신 프로세스**가 맡는다. 아래 "지식이 담겨 있다" 는 그대로 유효하다 — 다만 그 지식이 쓰일 자리가 재관측 경로가 아니라 그 프로세스다.

**`link_stats`(C-M2) 와 판정이 갈린 이유** — `link_stats` 는 없는 집계 잡이 필요했지만, 핸들 누적은 **이미 있어야 할 코드가 빠진 것**이다. 그리고 여기에는 지식이 담겨 있다("TikTok 은 핸들을 재할당한다", "그래서 값이 아니라 이력이어야 한다").

#### A-M2 · `declared_handles` 를 `data` 로 이동 (확정)

**메모:** `account.model.ts:99` — "이것도 정확한 목적 파악필요"

**정체:** 본인이 프로필에 **스스로 적어 넣은** 타 플랫폼 핸들이다. 실질적으로 **GitHub 의 `twitter_username` 하나**이며(주석: "사실상 gh `twitter_username`"), 용도는 크로스 플랫폼 계정 연결이다 — "이 GitHub 소유자가 저 X 계정과 같은 사람" 이라는 신호.

**현재 상태:** 생산자도 소비자도 없다. 인덱스도 없다. `platform` 이 `SocialPlatform` enum 이 아니라 `string` 이다.

**판정: `accounts.data.declared_handles` 로 내린다.** M-M2 의 배치 규칙을 그대로 적용한 결과다.

| 규칙 | 판정 |
|---|---|
| 쿼리·정렬·비교의 축인가 | 아니다 — 인덱스도 소비자도 없다 |
| 소셜 공통 의미를 갖는가 | 아니다 — GitHub 전용이다 |
| → 자리 | `data` 안의 타입 필드 |

**참조가 아니라 값으로 남기는 것은 기존 원칙과 일치한다** — 우리가 관측하지 않은 대상이라 `_id` 로 걸 수 없다(D-6, 멘션과 같은 논리).

**승격 조건:** 크로스 플랫폼 연결이 실제 기능이 되면 최상위로 올리고 인덱스를 건다. M-M2 의 승격 규칙이 이 경우를 위해 있다.

#### A-M3 · `moderation` 객체를 펼쳐 최상위 `unavailable` 로 (확정)

**메모:** `account.model.ts:116` — "객체화한 이유는?"

**답: 원래 2필드였고 하나가 빠지면서 껍데기만 남았다.** 주석이 그 흔적이다 — *"`unavailable_reason` 은 쿼리 축이 아니라 제거됐다(§1.11)"*.

**읽는 곳도 사라졌다.** 유일한 소비자가 `token_links.status` 였는데, R-007 에서 **"`accounts.moderation.unavailable` 의 낡은 사본"** 이라는 이유로 제거됐다.

**판정: `AccountModeration` 클래스를 없애고 최상위 `unavailable?: boolean` 으로 펼친다.**

**`data` 로 내리지 않는 이유** — M-M2 규칙의 첫 줄에 걸린다. "살아있는 계정만" 은 **실제 쿼리 축**이고 소셜 공통 의미다. 축이 될 값은 최상위에 둔다.

필드 하나짜리 네임스페이스는 중첩 한 겹을 값 없이 쓴다. 나중에 moderation 관련 필드가 늘면 그때 다시 묶으면 되고, 그 비용은 필드 추가와 같다.

**이름은 `unavailable` 을 유지한다.** 정지·삭제·비공개를 한 단어로 덮으며, 어느 쪽인지는 `unavailable_reason` 제거로 이미 "보지 않는다" 가 결정됐다.

#### A-M4 · `Account.observed_at` 제거 (확정)

**메모:** `account.model.ts:120` — "동일 필요한 이유"

**판정: 제거한다.** C-M3(`contents.observed_at`)와 동일한 판정이며 근거도 같다.

- **읽는 코드가 없다** — 유일한 독자인 `isObservedLaterThan()` 의 프로덕션 호출부가 0 이다(테스트만 호출).
- **`created_at` 과 구조적으로 갈리지 않는다** — `resolveAccount` 가 기존 계정을 갱신하지 않으므로(`writer.ts:326`) 이 값은 영원히 생성 시각에 머문다.
- **`isObservedLaterThan()` 도 함께 삭제한다.**

**`addHandleObservation` 의 인자는 남는다.** `first_seen` 을 채우려면 관측 시각이 필요한데, 그것은 **저장된 필드가 아니라 입력값**(`input.observedAt`)에서 온다. 필드를 지워도 인자 경로는 그대로다.

~~**재검토 트리거:** 재관측 업데이트 작업(A-M1-a)에서 "들어온 관측이 저장된 것보다 최신인가" 판정이 필요해지면, `updated_at` 으로 충분한지 여기서 다시 본다.~~
→ **트리거가 사라졌다**(2026-08-13). 재관측이 아무것도 안 덮으므로 "최신인가" 를 물을 자리가
없다. 같은 판정이 필요해지는 곳은 **주기성 갱신 프로세스**이고, 그쪽은 자기가 언제 읽었는지를
스스로 알므로 저장된 관측 시각에 기대지 않는다.

**`venues.observed_at` 도 같은 판정이다.** venues 항목에서 확정한다.

### venues

#### V-M1 · 정체성 축에서 `subtype` 제거 — 세 객체를 `(platform, platform_key)` 로 통일 (확정)

**메모:** 없음 — 전수조사 중 발견. `contents` 에도 같이 적용한다.

**판정: 정체성 축을 `(platform, platform_key)` 로 통일한다. `subtype` 필드 자체는 유지한다 — 정체성이 아니라 속성이다.**

**발견 — 막으라고 건 unique 인덱스가 오히려 갈라짐을 허용한다.**

```ts
@index({ platform: 1, subtype: 1, platform_key: 1 },
       { unique: true, name: 'uniq_venues_platform_key' })
```

Telegram 은 같은 채널이 `channel` · `portal` · `shell` 로 갈리고 그 판정이 **fetch 후에** 나온다. 주석은 그 사실을 근거로 `subtype` 을 축에 넣었다고 적고 있는데, 그것이 정확히 문제를 만든다.

```
1월: 토큰 A 가 t.me/foo 를 검. 메시지 0 → subtype = shell
     → venues 행 생성 (tg, shell, foo)
3월: 토큰 B 가 t.me/foo 를 검. 커뮤니티 활성화 → subtype = channel
     → findByPlatformKey(tg, channel, foo) 가 못 찾음
     → venues 행 하나 더 생성 (tg, channel, foo)
```

**같은 채널이 두 행이 되고, unique 인덱스는 키가 다르므로 막지 못한다.** 드문 경우도 아니다 — 실측이 **빈껍데기 25%** 인데, 빈 채널이 나중에 차는 것은 토큰 프로젝트의 정상 진행이다.

**근거 — `subtype` 은 "무엇인가" 가 아니라 "지금 어떤 상태인가" 다.**

`shell` → `channel` 은 다른 베뉴가 된 것이 아니라 **같은 베뉴가 채워진 것**이다. 상태를 정체성에 넣으면 상태가 바뀔 때마다 새 객체가 생긴다.

`platform_key` 는 소셜별 안정 식별자라 `subtype` 없이도 유일하다 — Telegram 은 `groupId`(가변인 채널명이 아니다), Reddit 은 slug(불변), X 커뮤니티는 id.

**`contents` 에도 같이 적용한다.**

`idx_contents_platform_key` 도 `(platform, subtype, platform_key)` 이고 `findContent` 가 같은 모양으로 부른다. **위험은 낮지만 0 이 아니다** — 대상의 상태가 변해서가 아니라 **우리 판정이 바뀔 때** 생긴다.

- `tiktok_video` — 슬라이드쇼면 `isSlideshow` 로 `video`/`photo` 가 갈린다
- `instagram_post` — `post`/`reel`/`tv` 가 병합돼 들어온다

즉 제너레이터를 고치는 순간 같은 콘텐츠가 다른 subtype 으로 분류되고 행이 갈린다.

**"덜 생긴다" 는 "안 생긴다" 가 아니고, `subtype` 이 정체성이 아니라는 판단은 두 컬렉션에서 같다.** 통일하면 정체성 규칙을 한 문장으로 말할 수 있게 된다.

**확정 후 세 객체의 정체성 축**

| 컬렉션 | 정체성 축 | `subtype` 필드 |
|---|---|---|
| `accounts` | `(platform, platform_key)` | **없음** — `platform` 을 알면 종류가 결정된다(gh→owner, rd→user) |
| `venues` | `(platform, platform_key)` | **있음** — portal/shell/channel 상태 |
| `contents` | `(platform, platform_key)` | **있음** — tweet/video/post 종류 |

`accounts` 와 같아지는 것은 **정체성 축의 모양**뿐이고 필드 구성은 다르다.

**`subtype` 은 여전히 조회축으로 쓴다** — `idx_contents_platform_subtype` · `idx_links_platform_linked` 가 그대로 남는다. 정체성 축에서만 뺀다.

**영향 범위 (최종 적용 시)**
- `venue.model.ts` — `uniq_venues_platform_key` 를 `(platform, platform_key)` 로
- `content.model.ts` — `idx_contents_platform_key` 를 `(platform, platform_key)` 로 (unique 여부는 H-1 에서 별도 판단)
- `venue.repository.ts` · `content.repository.ts` — `findByPlatformKey` 에서 `subtype` 인자 제거
- `social-graph.writer.ts` — `resolveVenue` · `findContent` 의 호출 인자 축소
- **주기성 갱신 프로세스에 항목 추가:** 기존 행의 `subtype` 이 달라졌으면 갱신한다. 그것이 이 변경이 노리는 동작이다.
  ⚠️ **재관측 경로(`resolveVenue`)에서 하지 않는다**(2026-08-13 확정) — 거기서 갱신하면 `shell` → `channel` 전이 시각이
  "실제로 승격된 때" 가 아니라 "누군가 이 URL 을 건 때" 가 된다. 지표를 최초 1회로 묶은 근거(R-009)와 같다

#### V-M2 · `Venue.observed_at` 제거 · `metrics` 는 M-M1 로 해결 (확정)

- **`observed_at`** — C-M3 · A-M4 와 동일 판정. 읽는 코드가 없고, `resolveVenue` 가 기존 베뉴를 갱신하지 않으므로(`writer.ts:349`) `created_at` 과 구조적으로 갈리지 않는다.
- **`metrics`** (메모 `venue.model.ts:73`) — M-M1 에서 확정. `VenueMetrics { members? }` 를 현행 그대로 유지하며, 시계열 대상이 아니므로 이름도 `metrics` 그대로다.
- **곁가지:** `VenueMetrics` 클래스에 붙은 주석 *"`moderators` 는 `data` 로 내려갔다(§1.5)"* 는 지표와 무관한 내용이 잘못 붙은 것이다. `data` 필드 쪽으로 옮긴다.

### social-graph.consts

#### E-M1 · subtype enum 에 플랫폼·생성가능 여부 주석 추가 (확정)

**메모:** `social-graph.consts.ts:54` · `:77` — "최소한 주석으로라도 어떤 플랫폼의 type 인지 명시 필요할듯"

**판정: 각 값에 담당 플랫폼과 "현재 생성 가능한가" 를 주석으로 단다. 값은 삭제하지 않는다.**

**조사 중 드러난 것 — `ContentSubtype` 값의 절반 이상이 지금 생성될 수 없다.**

R-002 로 실패가 행을 만들지 않고, R-M1 로 미지원 타입에는 제너레이터가 없다. **담당 제너레이터가 없으면 그 subtype 은 영영 생기지 않는다.**

| `ContentSubtype` | 플랫폼 | 생성 가능? |
|---|---|---|
| `tweet` | X | ✅ |
| `video` | TikTok · YouTube | ✅ |
| `post` | Instagram · Reddit | ✅ |
| `repo` | GitHub | ✅ |
| `photo` | TikTok (`isSlideshow`) | ✅ fetch 후 세분화 |
| `reel` · `tv` | Instagram | ✅ fetch 후 세분화 |
| `search` | X · TikTok · Reddit | ❌ `UNSUPPORTED` — **T-012 재검토 대기** |
| `intent` | X | ❌ `UNSUPPORTED` |
| `shortlink` | TikTok · Reddit | ❌ `UNSUPPORTED` |
| `story` | Instagram | ❌ `UNSUPPORTED` (24h 소멸) |
| `gist` | GitHub | ❌ `UNSUPPORTED` (엔드포인트 미구현) |
| `unknown` | YouTube playlist · web | ❌ `UNSUPPORTED` |
| `site` | web | ⏸ 웹 작업 보류 (T-M3) |

`VenueSubtype` 은 전량 살아 있다 — `community`(X) · `subreddit`(Reddit) · `channel`·`portal`·`shell`·`guard_group`(Telegram).

**삭제하지 않는 이유:** `search` 는 T-012 가 열려 있다. 실측에서 `tiktok_search` 1회 호출($0.038)이 주제 적합 10/10 · 서로 다른 작성자 10명 · 총 조회 194만을 돌려줘 "검색 URL 은 객체가 없다" 는 전제가 반증됐다. 결론 전에 지우면 되돌리는 일이 생긴다.

**주석 형태**

```ts
export enum ContentSubtype {
  TWEET = 'tweet',       // X
  VIDEO = 'video',       // TikTok · YouTube
  PHOTO = 'photo',       // TikTok — 슬라이드쇼(isSlideshow). fetch 후 세분화
  POST = 'post',         // Instagram · Reddit
  REEL = 'reel',         // Instagram — fetch 후 세분화
  TV = 'tv',             // Instagram — fetch 후 세분화
  REPO = 'repo',         // GitHub
  SEARCH = 'search',     // X · TikTok · Reddit — ⚠️ UNSUPPORTED, 현재 생성 경로 없음 (T-012 재검토 대기)
  STORY = 'story',       // Instagram — ⚠️ UNSUPPORTED, 24h 소멸이라 fetch 보류
  ...
}
```

#### E-M2 · `ContentSubtype` / `VenueSubtype` 분리 유지 (확정)

**메모:** `social-graph.consts.ts:77` — "venue, Content 분리해서 subtype 을 관리하는 게 맞나도 고민. 기본적 구조 자체가 platform, subtype 구조고 다 일렬로 관리하고 있어서"

**판정: 분리를 유지한다.**

**관찰은 맞다** — 두 enum 다 소셜을 섞은 평면 목록이고, `token_links.subtype` 은 아예 `{...ContentSubtype, ...VenueSubtype}` 합집합으로 검증한다.

**그러나 합치면 타입이 막지 못한다.** 지금은 `Content.subtype: ContentSubtype` 이라 `channel` 을 콘텐츠에 넣으면 컴파일이 깨진다. 합치면 통과한다.

**그리고 이것은 M-M1 과 같은 논리다.** 지표를 `ContentMetrics`·`AccountMetrics`·`VenueMetrics` 로 나눈 이유가 정확히 "객체 종류가 다르면 어휘도 다르다" 였다. 여기서 반대로 가면 한 결정에서 정반대 판단을 두 번 하는 셈이다.

**`token_links.subtype` 이 합쳐 쓰는 것은 정직한 상태다.** 그 필드는 대상 객체에서 복사한 값이라 실제로 둘 중 하나이며, `ContentSubtype | VenueSubtype` 유니온이 사실을 그대로 표현한다.

**⚠️ 제약으로 기록 —** mongoose enum 검증이 `COPIED_SUBTYPES`(둘의 합집합)라 **베뉴 링크에 `tweet` 이 들어가도 DB 는 통과시킨다.** 다형 참조의 구조적 한계라 스키마로는 막을 수 없고, `object` 값과 함께 봐야 판정된다.

### object_raw

> ### ⛔ 2026-08-07 — **이 컬렉션은 제거됐다.** 아래는 그때의 판정 기록이다.
>
> O-M1 의 근거(*"정의상 무손실 덤프"*)가 **실제로는 성립하지 않았다.** 저장되던 값은 SDK 의
> transform 을 이미 거친 결과였다 — `twitter-api.sdk.ts:88` 에서 HTTP 응답이 함수 안에서
> 사라지므로 fetcher 도 generator 도 원문을 **본 적이 없다.** 즉 "잘라 저장한 덤프" 를
> 피하려고 만든 컬렉션이 정확히 그것을 담고 있었다.
>
> **그 결과 스키마에 `Mixed` 가 하나도 남지 않는다.** 아래에서 "유일하게 남는 `Mixed`" 라고
> 적은 것이 그 하나였다. M-M2 의 배치 규칙 세 번째 줄("루트 객체면 `object_raw` 가 원문을
> 갖고 있다")도 전제가 사라졌으므로, 그 경우 값은 **버리거나 `data` 에 담는다.**
>
> 원문 보존은 그것을 실제로 볼 수 있는 **SDK 층에서 완결시킨다** — 근거와 착수 조건은
> [`decisions.md`](../social-generator/decisions.md) G-8(폐기) · G-12.

#### O-M1 · `payload` 의 `Mixed` 유지 · `observed_at` 제거 (확정)

**메모:** 없음 — 전수조사 대상으로 확인.

**`payload` 는 `Mixed` 를 유지한다. 스키마 전체에서 유일하게 남는 `Mixed` 다.**

이 필드는 **정의상 무손실 덤프**다. 타입을 붙이는 순간 mongoose strict 가 스키마에 없는 경로를 버려서 존재 이유가 사라진다. 주석이 그 대가를 이미 기록하고 있다 — *"audit 이 `alt`·`isSlideshow`·`author.id` 를 놓친 원인이 잘라 저장한 덤프였다."*

M-M2 의 배치 규칙 세 번째 줄("구조가 복잡하고 쓸 계획이 없다 → 버린다. 루트 객체면 `object_raw` 가 원문을 갖고 있다")이 이 필드를 전제로 성립한다.

**`observed_at` 은 제거한다 (P-1).**

`upsertByObject` 라 재관측할 때마다 문서가 덮이고 그때 `updated_at` 도 함께 움직인다. 두 값의 차이는 fetch 소요 시간뿐이며 인프라의 성질이다.

**다른 컬렉션과 이유가 다르다는 점을 기록해 둔다.** `contents`·`accounts`·`venues` 는 "재관측해도 덮지 않으니 `created_at` 과 같다" 였고, 여기는 **"재관측하면 덮으니 `updated_at` 과 같다"** 다. 결론만 같다.

**손대지 않는 것**
- 인덱스가 `{object, object_id}` unique 하나뿐인 것은 의도다. 문서가 수십 KB(X 트윗 raw 는 author 33키 + quoted_tweet 전체)라 여기서 목록·검색을 하면 캐시가 오염된다. `platform` 필터 필드를 두지 않는 것도 같은 이유다.
- **다형 참조를 유지한다.** `metric_series` 는 콘텐츠 전용으로 좁혔지만(M-M1), 루트 객체는 콘텐츠·계정·베뉴 셋 다 될 수 있다(`x_profile` URL 이면 루트가 계정, `t.me/foo` 면 베뉴).

**알려진 한계 (기록만):** 원문은 **루트 객체 1건**만 저장된다. fan-out 으로 나온 객체(인용 원본, 작성자)는 원문이 없고, 그 객체들의 부가 정보는 `data`(M-M2)가 유일한 창구다.

---

## 전체 점검

개별 항목이 아니라 **항목 사이의 충돌**을 본다. 아래 5개 축으로 확인했다.

### ⚠️ 정정 1 — R-M1 에 쓴 사실이 틀렸다

R-M1 에 이렇게 적었다: *"게이트 주석이 근거로 든 '같은 판정이 임시 어댑터와 수집 경로 두 곳에서 필요하다' 는 현재 사실이 아니다 — `SocialFetchGate` 를 주입받는 곳은 `SocialRecordProcessor` 하나뿐이다."*

**틀렸다.** `SocialFetcherService` 도 게이트를 주입받아 쓴다(`social-fetcher.service.ts:45` · `:76`). **소비자는 둘이며, 주석이 말한 그대로다.**

**결정 자체는 바뀌지 않는다. 오히려 근거가 강해진다.** 소비자가 둘이라는 것은 두 곳이 같은 규칙을 알아야 한다는 뜻인데, 규칙을 fetcher 안으로 내리면 **어느 쪽도 알 필요가 없어진다.** 어댑터는 fetcher 를 부르고, fetcher 가 스스로 판정한다.

다만 "정책이 한 파일에 모여 있다" 는 반대 근거를 내가 부당하게 깎았다. 그 근거는 정당했고, **fetcher 가 그 하나의 자리를 대신한다**는 것이 실제 답이다.

### ⚠️ 발견 1 — 중복이던 인덱스가 V-M1 로 해소된다

지금 `contents` 에 두 인덱스가 있다.

```
idx_contents_platform_key      (platform, subtype, platform_key)
idx_contents_platform_subtype  (platform, subtype)
```

**앞의 것이 뒤의 것을 접두어로 포함한다.** 즉 `idx_contents_platform_subtype` 은 현재 **불필요한 중복**이다.

V-M1 이 앞의 것을 `(platform, platform_key)` 로 바꾸면 접두어 관계가 끊어져 **둘 다 제 역할을 하게 된다.** 의도한 효과는 아니었지만 결과가 맞다. 삭제 대상이 아니다.

### 축 ① 결정끼리의 모순 — 없음

| 확인한 것 | 결과 |
|---|---|
| V-M1 이 `subtype` 을 정체성에서 뺐는데 조회에 남는가 | `idx_contents_platform_subtype`·`idx_links_platform_linked` 는 **조회축**이라 유지가 맞다 |
| T-M1(크론 재분류)과 R-M1(라우터가 `platform` 만 반환)이 맞물리는가 | **맞물린다.** 크론은 제너레이터만 고르면 되고 게이트가 사라져 `sourceType` 이 필요 없다 |
| M-M1(시계열 콘텐츠 전용) 후 계정·베뉴 지표는 어디 남는가 | `accounts.metrics` · `venues.metrics` 에 생성 시 기록된다(`Account.of` 가 이미 그렇게 한다) |
| C-M1(`platform_key` 필수)과 R-M1(`SOURCE_TYPE_OBJECT` 삭제)가 충돌하는가 | 충돌 없다. R-002 로 실패가 행을 만들지 않아 fetch 전 좌표가 필요 없다 |
| H-1(unique 보류)과 V-M1(인덱스 축 변경)이 충돌하는가 | 충돌 없다. V-M1 은 **축**을, H-1 은 **unique 여부**를 정한다 |

### 축 ② 삭제 연쇄 — 잔존 참조 목록

각 심볼을 지울 때 함께 손봐야 할 파일이다.

| 삭제 대상 | 참조 파일 수 | 주의 |
|---|---|---|
| `SOURCE_TYPE_OBJECT` | 2 | `social-recording.consts.ts`(정의) · `social-record.processor.ts` |
| `pickKnownMetrics` | 3 | `metric-spec.ts`(정의) · `content.model.ts` · `metric-series.model.ts` |
| `METRIC_SPEC` | 2 | `metric-spec.ts`(정의) · 테스트 |
| `SocialFetchGate` | 4 | **정의 + 어댑터 + 프로세서 + 모듈 등록**. 위 정정 참조 |
| `findBySourceUrl` | 4 | 정의·호출 각 1 + **주석 언급 2**(`venue.repository.ts:37`·`account.repository.ts:44`). 주석도 갱신 필요 |
| `ContentSourceUrl`·`ContentLinkStats`·`ContentAuthorSnapshot` | 각 1~2 | 모델 + 타입 |
| `AccountModeration`·`isObservedLaterThan`·`hadHandleAt` | 각 1~2 | 모델 + 테스트 |

### 축 ③ 인덱스 최종 목록 — 21 → 20

| 컬렉션 | 인덱스 | 변경 |
|---|---|---|
| `tokens` | `uniq_tokens_address` | — |
| | `idx_tokens_social_url_status` | — |
| | `idx_tokens_fingerprints` | — |
| | `idx_tokens_discovered` | **정렬축 `discovered_at` → `created_at`** (T-M4) |
| `token_links` | `idx_links_object_linked` | **정렬축 `linked_at` → `created_at`** (TL-M1) |
| | `idx_links_token_linked` | **정렬축 `linked_at` → `created_at`** (TL-M1) |
| | `idx_links_platform_linked` | **정렬축 `linked_at` → `created_at`** (TL-M1) |
| `contents` | `idx_contents_source_url` | **삭제** (C-M1) |
| | `idx_contents_platform_key` | **`(platform, platform_key)`**, partial 제거 (C-M1·V-M1) |
| | `idx_contents_platform_subtype` | — (위 발견 1 참조) |
| | `idx_contents_creator_published` | — |
| | `idx_contents_parent` | — |
| | `idx_contents_venue` | — |
| `accounts` | `uniq_accounts_platform_key` | — |
| | `idx_accounts_handle` | **`(platform, handles.value)`** (A-M1) |
| | `idx_accounts_wallet` | — |
| `venues` | `uniq_venues_platform_key` | **`(platform, platform_key)`** (V-M1) |
| | `idx_venues_creator` | — |
| `metric_series` | `uniq_series_object` | **`uniq_series_content`** — `content_id` unique (M-M1) |
| | `idx_series_platform` | **삭제** — `platform` 필드와 함께 (M-M3) |
| `object_raw` | `uniq_raw_object` | — |

**삭제 2개(`idx_contents_source_url` · `idx_series_platform`), 축 변경 6개, 나머지 유지. 21 → 19.**

### 축 ④ 제너레이터 계약 — 흩어진 결정이 하나로 모인다

여러 항목이 제너레이터에 조건을 걸었다. 최종 계약은 이렇다.

```ts
generate(url, opts, observedAt): Promise<{
  status: FetchStatus;              // R-M1 — 유료·미지원 판정을 스스로 한다
  attempted: boolean;               // R-M1 — 외부를 실제로 불렀는가
  graph: SocialObjectGraph | null;
}>
```

반환 트리가 지켜야 할 것:

1. **모든 객체에 `platform_key` 가 있어야 한다.** 계정·베뉴는 기존 계약이었고, C-M1 로 **콘텐츠까지 확대**된다. 키를 만들 수 없으면 그 객체를 넣지 않는다.
2. **지표는 필드 단위로 명시 매핑한다. 스프레드로 넘기지 않는다** (M-M1). `data` 도 같다(M-M2).
3. **모든 URL 은 정규화된 값이다** (P-2). `outbound_urls` 는 본문에서 추출한 값이라 **제너레이터가 직접 정규화해야 한다** — 라우터를 거치지 않는 유일한 URL 경로다.
4. **`subtype` 은 fetch 후 판정이며 정체성이 아니다** (V-M1). 같은 대상을 다시 만나면 subtype 이 달라질 수 있다.

⚠️ **3번은 새 의존을 만든다.** 제너레이터가 `normalize()` 에 접근해야 한다. 라우터를 통째로 주입할지, 정규화만 별도 유틸로 뽑을지는 구현 시 정한다.

### 축 ⑤ 재관측 업데이트 — **2026-08-13 결정 완료**, 주기성 갱신으로 이월

이번 스코프에서 넘긴 것들이다. 지금은 `resolveAccount`·`resolveVenue` 가 기존 행을 찾으면 **그대로 반환하고 아무것도 갱신하지 않는다**(`writer.ts:326`·`:349`).

| 출처 | 넘긴 것 |
|---|---|
| A-M1 | 핸들 관측 누적 (`addHandleObservation` 이 생성 시 1회만 불린다) |
| V-M1 | `subtype` 갱신 (`shell` → `channel` 진행을 반영) |
| 기존 MEMO | `urls`·`bio` 등 갱신 (`writer.ts` 의 `// MEMO: entity 가 있을 때 ... 업데이트`) |

**셋이 같은 자리에서 같은 판단을 요구한다** — "재관측 시 무엇을 덮고 무엇을 남길 것인가". 따로 하면 규칙이 갈리므로 **한 작업으로 묶어야 한다.**

#### 판정 (2026-08-13 확정) — **아무것도 안 덮는다. 셋 다 지표와 같은 처리로 간다**

지금 동작(`existing ?? create`, 갱신 0건)이 **최종 정답**이다. 세 항목은 «재관측 시 덮기» 가 아니라 **주기성 갱신 프로세스** 소관으로 옮긴다 — R-009 가 지표를 넘긴 그 프로세스와 **같은 것**이다.

**R-009 를 뒤집지 않는다. 전 필드로 확장한다.** 예전 계획(*"지표는 안 덮되 `subtype`·`handles`·`urls` 는 갱신"*)을 버린 이유가 둘이다.

① **한 행 안에서 필드마다 시점이 갈린다.** 어떤 값은 첫 관측이고 어떤 값은 마지막 관측이면, 그 행을 읽는 사람이 필드별로 "이건 언제 값이지" 를 따져야 한다. 규칙이 하나여야 그 질문이 사라진다.

② **R-009 의 근거가 지표 전용이 아니었다.** 근거는 *"발견될 때마다 찍으면 «누군가 이 대상을 건 시점» 에만 찍힌 불규칙 표본이 된다"* 인데, 핸들 이력에 그대로 적용된다 — 재관측 경로로 쌓은 `handles[]` 는 "핸들이 언제 바뀌었나" 가 아니라 "언제 누가 이 URL 을 걸었나" 를 기록하게 되고, 그것으로 재할당 시점을 추정하면 틀린 답이 조용히 나온다. `subtype` 의 `shell` → `channel` 전이 시각도 같다.

**감수하는 대가** — 주기성 프로세스가 생길 때까지 전 필드가 첫 관측에서 얼어붙는다. 첫 관측이 fan-out 경로(인용 원본의 축약된 author)면 빈약한 행이 고착되고 나중에 직접 fetch 해도 안 채워진다(H-008). 알고 고른 것이다. 통제하지 않는 시점의 갱신은 정보성이 떨어진다는 판단이 그보다 앞선다(G-6).

⚠️ **`metric_series` 를 접는 탈출구가 약해졌다.** R-009 는 *"갱신 프로세스가 안 생기면 `metric_series` 를 접고 `metrics_latest` 만 남기는 것이 맞다"* 를 남겨 뒀는데, 이제 그 프로세스에 걸린 것이 지표만이 아니다.

**A-M1-a 는 없어졌다.** 「재관측 시 덮을지 정하기」라는 결정 작업이 사라졌고, 남은 것은 **주기성 갱신 프로세스 구현** 하나다.

### 남은 미결 1건

**H-1(동시 생성)** — 별도 순차 처리 방식이 정해진 뒤 `contents` unique 여부를 판단한다.

(점검 시점의 미결이던 `idx_series_platform` 은 M-M3 으로 확정됐다.)

---

## 점검 이후 확정

#### M-M3 · `MetricSeries.platform` 필드와 인덱스 삭제 (확정)

**판정: 필드와 `idx_series_platform` 인덱스를 함께 제거한다.**

**근거**
- **읽는 코드가 없다.** 이 인덱스를 타는 쿼리가 프로덕션에 하나도 없다.
- **M-M1 이후 대안이 생겼다.** 시계열이 콘텐츠 전용이 되면서 `contents` 와 1:1 이 됐고, `contents` 에는 이미 `idx_contents_platform_subtype` 이 있다. "틱톡 콘텐츠를 고른 뒤 그 시계열을 가져오기" 로 같은 결과가 나온다.
- **복사본은 원본과 갈릴 수 있다.** 인덱스만 빼고 필드를 남기면 `contents.platform` 의 낡을 수 있는 사본만 남으므로, 필드까지 함께 뺀다.

**되살리는 비용은 필드 하나 + 인덱스 하나다.** C-M2 · M-M1 에서 쓴 것과 같은 기준이다.

**재검토 트리거:** `contents` 를 거치지 않고 시계열만 직접 훑는 조회가 생기면(예: "전 플랫폼 시계열 중 틱톡만 골라 속도 계산") 이 항목을 다시 연다.

**확정 후 `MetricSeries`**

```ts
@index({ content_id: 1 }, { unique: true, name: 'uniq_series_content' })
export class MetricSeries extends BaseModel {
  content_id: Types.ObjectId;
  points: MetricPoint[];
}
```

인덱스가 하나만 남는다.
