# Generator 루브릭 — `token_links` 정책과 지켜야 할 것

> **용도: 생성기 8종을 구현·리뷰할 때 훑는 체크리스트.**
>
> 📄 **작업 중에는 [`social-generator-rubric.html`](./social-generator-rubric.html) 을 연다** — 같은 내용이되 링크 탐색기(사슬 길이와
> 겹침을 바꿔 가며 저장되는 링크를 본다) · 통과/거부 모양 갤러리 · 누를 수 있는 점검표가 있다.
> **정본은 이 문서다.** 규칙이 바뀌면 둘을 같이 고친다.
> 앞부분(Part 1)은 "무엇이 왜 저장되나", 뒷부분(Part 2)은 "그래서 생성기가 무엇을 지켜야 하나" 다.
> Part 2 의 각 항목은 **어기면 무슨 일이 나는지**를 함께 적었다 — 그게 없으면 규칙이 관례가 되고,
> 관례는 8곳에서 각자 다르게 해석된다.
>
> 기준 코드: `social-graph.writer.ts` · `social-record.processor.ts` ·
> `social-recording.types.ts` · `social-generator.interface.ts` (2026-08-07)
>
> 결정의 근거는 [`decisions.md`](../docs/features/social-generator/decisions.md)(G-*)와
> [`recording-holes.md`](../docs/features/social-recording-refactor/recording-holes.md)(H-*)에 있다.
> 이 문서는 **판정 결과만** 담는다.

---

# Part 1 — `token_links` 정책

## 1.1 이 컬렉션이 답하는 질문

> **"이 토큰이 어느 소셜 객체에 걸렸나."**

그것뿐이다. 아래는 **답하지 않는다.**

| 묻고 싶은 것 | 답하는 곳 |
|---|---|
| 누가 이 글을 썼나 | `contents.creator_id` |
| 이 URL 을 마지막에 열어봤을 때 어떻게 됐나 | `tokens.social_urls[].status` |
| 이 계정이 정지됐나 | `accounts.unavailable` ⚠️ **현재 채우는 경로가 없다** — 부록 참조 |
| 지표가 얼마였나 | `metric_series` · `contents.metrics_latest` |

**상태 필드가 없는 것이 의도다**(R-007). 같은 사실이 두 곳에 있으면 갈렸을 때 어느 쪽이 맞는지
판단할 근거가 없다.

## 1.2 URL 마다 객체가 있어야 하나 — **아니다**

이 질문이 8종을 만들면서 매번 나온다. 답을 먼저 적는다.

> **`token_links` 행 하나 = `platform_key` 로 식별된 객체 하나.** 역도 성립한다.
> 객체가 없으면 링크도 없고, **그것은 정상 결과다.**

체인이 한 방향이다.

```
platform_key 있음 → 객체 행 생성 → token_links 1건
platform_key 없음 → 객체 없음    → token_links 0건
```

**이건 관례가 아니라 스키마가 강제한다.** `contents`·`accounts`·`venues` 의 `platform_key` 가
전부 `required: true` 라 없으면 mongoose 가 저장을 거부하고, `buildLink()` 는 이미 `_id` 가
확정된 객체만 받아 **객체 없이 링크를 만들 경로가 코드에 없다.**

### 기록이 남는 세 단계

| 단계 | `token_links` | `tokens.social_urls[]` | 언제 |
|---|:---:|:---:|---|
| **정상 수집** | ✅ | ✅ `ok` | 객체를 확정했다 |
| **객체 없음** | ❌ | ✅ 그 외 status | 부르지 못했거나, 불렀는데 대상이 없거나, 키를 못 만들었다 |
| **완전 소실** | ❌ | ❌ | **정규화에 실패했다** — 로그에만 남는다 (H-012) |

두 번째 단계를 **의도적으로 허용한다.** 예전에는 `contents(subtype=unknown)` 자리행과
`token_links(unresolved)` 를 만들어 "이 토큰이 이 URL 을 걸었다" 를 표현했는데(R-002 이전),
두 가지가 망가졌다.

- **"링크가 있다" 가 "수집됐다" 를 뜻하지 않게 됐다**
- **자리행이 승격되지 않아**, 나중에 제대로 수집해도 `unknown` 인 채로 남았다

### `graph` 가 `null` 인 status 와 그 이유

`OK` 하나만 `graph` 를 갖는다. 나머지는 전부 객체를 만들지 않는다.

