# X Generator — 구현 계획

> 상태: **구현 완료 · 루브릭 전수 점검 완료**(§12, 2026-08-11). `x/x.generator.ts` 547줄,
> 커밋 `02bed71`·`e790b59`·`58a63ea`·`5d24653`. 아래 결정은 2026-08-06 대화에서 확정됐다.
> §8 의 실측 확인 항목은 대부분 닫혔고, 남은 미결은 [`backlog.md`](../../../docs/features/social-generator/backlog.md) 가
> 추적한다(B-7).

## 0. 범위

`XGenerator` 하나. `SocialGenerator` 를 구현하고 `XFetcher` · `XCommunityFetcher` 를 주입받아
`GenerateResult` 를 돌려준다. 저장·`_id`·링크·토큰은 모른다.

> ⚠️ **구현 전 [`guides/social-generator-rubric.md`](../../social-generator-rubric.md)
> 를 전수 확인한다** (작업 중에는
> [`.html`](../../social-generator-rubric.html)).
> 이 문서는 **X 에만 해당하는 결정**을 담는다 — 8종 공통으로 지켜야 할 R-1~R-18 은 거기 있고,
> 절반은 **어겨도 예외가 나지 않는다.**

**다루는 URL 종류 6개** — `x_profile` · `x_tweet` · `x_community` ·
`x_tweet_search`(196건) · `x_intent` · `x_trend`(82건, 신규 종류).
뒤 셋은 비객체 URL 로, 검색·intent 는 **호출 0회**, trending 은 **실패해도 되는 보조 호출 1회**다.
`grok` · 예약 루트 · 그 밖의 `unknown` 은 `UNSUPPORTED` 로 끝낸다. 배경은 §9.

## 1. 왜 X 가 첫 타자인가

X 는 Creator · Content · Venue 셋을 모두 가진 유일한 소셜이다(`x/x.types.ts:4`).
인용 트윗이 별도 묶음이 되어 사슬을 만들고, 커뮤니티 진입은 베뉴와 그 개설자를 함께 낸다.
즉 `SocialObjectGroup` 계약이 감당해야 할 최대 부하가 X 에 전부 있다 — 여기서 통과하면
나머지 7소셜은 부분집합이다. "모든 객체에 `platform_key` 를 항상 만들 수 있다" 는 아직
실측이 아닌 가정인데, 그것도 여기서 처음 깨진다(§3 X-3).

## 2. 구조

```ts
@Injectable()
export class XGenerator implements SocialGenerator {
  readonly platform = SocialPlatform.X;

  constructor(
    private readonly fetcher: XFetcher,
    private readonly communityFetcher: XCommunityFetcher,
  ) {}

  async generate(url: string, opts: FetchOptions, observedAt: Date): Promise<GenerateResult>
}
```

**구체 클래스를 주입받는다.** `SocialFetcher` 인터페이스에는 `classify()` 밖에 없고, 그 이유가
타입 주석에 있다 — *"종류를 아는 쪽은 그 소셜의 Generator 다. 자기 fetcher 를 물고 있으니
`classify()` 가 돌려주는 자기 타입(`XRoute` 등)을 그대로 쓴다"*(`social-fetcher.types.ts:64`).
따라서 `SocialRoute<XSourceType>` 을 좁은 타입 그대로 받고 **캐스팅이 0개**다.

URL 파싱은 생성기가 직접 한다 — `normalizeUrl()` 은 `url-normalizer.ts` 에 독립돼 있고
소비자가 라우터·fetcher·Generator 셋으로 이미 설계돼 있다.

```
generate(url)
  └ normalizeUrl(url) → ParsedUrl
      └ fetcher.classify(parsed) → { sourceType, sourceKey }
          ├ x_profile   → generateProfile(sourceKey)
          ├ x_tweet     → generateTweet(sourceKey, opts)
          ├ x_community → generateCommunity(sourceKey, opts)
          └ 그 외        → UNSUPPORTED (보류분, §9)
```

## 3. 결정 로그