| status | 왜 객체가 없나 | 불렀나 | 재시도로 바뀌나 |
|---|---|:---:|---|
| `UNSUPPORTED` | 담당 Generator 가 없거나, 있어도 그 종류를 다루지 않는다 | ❌ | 우리가 만들면 바뀐다 |
| `SKIPPED_PAID` | 유료라 안 불렀다 | ❌ | 설정을 켜면 바뀐다 |
| `SUSPENDED` ⏳ | 계정이 정지·탈퇴라 **`platform_key` 를 만들 수 없다** | ✅ | 정지는 풀린다 |
| `NOT_FOUND` | 불렀는데 대상이 없다 | ✅ | 거의 안 바뀐다 |
| `BLOCKED` | 대상 쪽 사정(비공개·차단·초대 전용) | ✅ | 열릴 수 있다 |
| `ERROR` | 우리 쪽 사정(네트워크·DB·계약 위반) | ✅ | 바뀔 수 있다 |

<sub>⏳ `SUSPENDED` 는 아직 enum 에 없다 — G-5, 부록 참조.</sub>

**어휘를 이만큼 쪼갠 이유가 마지막 열이다.** 재수집 배치(H-009)가 무엇을 다시 두드릴지
판단하는 유일한 근거다. 뭉개면 영영 열리지 않을 URL 을 계속 두드린다 — 프로세서가
`TARGET_BLOCKED` 를 `ERROR` 에서 가르는 것과 같은 근거다.

### 판정 기준은 "부를 수 있나" 가 **아니다**

여기가 가장 헷갈린다. 기준은 **"`platform_key` 를 만들고 계약된 필드를 채울 수 있나"** 다
(G-3 + G-6). 외부를 못 불러도 URL 에서 뽑을 게 있으면 만들고, 부를 수 있어도 껍데기밖에
안 나오면 만들지 않는다.

| 예 | 외부 호출 | 객체 | 근거 |
|---|:---:|:---:|---|
| X 검색 (`x_tweet_search`) | **0회** | ✅ | **URL 자체가 정보다** — 검색어가 곧 키이자 `text` |
| X intent | **0회** | ✅ | intent 텍스트가 값이다 |
| X trending | 보조 1회 (실패 허용) | ✅ | snowflake id 에서 **생성 시각이 호출 0회로** 나온다 |
| X 정지 계정 | 1회 | ❌ | 응답에 `id` 가 없어 키를 만들 수 없다 |
| `grok` · 예약 루트 · `unknown` | 0회 | ❌ | 키도 못 만들고 채울 필드도 없다 |

## 1.2-b 언제 링크 행이 생기나

**Generator 가 `status: OK` 와 `graph` 를 함께 돌려줬을 때만.**

즉 `token_links` 에 행이 있다 = **그 URL 을 성공적으로 열어 객체를 확정했다.**

## 1.3 한 그래프가 만드는 링크의 수

**그래프가 확정한 객체마다 정확히 1건.** 객체는 `_id` 기준이다.

```
묶음 하나가 낼 수 있는 객체 = creator · venue.creator · venue · content  (최대 4)
```

같은 `_id` 가 여러 번 나오면 **1건으로 접는다.** 두 경우가 있다.

- **한 묶음 안** — 작성자가 곧 베뉴 개설자일 때
- **묶음 사이** — 자기 인용(내 트윗이 내 트윗을 인용)일 때

접을 때 **깊이는 가장 얕은 값이 남는다**(H-001). 사슬을 깊은 것부터 돌면서 덮어쓰기 때문이다.

## 1.4 `link_depth` 의 의미

> **`rootRef` 묶음에서 인용을 **몇 번** 타고 그 객체에 닿았나.**

| 값 | 뜻 |
|:---:|---|
| **0** | 토큰이 **직접 건 URL** 의 대상. 토큰 발행자가 자기 손으로 이 주소를 적어 넣었다 |
| **1** | 인용을 타고 들어가 발견한 대상. 발행자가 적은 적이 없다 |

**계산 방식** — `plan()` 이 `rootRef` 에서 `parentRef` 를 타고 사슬을 세우고 깊은 것부터
정렬한다. 그 배열에서 **자리가 곧 깊이**다(`ordered.length - 1 - index`). 별도의 깊이 표는 없다.

**상한** — `LINK_MAX_DEPTH = 2` 이고 `TokenLink.capDepth()` 가 그 위를 자른다.
그래서 저장되는 값은 **0 아니면 1 둘뿐**이다. 인용 2단(A→B→C)이면 C 도 1 이 된다.

**깊이는 묶음 단위다.** 인용 원본의 작성자도 인용을 타고 온 것이므로 1 이다. 계정만 0 으로 두면
"이 토큰이 홍보한 계정" 에 제3자가 섞인다.

```
토큰이 A 의 URL 을 검  ·  A 가 B 를 인용  ·  A·B 작성자가 다름

  @a(A작성자)  depth 0     ← 홍보 계정
  tweetA       depth 0
  @b(B작성자)  depth 1     ← 제3자
  tweetB       depth 1
```

## 1.5 `entry_url`

**그 그래프에 도달한 진입 URL.** 한 응답에서 fan-out 된 객체들이 **같은 값**을 공유한다 —
인용 원본의 링크에도 최상위 URL 이 박힌다.

- 값은 `normalizeUrl()` 을 통과한 canonical 문자열이다(라우터가 진입점에서 정규화한다)
- `object_id` 는 재분류로 바뀔 수 있어도 이 값은 안 바뀐다 — 재할당의 안전장치다

## 1.6 복사되는 필드

`platform` 과 `subtype` 은 **대상 행에서 복사**한다. 플랫폼별 집계를 조인 0회로 끝내기 위해서다.

- `subtype` 은 **선택**이다 — `accounts` 에는 `subtype` 이 없다
- ⚠️ 스키마는 `ContentSubtype ∪ VenueSubtype` 합집합으로 검증하므로, 베뉴 링크에 `tweet` 이
  들어가도 DB 는 통과시킨다. `object` 값과 함께 봐야 판정된다

## 1.7 유니크가 없다

**의도된 append-only** 다. 같은 `(token, object)` 가 시차를 두고 여러 번 들어오는 것이 정상이고,
그 간격이 재관측 이력이다.

⚠️ 다만 지금은 **재처리 부산물과 재관측 이력이 구분되지 않는다**(H-013). 저장 중간에 실패하면
다음 이벤트가 토큰 전체를 재처리하면서 링크만 다시 쌓인다.

## 1.8 응답 원문은 **저장하지 않는다** (2026-08-07)

`object_raw` 컬렉션과 `graph.raw` 필드가 함께 제거됐다. 생성기는 **원문을 위로 올리지 않는다.**

없앤 이유는 그 컬렉션이 **자기 존재 이유를 위반하고 있었기** 때문이다. 명분은 "무손실 원본 —
자르는 순간 재해석 가능성이 사라진다" 였는데, 실제로 저장되던 값은 **SDK 의 transform 을 이미
거친 결과**였다. SDK 가 HTTP 응답을 함수 안에서 버리므로 fetcher 는 원문을 **본 적이 없다.**
그리고 그 값의 필드는 거의 전부 `ContentInput`·`AccountInput` 으로 가고 있어 중복이었다.

원문 보존이 필요해지면 **그것을 실제로 볼 수 있는 SDK 층에서 완결시킨다**(G-12). 위로
전파하지 않는다 — 지금 컬렉션을 남겨 두면 나중에 정체성 축이 바뀌면서(`object_id` 는 저장
전에 존재하지 않는다) 마이그레이션이 생긴다.

---

# Part 2 — 생성기 루브릭

각 항목은 **규칙 · 어기면 · 확인 방법** 순이다.
🔴 은 어겼을 때 **에러 없이 잘못된 데이터가 남는** 것, 🟠 은 **Writer 가 던지는** 것이다.

## 그래프 모양

### R-1 🟠 그래프는 **사슬 하나**다

모든 묶음이 `rootRef` 에서 `parentRef` 를 타고 도달 가능해야 한다. 가지도 고아도 없다.

- **어기면** — `plan()` 이 `'도달할 수 없는 묶음이 있다'` 로 던지고 **그 URL 전체가 실패**한다
- **왜** — 깊이 0 은 "토큰이 직접 걸었다" 는 강한 주장인데, 도달 경로 없는 묶음에는 근거가 없다.
  예전엔 그런 묶음에 0 을 줘서 홍보한 적 없는 대상이 홍보 명단에 섞였다(H-002)
- **확인** — 묶음이 N 개면 `rootRef` 에서 `parentRef` 를 N-1 번 타서 전부 닿아야 한다

### R-2 🔴 인용이 **아니면** 묶음을 가르지 않는다

한 URL 에서 나온 계정·베뉴·콘텐츠는 **묶음 하나의 세 자리**(`creator`·`venue`·`content`)에 담는다.
묶음이 갈리는 것은 인용·리트윗·포크일 때뿐이다.