| ID | 결정 | 근거 |
|----|------|------|
| **X-1** | **fan-out 전부** — 트윗 + 작성자 + 인용/리트윗 원본(깊이 2)을 각각 묶음으로 | 스키마가 이미 이걸 전제한다: `TokenLinkInput.linkDepth` 주석이 *"0이면 직접, 1 이상이면 인용 경유"*, `parentRelation` 이 `quote`·`retweet`·`fork`. 추가 외부 호출 0건(응답에 이미 들어 있음).<br>✅ **저장 어휘는 이미 맞춰져 있다**(정정, 2026-08-11) — 착수 시점엔 `LINK_MAX_DEPTH=2` 라 깊이 2가 1로 뭉개진다고 적었으나, **그 뒤 `LINK_MAX_DEPTH=3` 으로 이미 올라갔다**(`social-graph.consts.ts:34`). `TokenLink.capDepth()` 가 `LINK_MAX_DEPTH - 1 = 2` 로 깎으므로 fetcher 의 3단계(깊이 0·1·2)가 그대로 들어간다. 2026-08-10 라이브 실측으로 확인 — `link_depth` 분포 0/1/2 = 51/14/**2**, 뭉개진 흔적 없음 |
| **X-2** | **작성자 계정은 `author` 스냅샷을 통째로** 채운다 | `XTweet.author` 의 타입이 `XActiveProfile` **그 자체**이고, 프로필 fetch 와 트윗 파싱이 같은 `toActiveProfile()` 을 거친다(`x.fetcher.ts:197`·`:144`). 즉 "트윗 정보가 부족하니 프로필을 추가로 부른다" 는 선택지가 X 에는 없다 — 불러도 같은 11필드다. `resolveAccount` 가 기존 행을 갱신하지 않으므로(`social-graph.writer.ts:285`) **생성기가 채우지 않으면 그 자리는 비어서 저장된다** — 갱신은 별도 로직 소관이고(H-008), 그 로직도 fetcher 가 값을 주지 않으면 채울 수 없다 |
| **X-3** | **정지·탈퇴 계정은 `FetchStatus.SUSPENDED`(신규) 로 끝낸다.** 계정 행은 만들지 않는다 | `XUnavailableProfile` 에 `id` 가 없어 `platformKey` 를 만들 수 없다(`x.types.ts:57`). 그런데 audit 은 이걸 **actionable**(*"런칭 후 계정 정지=팀 이탈 강신호"*)로 판정했다. `not_found` 로 뭉개면 "팀이 계정을 지웠다" 와 "처음부터 가짜 링크였다" 가 한 값이 되고 **재시도 정책도 같이 뭉개진다** — 정지는 풀리고 없는 핸들은 안 생긴다. 프로세서가 `TARGET_BLOCKED` 를 `ERROR` 에서 따로 가르는 이유와 같은 근거다(`social-record.processor.ts:271`) |
| **X-4** | **커뮤니티 개설자는 프로필을 1콜 더 불러 채운다** | `XCommunity` 는 `creatorId`·`creatorUserName` 만 준다. 그것만으로 계정을 만들면 `accountCreatedAt`·`followers`·`bio` 가 **영구 미상태**가 된다(갱신 경로 없음). 커뮤니티 자체가 이미 opt-in(20 credits)이라 그 안에서의 1콜 추가는 상대적으로 작다. 그 프로필이 정지 계정이면 `venue.creator = null` 로 떨어진다.<br>✅ **2026-08-11 라이브 실측으로 확인** — `SOCIAL_RECORD_ENABLE_COMMUNITY` 게이트만 켜면 코드는 그대로 동작했다. Venue(`members`·`moderatorCount`·`admin_handle`) · 개설자 계정(1콜 추가) · 인용 사슬까지 전부 실제로 저장됨을 확인했다(`backlog.md` B-4) |
| **X-5** | **트윗 경로에서는 커뮤니티를 만들지 않는다.** 트윗은 `content` + `creator` 까지다 (2026-08-07 변경) | 원래는 `communityInfo` 로 venue 를 채우려 했으나 세 가지가 걸렸다. ① **정보성이 낮다** — audit 의 traderValue 가 `context` 등급이고(actionable 아님) 커뮤니티 자체도 *"상시 파이프라인 아닌 온디맨드 보강용"* 판정이다. ② **깊이가 거짓말을 한다** — 개설자가 진입 트윗과 같은 묶음이라 `link_depth: 0` 을 받는데, 그 계약은 *"토큰이 **직접** 건 URL 의 대상"* 이라 **토큰과 무관한 사람이 홍보 명단에 섞인다**. ③ **경로에 따라 저장 모양이 갈린다** — 커뮤니티 URL 로 오면 개설자가 있고 트윗 경유면 없다.<br>부수 이득으로 커뮤니티 트윗의 호출이 3회에서 1회가 된다.<br>✅ **`communityInfo` 는 담지 않는다 — 확정**(2026-08-11). 한때 `graph.raw` 에 실려 간다고 정당화했으나 그 자리가 사라졌고(X-9 폐기), `contents.data` 로 옮기려면 `unknown` 인 실응답 구조를 먼저 확인해야 했다. **확인할 값 자체가 없었다** — audit 10건 + 2026-08-10 라이브 실측 2건(커뮤니티 소속이 확인된 트윗과 그 인용 원본 포함), 합쳐서 **12/12 전부 `null`**. 저장 여부가 아니라 **저장할 값이 관측된 적이 없는 문제**였다. 구조가 안 잡히면 지어낼 수도 없어 이 값은 더 열어 두지 않는다 |
| **X-6** | **`ContentDataInput` 을 X 가 첫 정의한다** — `conversationId` · `inReplyToId` · `inReplyToUserId` 를 X 이름 그대로 | audit 이 셋을 `keep` 으로 판정했다(*"여러 계정이 같은 스레드에 몰리는 조율 패턴 탐지 + 원본/파생 구분"*). `ContentMetrics` 주석이 이미 같은 규칙을 못박고 있다 — *"키 이름은 지어내지 않고 각 fetcher 반환 타입의 실제 필드명을 그대로 쓴다"* |
| **X-7** | **`quoted` 와 `retweeted` 가 동시에 오면 `parentRef` 는 `retweeted` 쪽이고, 최상위 `quoted` 는 그것이 `retweeted.quoted` 와 같은 트윗이면 버린다** | 그 경우는 **인용트윗을 리트윗**한 것 하나뿐이고, 그때 최상위 `quoted` 는 독립적인 두 번째 참조가 아니라 **중첩의 복제**다. 중첩을 따라가면 `A → B(retweeted) → C(quoted)` 사슬이 되고 C 는 B 를 통해 이미 묶음이 된다. 복제를 독립 묶음으로 또 내면 같은 트윗이 두 번 나오고, 하나는 `rootRef` 에서 도달할 수 없어 **Writer 가 던진다**(G-2).<br>X 의 작성 모델상 하나의 트윗이 서로 다른 두 트윗을 가리키는 행위는 없다(G-9). **id 가 다르게 나오면 우리가 모르는 모양이므로 그때 다시 판단한다** — §8 확인 항목 |
| **X-9** | ❌ **폐기 (2026-08-07)** — `graph.raw` 자체가 사라졌다 | 원래 결론은 *"`graph.raw` 는 fetcher 반환값이다"* 였다. 그 결론이 맞다는 것이 폐기 사유가 됐다 — `object_raw` 의 명분이 "무손실 원본" 인데 SDK 의 transform 을 이미 거친 값을 담고 있었다(`twitter-api.sdk.ts:88` 에서 원문이 함수 안에서 사라진다). 횡단 결정 **G-8 폐기 · G-12** 참조.<br>⚠️ **X-5 의 전제가 함께 바뀐다** — `communityInfo` 를 "`raw` 에 실려 가니 백필로 꺼낸다" 로 정당화했는데 그 자리가 없어졌다. 대체 자리를 `contents.data` 로 잡았다가 **그것도 보류로 돌렸다**(X-5 · §8) |
| **X-13** | **`following` → `data` · `moderatorCount` → `metrics` · `badges` 배열 안 씀** | 배치 기준은 *"쿼리·정렬·비교의 축인가"* 다. `followers` 는 축이 될 수 있지만 `following` 은 **비율로 읽는 배경정보**라 `data` 로 간다 — `data.contentCount` 가 같은 이유로 이미 거기 있다. 반대로 `moderatorCount` 는 `members` 와 같은 **규모 지표**라 `metrics` 다(`data.moderators` 가 배열이라 수를 못 담는 것이지 수가 `data` 성격인 게 아니다). `badges` 는 X 가 boolean 둘(`isVerified`·`isBlueVerified`)만 주는데 배열로 옮기려면 `['verified','blue']` 같은 **어휘를 지어내야** 하고, 그건 G-7 위반이다. 게다가 false 인지 필드가 없는 건지 구분도 사라진다 — 동명 boolean 둘로 그대로 담는다 |
| **X-14** | **`classify` 반환을 판별 유니온으로 좁힌다** — 키가 없으면 `unknown` | 지금 타입은 `sourceType` 과 `sourceKey` 가 서로 무관하게 선언돼 `{ sourceType: 'x_profile', sourceKey: null }` 이 **가능해 보인다.** 실제 코드는 그런 값을 절대 만들지 않는데도 생성기가 도달 불가능한 분기를 써야 하고, 거기 무엇을 반환하든 틀린 선택이 된다(`UNSUPPORTED` 면 우리 버그가 정상 관측처럼 보이고, `throw` 면 멀쩡한 URL 이 `error` 로 남는다).<br>`{ sourceType: XSourceType; sourceKey: string } \| { sourceType: 'unknown'; sourceKey: null }` 로 좁히면 **그 분기가 컴파일 단계에서 사라진다.** 조건은 하나 — `classify` 가 "키를 못 뽑으면 `unknown`" 을 지키는 것이고, 이미 그렇게 동작한다.<br>`normalizeUrl` 이 `null` 을 주는 경우는 다르다. 생성기가 받는 것은 **라우터가 이미 정규화한 URL** 이라, 그것을 다시 정규화해서 실패하면 정규화가 자기 출력을 못 읽는다는 뜻이다 — 입력 문제가 아니라 우리 버그이므로 **던진다**.<br>⚠️ `SocialRoute` 는 8소셜 공통이라 횡단 결정이다(G-14). 그리고 `unknown` 에 **네 번째 의미**가 섞인다 — "우리가 안 다루는 경로" 에 "다루는 종류인데 키가 없었다" 가 합쳐진다. 결과는 어차피 `UNSUPPORTED` 로 같지만 로그에서 구분되지 않는다 |
| **X-8** | **계정 `outboundUrls` 를 채운다 — `entities` 의 확장 원본으로** (2026-08-07 실측 확정) | audit 이 `url`·`entities` 를 `skip` 판정했으나 근거가 *"응답에 존재하나 fetcher 미사용"* 이지 가치 판단이 아니다. 같은 audit 이 `description` 은 **actionable**(*"CA 복붙·과거 다수 프로젝트 홍보 흔적"*)로 판정했다 — 바이오 본문은 담으면서 바이오 안의 링크는 버리는 상태다. 트윗 쪽은 이미 `expandedUrls()` 로 편다. 추가 외부 호출 0건.<br>**최상위 `url` 은 쓰지 않는다** — t.co 축약이라 정규화해도 `t.co` 로 남아 도메인 대조에 쓸모가 없다. 확장 원본은 `entities.url.urls[]` 와 `entities.description.urls[]` 두 자리에 있고 둘을 합친다 |

### 질문 없이 둔 가정

- `enableCommunity` 가 꺼진 `x_community` URL → `SKIPPED_PAID` · `attempted: false`
- `outboundUrls` 중 `normalizeUrl()` 이 실패한 값은 **버린다** — 라우터의 URL 정책과 같다(v5 규칙 ②)
- `knownWallets` · `data.accountType` · `data.sourceRef` 는 X 에서 채우지 않는다(§5 참조)

## 4. URL 종류별 산출

| 진입 | 외부 호출 | 묶음 | `rootRef` | status |
|------|-----------|------|-----------|--------|
| `x_profile` (active) | `fetchProfile` 1 | `{ creator }` 1개 | 그 묶음 | `OK` |
| `x_profile` (정지) | `fetchProfile` 1 | 없음 | — | `SUSPENDED` |
| `x_profile` (없음) | `fetchProfile` 1 | 없음 | — | `NOT_FOUND` |
| `x_tweet` | `fetchTweet` 1 | 1 ~ 3 (인용 체인) | 최상위 트윗 묶음 | `OK` |
| `x_community` (opt-in on) | `fetchCommunity` 1 + `fetchProfile` 1 | `{ venue, venue.creator }` 1개 | 그 묶음 | `OK` |
| `x_community` (opt-in off) | 0 | 없음 | — | `SKIPPED_PAID` |
| `x_tweet_search` | **0** | `{ content(search) }` 1개 | 그 묶음 | `OK` · `attempted: false` (X-10 · §9) |
| `x_intent` (`text` 있음) | **0** | `{ content(intent) }` 1개 | 그 묶음 | `OK` · `attempted: false` (X-11 · §9) |
| `x_trend` (신규, 82건) | 리더 프록시 1 (**실패 허용**) | `{ content(trend) }` 1개 | 그 묶음 | `OK` (X-12 · §9) |
| `x_intent` (`text` 없음) · grok · `unknown` | 0 | 없음 | — | `UNSUPPORTED` |

트윗 1건의 묶음 구성(X-1 · X-7):

```
묶음 A: content(tweet) + creator(author)          ← venue 는 없다(X-5)
  └ parentRef → B (retweeted 우선, 없으면 quoted)
묶음 B: content(원본) + creator(원본 author)
  └ parentRef → C (같은 규칙, 깊이 2까지)
묶음 C: content + creator

rootRef = A
```

**끊긴 묶음을 내면 안 된다.** 위 그림처럼 모든 묶음이 `rootRef` 에서 `parentRef` 를 타고
닿아야 한다 — 가지도 고아도 없는 **사슬 하나**다. 아니면 Writer 가 던진다(G-2 · X-7).

`linkDepth` 는 **Writer 가 그 사슬에서의 자리로 계산한다**(`social-graph.writer.ts:103`).
생성기는 `parentRef` 만 정확히 걸면 된다.

## 5. 필드 매핑

### AccountInput ← `XActiveProfile`

| 대상 | 출처 | 비고 |
|------|------|------|
| `platform` | 상수 `X` | |
| `platformKey` | `id` | 숫자 id. **핸들 금지** — 개명 가능 |
| `handle` | `userName` | |
| `displayName` | `name` | |
| `accountCreatedAt` | `createdAt` | audit: 러그리스크 1차 tell |
| `bio` | `description` | audit: actionable |
| `outboundUrls` | `entities` 확장 링크 (`url` + `description` 두 자리) | ✅ X-8 완료. t.co 가 아니라 확장 원본 |
| `metrics.followers` | `followers` | |
| `data.contentCount` | `statusesCount` | |
| `data.isVerified` · `data.isBlueVerified` | 동명 boolean 둘 | **`badges` 배열을 쓰지 않는다**(X-13) |
| `data.following` | `following` | 조회축이 아니라 `followers` 와의 비율로 읽는 값(X-13) |
| `observedAt` | 인자 | |
| ~~`knownWallets`~~ | — | X 응답에 없음 |
| ~~`data.accountType`~~ | — | `TwitterUser.type` 이 사실상 `'user'` 고정 |
| ~~`data.sourceRef`~~ | — | 스키마에만 있고 정의가 없다 |
| ~~`unavailable`~~ | — | 계정 행 자체를 안 만든다(X-3) |

`metrics` 에는 `followers` 만 간다. `following` 은 `data` 로 내려간다 — 자리가 없어서가 아니라
**조회축이 아니기 때문**이다(X-13). `data.contentCount` 가 같은 이유로 이미 `data` 에 있다.

### ContentInput ← `XTweet`

| 대상 | 출처 |
|------|------|
| `platform` · `subtype` | `X` · `ContentSubtype.TWEET` |
| `platformKey` | `id` |
| `publishedAt` | `createdAt` |
| `text.primary` | `text` (엔티티 디코딩은 fetcher 가 이미 함) |
| `outboundUrls` | `urls` → 각각 `normalizeUrl()`, 실패분 버림 |
| `tags` | `hashtags` |
| `mentions` | `mentions` → `{ platformKey: id, screenName }` |
| `metrics` | `viewCount`·`likeCount`·`replyCount`·`retweetCount`·`quoteCount`·`bookmarkCount` — **6종 전부 자리가 있다** |
| `data` | `conversationId` · `inReplyToId` · `inReplyToUserId` (X-6). `communityInfo` 는 **담지 않는다**(X-5 · 12/12 실측 `null`) |
| `parentRelation` | `isRetweet` → `RETWEET`, `isQuote` → `QUOTE` |
| `creatorId` · `venueId` · `parentContentId` | **비운다** — Writer 가 저장 후 채운다 |

⚠️ 지표는 **필드 단위로 명시 매핑**한다. 스프레드로 넘기면 초과 속성 검사가 안 걸리고
mongoose strict 가 모르는 키를 조용히 버린다(`content-metrics.ts:24`).

### VenueInput ← `XCommunity`

| 대상 | 출처 |
|------|------|
| `platform` · `subtype` | `X` · `VenueSubtype.COMMUNITY` |
| `platformKey` | `id` |
| `name` · `description` | 동명 |
| `venueCreatedAt` | `createdAt` |
| `metrics.members` | `memberCount` |
| `data.adminHandle` | `adminUserName` |
| `creator` | `creatorUserName` 으로 프로필 1콜(X-4) |
| `metrics.moderatorCount` | `moderatorCount` — `members` 와 같은 **규모 지표**라 `metrics` 다(X-13). `data.moderators` 는 배열(명단)이라 수를 못 담는 것이지, 수가 `data` 성격인 것은 아니다 |

## 6. 실패 표현

- **대상 없음** → `status: NOT_FOUND` · `graph: null` · `attempted: true`
- **호출 실패** → **삼키지 않고 그대로 던진다.** Processor 가 `ExternalFetchError` 를 코드별로
  처리하고 URL 단위로 격리한다(`social-record.processor.ts:210`)
- **정지 계정** → `SUSPENDED`(X-3)
- **opt-in 미충족** → `SKIPPED_PAID` · `attempted: false`
- **보류 종류** → `UNSUPPORTED` · `attempted: false`

`attempted` 는 **외부를 실제로 불렀는가**다. `attempts` 집계의 유일한 근거라 추정하지 않는다.

## 7. 선행 작업 (생성기 코드보다 먼저)

이 셋은 생성기 밖의 변경이라 별도 커밋으로 간다.

1. **`FetchStatus.SUSPENDED` 추가** (`social-graph.consts.ts`) — 횡단 결정이라
   [`decisions.md`](../decisions.md) G-5 에도 올린다
2. **`ContentDataInput` 에 X 키 3종 정의** (`social-graph.types.ts`) — 지금은 `{ [key: string]: never }`
3. ~~**fetcher 에 계정 `outboundUrls` 추가** (X-8)~~ ✅ 완료 — `TwitterUser` 에 `entities` 선언 +
   `XActiveProfile.outboundUrls: string[]` + `toActiveProfile()` 매핑.
   `fetcher-typing-refactor` T-002 의 소폭 재개방이다
4. **`classify` 가 intent 의 `text` 를 뽑도록** (X-11) — 지금은 `sourceKey: null` 이다
   (`x.fetcher.ts:121`). 3번과 같은 파일이라 함께 간다
5. **`XSourceType` 에 `x_trend` 추가 + `classify` 의 `i` 분기에서 `trending` 을 가르도록** (X-12) —
   지금은 `communities`·`user`·`status` 만 처리하고 나머지는 전부 `unknown` 이다(`x.fetcher.ts:94`)
6. **`ContentSubtype.TREND` 추가** (`social-graph.consts.ts`) — 기존 `UNKNOWN` 을 쓰면
   YouTube playlist·web 의 "미지원" 과 섞인다. 횡단 어휘라 [`decisions.md`](../decisions.md) 에도 올린다
7. **리더 프록시 경로 신설** (X-12) — `r.jina.ai` 호출 + snowflake 디코딩 유틸.
   **실패를 흡수하는 유일한 지점**이라 그 예외 사유를 코드 주석에 남긴다

## 8. 확인 필요 항목 (구현 전 해소)

> **먼저 `scaffold/x.json` 의 `liveTests` 를 본다.** X 는 audit 단계에서 유료 실호출을
> 했고(프로필 2건 · 커뮤니티 1건 · 트윗 12건 일괄), 그 원문이 거기 있다. 2026-08-07 에
> 이 절의 항목 절반이 **이미 답이 나와 있었다** — 확인하지 않고 새 호출을 요청했었다.

### ✅ 실호출로 닫힘 (2026-08-07)

- [x] **프로필 응답의 `entities` 안에 확장 URL 이 있는가** (X-8) — **있다.**
      ```json
      "url":         { "urls": [{ "expanded_url": "https://www.untaxed.wtf" }] }
      "description": { "urls": [{ "expanded_url": "http://t.me/untaxedwallet" }] }
      ```
      최상위 `url` 은 예상대로 t.co 축약(`https://t.co/PL4ISREyc4`)이었다. 확장 원본은
      `entities` 에만 있고, audit 덤프는 그 자리를 `{...}` 로 잘라 놓았었다.
      **바이오 링크가 텔레그램을 가리킨다** — 계정이 스스로 선언한 타 플랫폼 채널이고,
      축약된 채로 두면 그 사실이 드러나지 않는다. 두 자리를 합쳐 `outboundUrls` 로 담는다.

### ✅ 확정 (2026-08-11)

- [x] **`communityInfo` 는 담지 않는다** — audit 10건 + 라이브 실측 2건(2026-08-10,
      `x_community` 경로로 확인된 개설자 소속 트윗과 그 인용 원본 포함), 합쳐서
      **12/12 전부 `null`**. 소속이 확실한 트윗조차 비어 있어 표본 부족이 아니라
      **이 API 가 사실상 이 필드를 채우지 않는다**는 쪽의 근거다. 값이 없으니 구조를
      확인할 수 없고, 확인할 수 없으니 담을 자리를 지어낼 근거도 없다. `backlog.md` B-5 닫음

### ✅ audit 실측으로 이미 닫힌 것

- [x] **중첩 `quoted` 의 파싱과 채워짐** (X-1 · X-7 · R-18 의 전제)
      `liveTests` 에 사례가 있다 — `id=2070140594496626759`(`isQuote=true`) →
      `quoted_tweet.id=2070130818232787327`, 그 `author={id, userName, followers, createdAt}`.
      audit 결론이 *"`quoted_tweet` 의 키 집합이 최상위와 **100% 동일**하고 author 도 완전
      프로필"* 이다. **두 경로가 같은 필드를 낸다는 R-18 의 근거가 실측이다.**
      ⚠️ 단 `isRetweet=true` 사례는 여전히 0건이라, X-7 의 "리트윗된 인용트윗" 은 미관측이다.
      생성기는 그 경우 `quoted.id !== retweeted.quoted.id` 면 warn 을 남긴다.
- [x] **트윗 `author` 가 33키 완전 프로필** — 별도 프로필 호출이 불필요하다는 X-2 의 근거
- [x] **정지 계정 응답 모양** — `{"unavailable":true,"message":"User is suspended",
      "unavailableReason":"Suspended"}`. `id` 가 없어 키를 못 만든다는 X-3 의 근거
- [x] ~~`following` · `moderatorCount` · `badges` 값 어휘~~ — X-13 에서 확정

### ✅ 관측 0이지만 **조용히 틀리던 것** — 닫음 (2026-08-07)

둘 다 관측이 0이라 미뤄뒀는데, 오면 **틀린 데이터를 만드는** 종류라 먼저 닫았다.

- [x] **`x.com/i/user/{numericId}` → `unknown`**
      `x_profile` 로 보내면 SDK 가 핸들 조회만 지원해 빈 결과가 오고, 그러면 **존재하는
      계정이 `not_found` 로 기록된다.** 그건 "처음부터 가짜 링크였다" 와 같은 값이 되어
      재시도 정책까지 뭉갠다 — X-3 에서 `SUSPENDED` 를 가른 것과 같은 근거다.
      못 다루는 것을 못 다룬다고 말하는 쪽을 택했다(R-16).<br>
      ⚠️ **열려면 경로는 있다** — `/twitter/user/batch_info_by_ids?userIds=`(단건 18 credits).
      지금 안 여는 이유는 둘이다: 관측이 0건이고, 그 응답에 `entities` 가 있는지 미검증이라
      열면 **R-18 이 다시 열린다**(프로필 경로만 `outboundUrls` 를 갖게 된다).
- [x] **`intent/*` 는 파라미터가 대상을 말한다**
      `screen_name` 이 있으면 `x_profile`, `tweet_id` 가 있으면 `x_tweet` 으로 돌려보낸다.
      `follow`·`user`·`like`·`retweet` 이 그렇다 — **사실상 프로필·트윗 URL**인데 비객체로
      흘리면 그 계정·트윗을 통째로 잃는다. 종류 이름을 열거하지 않는 것은 파라미터가 이미
      답을 갖고 있어서다. 새 intent 종류가 생겨도 규칙이 안 바뀐다.
      `text` 만 있는 `intent/post` 는 그대로 `x_intent` 다(X-11).

## 9. 비객체 URL

### 검색 — 확정 (X-10)

**호출 0회로 검색어 행 1건만 만든다. 통계에는 포함시키고 디테일은 무시한다.**

| 대상 | 값 |
|------|-----|
| `subtype` | `ContentSubtype.SEARCH` |
| `platformKey` | `q` 정규화값 — **소문자 + `trim` + 연속 공백 축소**. 공백은 남긴다 |
| `text.primary` | **`platformKey` 와 같은 값**. 원문을 따로 나르지 않는다 (아래) |
| 그 외 전부 | 빈 처리 (`outboundUrls: []` · `tags: []` · `mentions: []`, `metrics`·`data`·`publishedAt` 없음) |
| 외부 호출 | **0회** · `attempted: false` |

정규화 예시 — `x.com/search?q=dustin%20poirier&src=typed_query&f=top`
→ `platformKey` · `text.primary` 둘 다 `"dustin poirier"`.
`src`·`f` 는 버린다(같은 검색의 UI 변형이 한 행으로 모인다).

**공백을 남기기로 바꿨다 (2026-08-07).** 원래는 공백까지 없애 `?q=to the moon` 과
`/hashtag/tothemoon` 을 한 행으로 모으려 했다. 구현 중 두 가지가 드러났다.

① **그 병합 이득이 관측 기준 0 이다** — 표본의 유일한 해시태그가 쐐기문자열이라 어떤
검색어와도 안 맞는다. ② **`classify` 는 정규화된 키만 돌려주므로 원문이 그 안에서 사라진다.**
원문을 `text.primary` 에 넣으려면 생성기가 `params.get('q')` 를 직접 읽어야 하는데, 그러면
"검색은 `q`, intent 는 `text`, 해시태그는 첫 세그먼트" 라는 **X URL 공간 지식이 두 곳에 산다.**

공백을 남기면 키가 곧 읽을 수 있는 값이 되어 그 경로 자체가 없어진다. 대신 잃는 것은
대소문자다 — 소문자화까지 빼면 `To The Moon` 과 `to the moon` 이 두 행이 되는데, 검색어로서는
그게 진짜 중복이라 소문자는 유지한다.

**근거** — audit 이 이 링크의 의도를 *"'계정을 봐라'가 아니라 '이 밈이 실재한다는 걸
확인해라'"* 로 판정했다(`task-012.md:115`). 그 판정에는 디테일이 필요 없다.
그리고 **무료로 부를 방법이 없다** — X 공식 API v2 무료 티어에 검색이 없고,
twitterapi.io 검색은 credit 과금이다.

**부수 효과(승인됨)** — `x.com/hashtag/{tag}` 가 같은 키로 떨어져 같은 행에 모인다.
`classify` 가 이미 둘을 `x_tweet_search` 로 통합해뒀으므로 일관된다.

**감수하는 것** — `resolveContent` 가 기존 행을 덮지 않으므로 나중에 검색 fetch 를 켜도
이 행의 `text`·`data` 는 이 경로로는 안 채워진다. **백필 배치로 해결한다** — 갱신 정책은
이미 별도 작업으로 예정돼 있다(`social-graph.writer.ts:280`, v5 §7). 그때 조인키가
바로 이 `platformKey` 다.

**접두사는 붙이지 않는다 (2026-08-07 확정).** 한때 `search:` 같은 접두사로 키 공간을 가르려
했으나 철회했다 — 그러면 `subtype` 이라는 **같은 사실이 두 곳(필드와 키)에 살고**, 둘이 어긋난
행(`platform_key="search:foo"` 인데 `subtype=intent`)을 아무도 잡을 수 없다. 이 레포가
반복해서 피해 온 실패 모드다(`social-fetcher.consts.ts:15` · `token-link.model.ts:40`).

**대신 제약으로 기록한다** — `search`·`intent`·`trend` 는 `contents` 안에서 **키 공간을
공유한다.** `contents` 의 조회는 `(platform, platform_key)` 뿐이고(`social-graph.writer.ts:330`)
`subtype` 이 들어가지 않으므로, 정규화된 검색어와 intent 문구가 같은 문자열이면 **한 행으로
합쳐지고 `subtype` 은 먼저 도착한 쪽으로 고정된다.** 행을 합치는 것은 인덱스가 아니라 이 조회다
— 인덱스는 unique 도 아니라 애초에 막지 않는다.

잃는 것은 두 종류의 구분뿐이다. `token_links` 는 양쪽 다 붙으므로 "이 토큰이 이 URL 을 걸었다"
는 사실은 남는다. 실제로 겹치기도 어렵다 — intent 문구는 대개 문장이고 검색어는 밈 이름
한두 단어이며, trend id 는 19자리 숫자라 나머지 둘과 만날 일이 없다.

**관측되면 그때 쓸 카드** — `findContent` 를 `(platform, subtype, platform_key)` 로 바꾸는 것.
`subtype` 이 이미 행에 있어 사본이 늘지 않고 조회축(`idx_contents_platform_subtype`)도 이미 있다.
지금 안 하는 이유는 v5 V-M1 그대로다 — 텔레그램 `shell → channel` 처럼 **`subtype` 이 변하는
종류에서는 같은 대상이 두 행이 된다.** 즉 "안 변하는 종류에만 적용하는 조건부 조회" 가 되는데,
그 분기가 접두사보다 나은지는 실제 충돌을 본 뒤에 판단한다.

**제약으로 기록** — 연산자 쿼리(`from:elon doge`)는 공백이 문법이라 정규화가 의미를 섞는다.
관측 빈도가 낮아 감수한다.

### intent — 확정 (X-11)

**검색과 완전히 같은 방식이다.** 호출 0회 · 나머지 빈 처리.

| 대상 | 값 |
|------|-----|
| `subtype` | `ContentSubtype.INTENT` |
| `platformKey` | `text` 파라미터의 **정규화값 — 검색과 같은 함수**(공백 유지) |
| `text.primary` | **`platformKey` 와 같은 값** (검색과 같은 이유) |
| 그 외 · 호출 | 검색과 동일 |

정규화 예시 — `x.com/intent/post?text=its%20not%20%22to%20the%20moon%22%20any%20more`
→ 둘 다 `'its not "to the moon" any more'`. **문장이라 대소문자 손실이 검색어보다 눈에 띈다** —
그럼에도 소문자를 유지하는 것은 검색과 정규화 함수를 공유하기 위해서다.

**선행 작업** — `classify` 가 지금 intent 에 `sourceKey: null` 을 준다(`x.fetcher.ts:121`).
`text` 를 뽑도록 고쳐야 한다. X-8 과 같은 층이라 함께 간다.

**정규화 규칙을 검색과 공유하는 이유** — 규칙을 하나로 유지하기 위해서다. CA(base58 32~44자)와
URL 을 키에서 빼면 같은 문구 템플릿을 쓴 토큰들이 한 행으로 모여 "같은 봇·같은 런치 서비스"
신호가 나오지만, 그러려면 정규화 함수가 둘로 갈린다. **지금은 규칙 하나가 더 중요하다고 봤다.**

**감수하는 것** — 문구에 CA·URL 이 섞이면 토큰마다 키가 달라 **병합이 사실상 안 일어난다.**
남는 건 "이 토큰이 intent 링크를 걸었다" 는 사실과 문구 원문뿐이다. 템플릿 단위 병합이
필요해지면 백필로 재계산한다(검색과 같은 경로).

`text` 파라미터가 없는 intent(`/intent/post` 만)는 키가 없어 `UNSUPPORTED` 로 끝낸다.

### trending — 확정 (X-12)

`x.com/i/trending/{id}` 는 **82건**으로 4번째로 많은 패턴이다. audit 은 `unknown` 유지로
판정했으나 그 근거(*"재현 가능한 식별자가 아니다"*)가 2026-08-07 실측으로 **반증됐다.**

**실측** — 서로 다른 id 3개를 리더 프록시로 요청하니 각각 다른 특정 스토리가 나왔다.
id 는 무시되지 않고 결정적으로 해석된다. 페이지는 6주 지난 트렌드도 살아 있다.

| id | 반환 제목 |
|---|---|
| `2069336423686427135` | Florida Man's Laptop AI 'Mythos' Draws Ridicule and Debate |
| `2069540336540508215` | JPMorgan Executive Fired for Stealing Knicks Parade Trash Can |
| `2069942106370478376` | Alpha AI CEO Kevin Xu Resets Challenge After 35% Drone ETF Loss |

이 페이지의 정체는 **X Stories** 다 — Grok 이 트렌드를 요약해주는 기능이라
제목과 요약 본문이 있고, 내용은 시간에 따라 갱신된다(화면이 그렇게 명시한다).

**결정 — 시각은 공짜로, 제목은 시도해보고 실패하면 인정한다.**

| 대상 | 값 | 실패 가능성 |
|------|-----|-----------|
| `subtype` | `ContentSubtype.TREND` (**신설**) | — |
| `platformKey` | trend id 그대로 (**접두사 없음** — X-10 의 "남은 결정" 참조) | — |
| 진입 조건 | **`isTrendId` 를 통과한 id 만** `x_trend` 다 (아래) | — |
| `publishedAt` | **snowflake 디코딩** `(id >> 22) + 1288834974657` | 없음(순수 계산) |
| `text.primary` | 스토리 제목 | 리더 프록시 실패 시 빈다 |
| `text.body` | Grok 요약 본문 | 위와 같음 |

**`classify` 가 id 를 먼저 거른다 (2026-08-07 추가).** 구현 중 알게 된 것 —
`fetchTrend` 가 snowflake 가 아닌 id 를 받으면 **외부를 부르기 전에** `null` 을 돌려주는데,
그때 남길 상태가 없다. `NOT_FOUND`·`BLOCKED`·`ERROR`·`SUSPENDED` 는 전부 `attempted: true`
고정이고(갈래 3), `UNSUPPORTED` 는 뜻이 "우리가 안 다루는 종류" 라 맞지 않는다.

그래서 `classify` 가 `isTrendId()` 로 걸러 통과하지 못한 것은 `unknown` 으로 보낸다(R-16).
**디코딩 성공만으로는 부족하다** — audit 픽스처의 `1234567890` 은 숫자이고 디코딩도 되는데
나오는 값이 epoch + 294ms(2010-11-04)다. 그 시각에 X Stories 는 존재하지 않았다.
그래서 술어가 2024-01-01 하한을 함께 본다.

**snowflake 가 핵심이다.** `(id >> 22) + 1288834974657` 로 트렌드 생성 시각이 **외부 호출 0회**로
나온다. 위 3건은 각각 2026-06-23 08:27 · 06-23 21:57 · 06-25 00:33(UTC)이고, 스크린샷의
"최종 업데이트 6월 26일" 과 정합한다(6/23 생성 → 6/26 까지 요약 갱신).

이 값이 있으면 **밈이 토큰 발행보다 앞서는가**를 판정할 수 있다 — `task-012` 가 검색에서
그토록 원했던 `oldestCreatedAt` 축과 같은 값이다. 검색 키는 문자열이라 시각이 없는데
trending 은 공짜로 준다. **그래서 제목을 못 얻어도 껍데기가 아니다**(G-6 충족).

**제목·요약은 `r.jina.ai` 리더 프록시로 시도한다.** Jina AI(독일)의 Reader API 로,
`r.jina.ai/{URL}` 에 요청하면 JS 렌더링까지 해서 마크다운으로 돌려준다. x.com 직접 요청은
**HTTP 402 로 막힌다**(봇 차단). Wayback 스냅샷도 없다.

- **키 없이 분당 20요청** — 복수 출처 확인. 파이프라인이 URL 을 순차 처리하고 trending 은
  전체의 1.5% 라 여유가 크다. 백필로 82건을 몰아도 5분이면 끝난다
- **try/catch 로 감싸고, 실패하면 시각만 남긴다.** fetcher 가 `try/catch` 를 두지 않는다는
  원칙의 **명시적 예외**다 — 이건 대상 쪽 사정이 아니라 **보조 경로의 실패**라, 그 URL 전체를
  실패로 만들 이유가 없다. 타임아웃은 짧게 잡는다(5초 수준)
- **되면 좋고 안 되면 인정한다** — X 가 `/i/` 를 막으면 리더가 정상적으로 실패하고,
  그때부터는 시각만 남는 A 안으로 자연스럽게 내려앉는다

**감수하는 것**

- **우리 URL 이 제3자 서버를 거친다.** 기본적으로 캐시·로깅된다(`x-no-cache` 헤더로 끌 수 있다).
  trending URL 은 공개 URL 이라 민감도가 낮다고 보고 그대로 둔다
- **다른 소셜과 성격이 다른 경로다.** 나머지는 전부 SDK·공식 API 인데 이것만 HTML 리더다.
  82건이 걸려 있고 실패해도 안전하게 내려앉으므로 감수한다
- 내용이 Grok 갱신으로 변한다 — 다른 mutable 필드와 같은 성격이라 관측 시점 스냅샷으로 둔다

**조사했으나 안 되는 경로** — twitterapi.io `/twitter/trends` 와 X 공식 v2
`/2/trends/by/woeid/{woeid}`, Apify 트렌드 액터 9종은 **전부 "지역별 현재 트렌드 목록"** 이라
과거 trend id 를 해석하지 못한다. 공식 v2 는 Pro 티어($5,000/월)이기도 하다.

### grok · 그 밖의 unknown — UNSUPPORTED

`x.com/i/grok?conversation={id}` 는 **1건**이다. id 가 snowflake 라 시각은 뽑히지만
(2026-06-23 15:30) 대화 내용에 외부에서 접근 가능한지가 불투명하고, 1건에 경로를 여는 것은
과하다. 예약 루트(`/home`·`/login` 등)와 `x.com` 루트도 같이 `UNSUPPORTED` 로 끝낸다.

## 10. 구현 순서

```
① 선행 3건 (§7) — enum · ContentDataInput · fetcher outboundUrls
② 확인 항목 해소 (§8) — 실응답 1~2콜
③ XGenerator 골격 — classify 분기 + generateProfile
④ generateTweet — 중첩 전개(X-1·X-7)가 가장 어렵다
⑤ generateCommunity — X-4 의 프로필 1콜 포함
⑥ 라우터 등록 (SocialGeneratorRouter)
⑦ 테스트
⑧ 루브릭 §8 체크리스트 23항목 전수 확인 ← 여기서 끝난다
```

## 11. 테스트

- **fixture 출처**: `social-url-structure-audit/scaffold/x.json` 의 `schemas`/`liveTests`.
  `samples/*.json` 은 구 fetcher 산출이라 쓰지 않는다
- 축 ① 필드 매핑 ② 묶음 구성(단일 · 인용 1단 · 인용 2단 · **리트윗된 인용트윗**)
  ③ `parentRef` 방향 ④ 정지 계정 ⑤ opt-in on/off ⑥ 결측 `null`
- **"리트윗된 인용트윗"** 축이 X-7 을 검증한다 — 최상위 `quoted` 와 `retweeted.quoted` 가
  같은 트윗일 때 묶음이 **3개**여야 한다(4개면 하나가 고아가 되어 Writer 가 던진다)
- Writer 는 `plan()` 에서 **빈 묶음** · **콘텐츠 없는 묶음을 인용 원본으로 지목** ·
  **순환** · **`rootRef` 에서 도달 불가**를 던진다 — **그 예외가 곧 생성기 버그의 신호**라
  테스트에서 그대로 활용한다. 미해결 `rootRef` 와 중복 `ref` 는 전용 검사가 없고
  마지막 도달 검증에 함께 걸린다(메시지가 `도달할 수 없는 묶음`)
- 검증: `npx tsc --noEmit` · `npm run lint` · `npm run test:unit`
- **마지막으로 [루브릭](../../social-generator-rubric.md) §8 체크리스트 23항목을
  처음부터 끝까지 훑는다.** 위 축들은 X 고유의 것이고, 그 16항목은 8종 공통이다 —
  특히 R-8(첫 생성이 곧 최종) · R-9(`outboundUrls` 정규화) · R-10(스프레드 금지)은
  **테스트로도 컴파일로도 안 잡힌다**

## 12. 루브릭 전수 점검 (2026-08-11)

R-1 ~ R-18 을 코드(`x.generator.ts`) 와 대조했다. **X 는 8종 중 가장 늦게 이 절이 붙었지만,
정작 가장 먼저 사고가 났던 소셜이다** — §11 위쪽 `TelegramGenerator × 실물 SocialGraphWriter`
섹션 진입부 주석이 *"X 에서 단위 테스트 23개가 전부 초록인 상태로 힙이 터졌던 것이 이 단계에서
잡혔다"* 고 기록해 뒀다. 그 회귀 테스트가 `backlog.md` B-2 로 어제서야 채워졌다.

### 🔴 발견 1 — 보조 호출 흡수 목록에 `UPSTREAM_CONTRACT_BROKEN` 이 빠져 있었다

커뮤니티 개설자 조회(`communityCreator`, X-4)는 R-15 예외(G-10)에 따라 실패를 흡수하는
보조 호출이다. 그런데 흡수해도 되는 코드 목록(`UNABSORBABLE` — 이름과 반대로 **던질** 코드
목록)에 `UPSTREAM_CONTRACT_BROKEN` 이 없었다. YouTube·Instagram·TikTok 은 각자의 전수
점검에서 이미 같은 판정 문장으로 추가해 뒀는데, **X 만 그 갱신을 안 받았다.**

`UPSTREAM_CONTRACT_BROKEN` 의 정의는 *"200 인데 계약이 깨졌다 — 사람이 고쳐야 한다"* 다.
흡수하면 실제 버그(SDK 응답 파싱 실패·필수 필드 결측 등)가 *"이 개설자만의 사정"* 으로
조용히 묻히고, 베뉴는 개설자 없이 저장된다 — R-8 이 *"가장 위험한 자리"* 로 지목한 바로 그
fan-out 첫 생성 케이스다.

**고쳤다** — `UNABSORBABLE` 에 추가하고, `x.generator.spec.ts` 의 기존
`it.each([CREDIT_EXHAUSTED, CREDENTIAL_REJECTED])` 목록에 `UPSTREAM_CONTRACT_BROKEN` 을
더했다. 고의로 지워서 그 테스트가 실제로 빨개지는 것도 확인했다.

### 통과 확인한 항목

| | 근거 |
|---|---|
| **R-1·R-5** | 인용 사슬이 `seen` Set 으로 순환 방어된다 — `e790b59`. `refAt(index)` 가 인덱스 기반이라 `ref` 중복이 구조적으로 불가능하다. `backlog.md` B-2 의 순환 테스트가 이걸 실물 Writer 로 재확인했다 |
| **R-2** | 커뮤니티 경로는 개설자를 **같은 묶음**의 `venue.creator` 에 담는다(인용이 아니므로 안 가른다). 사슬 분기는 `quotedOrigin()`(실제 인용·리트윗)에서만 일어난다 |
| **R-3·R-4** | `fromTweet` 이 만드는 모든 묶음은 `content` 가 항상 채워진다(사슬의 각 노드가 트윗이므로) — `parentRef` 대상이 콘텐츠 없는 묶음일 수가 없다. 다른 경로들도 항상 하나 이상 채워서 낸다 |
| **R-6** | `parentRef: linked ? refAt(index + 1) : null` — 사슬 뒤쪽(더 먼 인용 원본)을 가리킨다. *"A가 B를 인용"* 방향과 일치 |
| **R-7** | 계정(`profile.id`) · 콘텐츠(`tweet.id` · 검색어/트렌드id) · 베뉴(`community.id ?? fallbackKey`) · 멘션(`mention.id`) 전부 필수 소스에서 옴. 정지 계정(X-3)은 애초에 객체를 안 만든다 |
| **R-8** | `toAccount`·`toTweetContent`·`toVenue` 가 각 응답 타입의 필드를 전부 매핑한다(스팟체크: `XActiveProfile` 12필드, `XCommunity` 8필드 전부 자리가 있다). 발견 1이 이 규칙과 직결된 유일한 구멍이었다 |
| **R-9** | `outboundUrls`·`avatarUrl`·`mediaUrls` 전부 `normalizeAll`/`normalizeOne` 경유. `*Urls?$` 패턴을 벗어나는 URL 필드 없음 |
| **R-10** | 스프레드 없음 — 지표 6종·`data` 3종·베뉴 필드 전부 필드 단위 명시 |
| **R-11** | `ContentSubtype`(TWEET/SEARCH/INTENT/TREND)이 `platformKey` 어디에도 안 들어간다 |
| **R-12** | `creatorId`·`venueId`·`parentContentId` 를 어디서도 채우지 않는다(주석으로 명시) |
| **R-13·R-14** | 전 분기 추적 — `OK`+`graph:null` 조합을 만드는 경로가 없다. `x.generator.spec.ts` 가 `expectGeneratorContract` 로 매 호출 자동 검증한다(G-19) |
| **R-15 (본 호출)** | `fromProfile`·`fromTweet`·`fromCommunity` 의 메인 호출은 try/catch 없이 그대로 위로 던진다 — 어제 고친 "없는 핸들이 `ERROR` 로 남던" 버그가 정확히 이 경로였고, `NOT_FOUND` 분리는 fetcher 층(`isNotFoundBody`)에서 처리해 Generator 는 여전히 안 삼킨다 |
| **R-16** | `classify()` 의 키 불확실 분기가 전부 `route()` 헬퍼를 거쳐 빈 키를 `unknown` 으로 떨어뜨린다. `classify-contract.spec.ts` 가 자동 검사 |
| **R-17** | `normalizeUrl()` 이 `null` 이면 `generate()` 첫 줄에서 던진다 |
| **R-18** | 계정을 만드는 세 경로(`x_profile` 직접 · 트윗 `author` fan-out · 커뮤니티 개설자 fan-out)가 **전부 같은 `toAccount()` 함수를 호출**한다 — 산출이 같은 게 아니라 애초에 같은 코드 경로다. `x.generator.spec.ts` 에 전용 테스트(`R-18 — 프로필 경로와 트윗 경로가 같은 계정을 만든다`)도 있다 |

### 참고 — 결함은 아니지만 눈에 띈 것

`XTweet` 의 `authorId`·`authorUserName`·`authorCreatedAt`·`authorFollowers`(4필드)와
`isQuote`, `XCommunity.creatorId` 가 fetcher 응답엔 있는데 Generator 어디서도 안 읽힌다.
타입 주석은 authorId 4종을 *"조인·집계의 안정된 계약"* 이라고 적어 뒀지만, 실제로는
`author`(프로필 원형 전체) · `retweeted ?? quoted`(사슬 판정) 로 대체돼 전부 무해하게
버려진다. 저장에 영향은 없다 — R-10 이 막는 "스프레드로 조용히 새는 것"과 달리 애초에
읽지도 않는 것이라 mongoose strict 가 버릴 것도 없다. 굳이 고칠 이유는 없어 손대지 않았다.