- **어기면** — R-1 에 걸려 던진다. "프로필 + 고정 트윗" 같은 걸 두 묶음으로 내려는 순간이 그 경우다
- **확인** — 새 묶음을 만들 때마다 물어라: *"이건 앞 묶음이 인용한 원본인가?"* 아니면 같은 묶음이다

### R-3 🟠 `parentRef` 대상은 **콘텐츠를 가진 묶음**이어야 한다

- **어기면** — `'인용 원본이 콘텐츠 있는 묶음이 아니다'` 로 던진다
- **왜** — 아니면 `contents.parent_content_id` 가 에러 없이 빈다. 인용은 글이 글을 가리키는 것이다

### R-4 🟠 셋 다 `null` 인 묶음을 만들지 않는다

- **어기면** — `'빈 그룹'` 으로 던진다
- 객체를 하나도 못 만들겠으면 **그 묶음 자체를 안 낸다.** 그것이 마지막 묶음이면 `graph` 를 `null` 로

### R-5 🟠 `ref` 는 그래프 안에서 유일하다

지역 이름표라 무엇이든 되지만(`'main'`·`'tweet:111'`) 겹치면 안 된다.

- **어기면** — `byRef` 가 하나를 삼켜 사슬이 짧아지고, `'도달할 수 없는 묶음'` 으로 던진다
- ⚠️ 자기 인용은 `ref` 가 겹치는 게 **아니다** — 겹치는 것은 `platform_key` 이고 그건 정상이다

### R-6 🔴 `parentRef` 방향 — **"내가 인용한 원본"** 을 가리킨다

`A.parentRef = B` 는 "A 가 B 를 인용했다" 는 뜻이다. 반대가 아니다.

- **어기면** — 사슬이 거꾸로 서서 깊이가 뒤집힌다. 던지지 않는다
- **확인** — `rootRef` 묶음의 `parentRef` 가 **인용 원본**을 가리키는지

## 객체 내용

### R-7 🔴 **모든 객체에 `platformKey` 가 있어야 한다**

계정·베뉴·콘텐츠 전부. 만들 수 없으면 **그 객체를 아예 넣지 않는다**(G-3).

- **어기면** — 정체성 축이 없어 같은 대상이 관측할 때마다 새 행이 된다
- **본문 멘션도 마찬가지다** — `MentionInput.platformKey` 는 필수다. 핸들만 담으면 같은 KOL 이
  인용 경로와 멘션 경로에서 다른 키로 잡힌다
- **못 만들 때** — 객체를 빼고 신호는 `status` 로 보존한다 (예: X 정지 계정 → `SUSPENDED`)

### R-8 🔴 **껍데기를 만들지 않는다**

만들 거면 계약된 필드를 **채우는 것을 보장**한다. 못 채우면 ① 추가 호출로 채우거나 ② 안 만든다(G-6).

- **어기면 — 채울 대상 자체가 안 생긴다.** `resolveAccount`·`resolveVenue`·`resolveContent` 는
  기존 행을 갱신하지 않는다. **이것은 결함이 아니라 설계다** — *수집(축적)* 과 *지표 갱신* 을
  분리한 것이고, 이유는 **우리가 통제하지 않는 시점의 갱신은 정보성이 떨어지기 때문**이다.
  수집이 언제 도는지가 토큰 발행과 무관하면 그 스냅샷은 "언제 기준인지" 가 흐려진다.
  갱신 정책은 별도 로직이 갖는다(H-008·H-007 에서 정한다)
- **그래서 이 규칙이 여전히 구속력을 갖는다** — 갱신 로직이 나중에 생겨도
  **애초에 만들지 않은 객체는 채울 대상이 없다.** 껍데기를 만드는 것과 안 만드는 것은
  나중에 되돌릴 수 있는 종류가 다르다
- **가장 위험한 자리** — fan-out 으로 처음 만들어지는 객체(인용 원본의 작성자). 응답에 실린 축약
  정보뿐인데 그 상태로 확정된다
- 그 자리를 **경로끼리 대조하는** 규칙이 R-18 이다

### R-9 🔴 **모든 URL 은 정규화된 값**이다

`outboundUrls` 는 본문에서 뽑은 값이라 **생성기가 직접 `normalizeUrl()` 을 부른다** — 라우터를
거치지 않는 유일한 경로다. 정규화에 실패한 값은 **버린다**.

- **어기면** — `outbound_urls` 의 쓸모가 "이 콘텐츠가 토큰의 사이트를 가리키는가" 인데,
  그건 `tokens.social_urls[].url` 과 **문자열을 맞대는** 방식이다. 한쪽만 정규화돼 있으면 안 맞는다
- **강제하는 코드가 없다** — 컴파일도 런타임도 안 잡는다. 이 항목이 루브릭에 있는 이유다

### R-10 🔴 지표·`data` 는 **필드 단위 명시 매핑**

`{ ...response.stats }` 같은 스프레드 금지.

- **어기면** — TypeScript 의 초과 속성 검사가 스프레드에는 적용되지 않아 **컴파일을 통과하고**,
  mongoose strict 가 모르는 키를 **조용히 버린다.** 지표가 통째로 사라져도 에러가 없다
- **키 이름은 지어내지 않는다** — fetcher 반환 타입의 실제 필드명 그대로(G-7).
  어느 소셜 값인지는 같은 문서의 `platform` 이 말해준다

### R-11 🔴 `subtype` 은 **fetch 후 판정**이며 정체성이 아니다

같은 대상을 다시 만나면 값이 달라질 수 있다(빈 텔레그램 채널이 차면 `shell` → `channel`).

- **어기면** — 조회 키에 넣으면 같은 대상이 두 행으로 갈린다. `platform_key` 만이 정체성이다

### R-12 🔴 `creatorId` · `venueId` · `parentContentId` 를 **비운다**

저장 전이라 `_id` 가 없다. Writer 가 채운다.

## 반환 계약

### R-13 🔴 `graph` 는 `status === OK` 일 때만 non-null

**값이 아니라 갈래가 계약이다.** `FetchStatus` 값 7종이 `GenerateResult` 의 **세 갈래**로
묶여 있고, 갈래마다 `attempted` 와 `graph` 가 함께 고정된다.

```ts
// 갈래 1 — 성공. 부른 경우와 안 부른 경우가 둘 다 있다
| { status: OK;                                     attempted: boolean; graph: SocialObjectGraph }
// 갈래 2 — 부르지 않고 끝났다
| { status: SKIPPED_PAID | UNSUPPORTED;             attempted: false;   graph: null }
// 갈래 3 — 불렀는데 남길 게 없었다
| { status: NOT_FOUND | BLOCKED | ERROR | SUSPENDED; attempted: true;   graph: null }
```

> **새 `FetchStatus` 값은 갈래에 편입시킨다. 갈래를 늘리지 않는다.**
> `SUSPENDED` 는 **갈래 3** 이다 — X 에 실제로 물었고, 응답이 "정지됨" 이었고, `id` 가 없어
> `platformKey` 를 못 만든다(X-3 · G-5). 전용 갈래를 새로 만들면 `SUSPENDED` + `attempted: false`
> 같은 조합이 통과하게 되고, 그건 **부른 호출이 안 부른 것으로 기록되는** 것이다.

`GenerateResult` 는 **판별 유니온**이라 아래 셋은 **컴파일이 막는다.**

- `OK` 가 아닌데 `graph` 가 있음
- `SKIPPED_PAID`·`UNSUPPORTED` 인데 `attempted: true` (부르지도 않은 호출이 세어진다)
- `NOT_FOUND`·`BLOCKED`·`ERROR` 인데 `attempted: false`

**막히지 않는 조합이 딱 하나 남는다.**

> ⚠️ **`OK` 인데 `graph: null`** — `tsconfig` 의 `strictNullChecks: false` 때문에
> `null` 이 모든 타입에 assignable 이라 **컴파일을 통과한다.**
> Processor 가 저장을 건너뛰고 `OK` 로 기록해서, **링크가 0건인데 성공으로 남는다.**
> 이 하나는 눈으로 확인하는 수밖에 없다.

### R-14 🔴 `attempted` 는 **외부를 실제로 불렀는가**

`attempts` 집계의 유일한 근거다. 게이트가 사라져 이걸 아는 주체가 생성기뿐이다.

- **어기면** — 게이트가 막은 것과 불러서 실패한 것이 같은 횟수로 세어져, 비용·백오프 계산이
  조용히 틀린다

### R-15 🔴 호출 실패는 **`ExternalFetchError` 로 던진다**

삼키지 않는다. 어디까지 흡수할지는 호출 경계인 Processor 가 정한다.

- **어기면** — Processor 가 `ExternalErrorCode` 로 `BLOCKED` 와 `ERROR` 를 가르는데,
  일반 `Error` 로 던지면 전부 `ERROR` 로 뭉쳐 재수집 배치가 영영 안 열릴 URL 을 두드린다
- ⚠️ 현재 `ERROR` 에는 **DB 오류와 생성기 계약 위반도 함께 뭉친다**(H-004). 재수집 배치를
  만들 때 갈릴 예정이다

#### 예외 — **보조 호출**은 흡수한다 (G-10)

우리가 얹은 부가 경로의 실패는 그 URL 전체를 실패로 만들 이유가 없다. 판정은 한 문장이다.

> **그 호출이 실패해도 남는 값이 있는가.**

있으면 보조다. 없으면 그건 보조가 아니라 본 호출이고 `R-15` 가 그대로 적용된다.

| | 실패하면 | 판정 |
|---|---|---|
| X trending 제목·요약 (`r.jina.ai`) | `text` 는 비지만 `publishedAt` 이 남는다 — trend id 의 snowflake 를 계산할 뿐이라 외부를 안 부른다 | **흡수한다** |
| X 프로필 (`fetchProfile`) | 남는 게 없다 — URL 에는 `userName` 뿐이고 그건 변경 가능해 `platformKey` 가 못 된다 | **던진다** |

- **어기면(반대로 흡수하면)** — 본 호출 실패가 `OK`+`graph: null` 로 기록된다. R-13 이 못 막는
  바로 그 조합이라 **링크 0건인데 성공으로 남는다**
- **이 예외를 새 소셜로 넓히려면** 같은 문장을 다시 통과해야 한다

#### 단서 — 보조 호출이라도 **삼키면 안 되는 실패**가 있다

판정이 하나 더 붙는다. **그 URL 만의 사정인가, 다음 호출에도 똑같이 일어날 성질인가.**

| 코드 | 성격 | |
|---|---|:---:|
| `TARGET_BLOCKED` · `UPSTREAM_UNAVAILABLE` · `RATE_LIMITED` | 그 대상·그 순간의 사정 | 흡수 |
| **`CREDIT_EXHAUSTED`** · `QUOTA_EXHAUSTED` | 돈·할당량이 떨어졌다 | **던진다** |
| `CREDENTIAL_MISSING` · `CREDENTIAL_REJECTED` | 배포 설정이 틀렸다 | **던진다** |

- **`CREDIT_EXHAUSTED` 가 특히 그렇다** — Processor 가 그 코드를 보고 **이 이벤트의 남은
  유료 URL 을 건너뛴다**(E-4). 삼키면 그 신호가 사라져 **돈이 떨어진 채로 유료 호출이 계속
  나간다.** 보조 호출이 본 호출과 같은 크레딧을 쓰는 경우가 있다 — X 커뮤니티 개설자 조회가
  그렇다(`fetchProfile`, 같은 twitterapi.io 크레딧)
- **자격 두 종**은 배포 설정 오류다. 삼키면 설정이 틀린 채로 **8소셜이 조용히 반쪽**이 된다
- 반대로 X trending 의 리더 프록시는 우리 크레딧·자격을 쓰지 않아 전부 흡수해도 된다

## 분류 계약

### R-16 🔴 키를 못 뽑으면 `sourceType` 을 `'unknown'` 으로 (G-14)

`classify()` 의 반환은 **판별 유니온**이다. 종류가 있으면 키도 반드시 있고, 키가 없으면 종류는
`'unknown'` 이다.

```ts
type SocialRoute<T extends string = string> =
  | { sourceType: T;         sourceKey: string }
  | { sourceType: 'unknown'; sourceKey: null };
```

- **지켜야 하는 쪽은 `classify()` 다.** 키를 못 뽑았는데 종류를 그대로 답하면 타입이 거짓말을
  한다. 예: `?q=` 가 비었는데 `x_tweet_search` 를 답하는 경우
- **어기면** — 타입은 여전히 통과한다. 대신 생성기가 `sourceKey` 를 `string` 으로 믿고 쓰다가
  런타임에 빈 키로 행을 만든다. `platform_key` 가 빈 문자열인 행은 **정체성이 없는데도
  저장된다**
- **생성기 쪽 이득** — `if (!route.sourceKey)` 분기를 **쓸 수 없게 된다.** 도달 불가능한 분기에
  무엇을 반환해도 틀렸다: `UNSUPPORTED` 면 우리 버그가 정상 관측으로 보이고, `throw` 면 멀쩡한
  URL 이 `ERROR` 로 남는다
- ⚠️ **대가** — `'unknown'` 의 뜻이 넓어진다. "그 호스트지만 어떤 객체도 아닌 경로" 에
  "종류는 알겠는데 키가 없었다" 가 합쳐진다. 둘 다 `UNSUPPORTED` 로 끝나 동작은 같지만
  로그에서 구분되지 않는다

### R-17 🟠 `normalizeUrl()` 이 `null` 이면 **던진다**

생성기가 받는 URL 은 라우터가 이미 정규화한 값이다. 그것을 다시 정규화해서 실패하면 정규화가
자기 출력을 못 읽는다는 뜻이다.

- **어기면** — 입력 문제가 아니라 우리 버그인데 `UNSUPPORTED` 로 조용히 묻힌다

## 경로 일관성

### R-18 🔴 같은 객체를 만드는 **경로가 둘이면 산출이 같아야 한다**

거의 모든 소셜이 같은 계정을 두 경로로 만든다.

| 소셜 | 경로 A (직접) | 경로 B (딸려옴) |
|---|---|---|
| X | `x_profile` | `tweet.author` · `community.creator` |
| TikTok | 프로필 | `video.author` |
| Instagram | 프로필 | `post.owner` |
| Reddit | user | `post.author` |
| GitHub | owner | `repo.owner` |

**두 경로가 같은 필드 집합을 내야 한다.** 못 내면 둘 중 하나다 —
① **덜 주는 쪽에서 추가 호출로 채우거나**, ② **보장 안 되는 필드를 양쪽에서 다 뺀다.**
한쪽만 채운 채로 두는 것이 금지다.

- **어기면 — 도착 순서가 저장 모양을 가른다.** 같은 계정인데 어떤 토큰에서 보면 필드가 있고
  다른 토큰에서 보면 없다
- **갱신 로직이 생겨도 안 풀린다.** 갱신은 *"무엇을 덮을지"* 를 정해야 도는데(H-008),
  두 경로가 서로 다른 필드 집합을 내면 **어느 쪽이 맞는지 판단할 기준이 없다.**
  즉 이 규칙은 갱신을 미뤘기 **때문에** 느슨해지는 것이 아니라, 미룬 로직이 나중에
  일할 수 있게 만드는 전제다
- **조용하다** — 결손 종류에 따라 갈린다. 날짜가 빠지면 `new Date(undefined)` 가
  **Invalid Date** 가 되어 mongoose 가 거부하지만(시끄러움), **숫자·문자열이 빠지면 그 필드만
  없는 행이 그대로 저장된다.** 예외도 로그도 없다
- **확인 방법** — 두 경로에 **같은 대상**을 넣고 산출 객체를 통째로 비교하는 테스트를 둔다.
  필드를 하나씩 보면 나중에 추가되는 필드에서 다시 갈린다
- **⚠️ 필드를 추가할 때가 가장 위험하다.** 기존 두 경로가 같아도, 새 필드가 한쪽 응답에만
  있으면 그 순간 깨진다. X 의 계정 `outboundUrls`(X-8)가 그 후보다 — 프로필 응답에는
  `entities` 가 있는데 **중첩 author 에서는 생략된다**고 SDK 주석이 경고한다

<sub>X 는 통과한다. **매핑**은 두 경로가 같은 `transformUser` → `toActiveProfile` → `toAccount` 를
지나 산출이 문자열까지 동일하고(2026-08-07 실측), **채워짐**도 audit 유료 실호출로 확인됐다 —
*"`quoted_tweet` 의 키 집합이 최상위와 100% 동일하고 author 도 완전 프로필"*
(`social-url-structure-audit/scaffold/x.json` → `liveTests`).</sub>

---

# Part 3 — 구현 후 자가 점검

새 생성기를 다 쓴 뒤 이 순서로 훑는다.

**그래프 모양**
- [ ] 묶음이 N 개일 때 `rootRef` 에서 `parentRef` 로 전부 도달하는가 (R-1)
- [ ] 인용이 아닌 이유로 묶음을 가른 곳이 있는가 (R-2)
- [ ] `parentRef` 가 가리키는 묶음에 `content` 가 있는가 (R-3)
- [ ] `parentRef` 방향이 "내가 인용한 원본" 인가 (R-6)

**객체**
- [ ] 계정·베뉴·콘텐츠·멘션 **전부** `platformKey` 가 있는가 (R-7)
- [ ] 키를 못 만드는 경우 객체를 빼고 `status` 로 신호를 남겼는가 (R-7)
- [ ] 첫 생성이 곧 최종임을 알고, 채울 수 있는 필드를 전부 채웠는가 (R-8)
- [ ] `outboundUrls` 에 `normalizeUrl()` 을 걸었는가 (R-9)
- [ ] 지표·`data` 를 스프레드가 아니라 필드 단위로 썼는가 (R-10)
- [ ] `creatorId`·`venueId`·`parentContentId` 를 비웠는가 (R-12)
- [ ] 같은 객체를 만드는 **두 경로의 산출을 통째로 비교**했는가 (R-18)
- [ ] 새 필드를 더했다면 **두 경로 다 채워지는가** (R-18)

**반환**
- [ ] 세 갈래(`OK` / 안 부름 / 부름)가 `graph`·`attempted` 와 정확히 맞는가 (R-13 · R-14)
- [ ] 새 `FetchStatus` 값을 추가했다면 **기존 갈래에 편입**했는가, 갈래를 늘리지 않았는가 (R-13)
- [ ] 호출 실패를 `ExternalFetchError` 로 던지는가 (R-15)
- [ ] 흡수한 호출이 있다면 **실패해도 남는 값이 있는가** 를 통과했는가 (R-15 예외)
- [ ] 흡수하는 자리에서 **크레딧·자격 코드는 다시 던지는가** (R-15 단서)

**분류**
- [ ] 키를 못 뽑는 경로가 전부 `'unknown'` 으로 떨어지는가 (R-16)
- [ ] `normalizeUrl()` 이 `null` 일 때 던지는가 (R-17)

**테스트**
- [ ] URL 종류마다 묶음 개수와 `rootRef` 를 검증하는가
- [ ] 인용 1단·2단에서 `parentRef` 사슬이 서는가
- [ ] 자기 인용(작성자가 같은 인용)에서 계정 링크가 **1건**이고 깊이 **0** 인가
- [ ] 결측 필드가 `null` 로 떨어지는 경로가 있는가

---

# 부록 — 미확정 · 알려진 한계

이 문서가 기술하는 정책 중 **아직 안 정해졌거나 알면서 감수하는** 것들이다.
생성기 구현에서 이걸 우회하려 들면 안 된다.

| 항목 | 내용 | 추적 |
|---|---|---|
| `FetchStatus.SUSPENDED` | **아직 enum 에 없다.** X 구현 전에 추가하고, `GenerateResult` 의 **갈래 3** 에 편입시킨다(R-13) | G-5 |
| **`accounts.unavailable`** | **채우는 경로가 없다.** 정지 계정은 키를 못 만들어 행 자체를 안 만들고(X-3), 행이 이미 있는 계정이 나중에 정지되는 경우는 재관측 갱신이 없어 반영되지 않는다. 결과적으로 정지 신호는 `tokens.social_urls[].status` 에만 남고, "어느 계정이 정지됐나" 는 URL 을 다시 `route()` 해서 역추적해야 한다. audit 이 이걸 actionable(*"런칭 후 계정 정지 = 팀 이탈 강신호"*)로 판정했으므로, 실제로 쓰려면 H-008 이 먼저 풀려야 한다 | H-008 · X-3 |
| **`search`·`intent`·`trend` 가 키 공간을 공유** | `contents` 조회가 `(platform, platform_key)` 뿐이라(`subtype` 이 빠진다) 정규화된 검색어와 intent 문구가 같은 문자열이면 **한 행으로 합쳐지고 `subtype` 은 먼저 온 쪽으로 고정된다.** 합치는 것은 인덱스가 아니라 `findContent()` 의 조회다 — 인덱스는 unique 도 아니라 애초에 막지 않는다. `platform_key` 에 접두사를 붙이면 막히지만, 그러면 `subtype` 이라는 같은 사실이 두 곳에 살게 되어 어긋난 행을 아무도 못 잡는다. 잃는 것은 두 종류의 구분뿐이고 `token_links` 는 양쪽 다 붙는다 | G-13 |
| `ContentDataInput` | 현재 `{[key: string]: never}` 라 **아무것도 못 담는다.** X 가 첫 정의 | G-7 · X-6 |
| 재관측 갱신 | 기존 행의 모든 필드가 첫 관측에서 얼어붙는다 | H-008 |
| `metrics_latest` ↔ `metric_series` | 첫 관측 시점이 달라 갈릴 수 있다 | H-007 |
| 재수집 배치 | 없다. 일시 실패 = 현재로선 영구 미기록 | H-009 |
| 같은 대상 두 URL | 정규화 문자열이 다르면 dedup 안 돼 fetch 2회 | H-011 |
| 정규화 실패 URL | 흔적이 아예 안 남는다 | H-012 |
| 링크 중복 | 재처리 부산물과 재관측 이력이 구분 안 된다 | H-013 |
| 동시 생성 | `contents`·`venues` 는 unique 가 아니라 두 행이 생길 수 있다 | H-005 |
