# Telegram Generator — 구현 계획

> 착수 결정의 선택지와 그때의 근거 숫자는 [`telegram-kickoff.html`](../../../docs/features/social-generator/telegram-kickoff.html) 에 있다.
> 이 문서는 **확정된 답**만 담는다. 규칙은 [`guides/social-generator-rubric.md`](../../social-generator-rubric.md),
> 횡단 결정은 [`decisions.md`](../decisions.md).
> 최종 갱신: 2026-08-11 — 루브릭 전수 점검 완료(§11)

---

## 0. 범위

`t.me` · `telegram.me` 로 들어오는 URL 을 **Venue 한 종류**로 만든다. 관측 합계 363건.

**이 소셜은 Venue 만 만든다** — Account 도 Content 도 없다. audit 의 4객체가 전부 Venue 변종이고
(portal · channel · shell · guard_group), Telegram 에는 "채널 소유자" 를 알 수 있는 경로가 없다.

---

## 1. 이 소셜만 다른 것 셋

**① URL 로는 종류를 모른다.** `t.me/{name}` 하나가 가드 포털일 수도 빈 채널일 수도 활성 채널일 수도 있고,
**프리뷰를 받아봐야 갈린다**. 8소셜 중 유일하다. 그래서 `classify` 는 전부 `tg_channel` 로 두고
fetch 결과가 종류를 좁힌다(D-012).

**② 우리가 보는 것이 커뮤니티가 아니다.** 실측 107개 중 **79% 가 가드 포털**이다.
포털은 안내소이고 진짜 방은 검증 뒤에 있어 **우리는 못 본다**.
유일한 대조 사례에서 포털 486 구독자 vs 실제 방 1,377 멤버(2.8배).

> audit 의 결론: *"텔레그램 축의 성격이 '어떤 커뮤니티인가' 가 아니라 **'이 팀이 어떻게 셋업했나'** 로 바뀐다."*
> **커뮤니티 규모·나이 기반 필터는 만들 수 없다.**

**③ 조인키가 불변이 아니다.** Telegram 공개 username 은 소유자가 놓으면 타인이 가져간다
(X 핸들과 같고 Reddit 과 반대). 실측 이관 2건 — `seelprotocol`·`flybubbleman` 은
**6월 토큰이 링크했는데 채널 생성일이 7월**이다. §8 의 한계로 기록한다.

---

## 2. 구조

```
Router          정규화 · host 조회 (t.me · telegram.me)
   ↓
TelegramFetcher classify(ParsedUrl) → SocialRoute<TelegramSourceType>   순수·무료
                fetchChannel(name)  → TelegramChannel | null            무료 HTML 1회
   ↓
TelegramGenerator  TelegramChannel → SocialObjectGroup { venue }        묶음 항상 1개
```

**묶음이 항상 1개다.** Content 를 만들지 않으므로(T-4) 사슬 조립·순환 방어·`parentRef` 가
전부 해당되지 않는다. X 에서 가장 어려웠던 부분이 여기서는 없다.

**소스는 무료다.** `GET https://t.me/s/{name}` 1회. 비용 0 · 게이트 없음.
`t.me/{name}` 대신 `t.me/s/` 를 받는 이유는 **같은 1회 요청으로 훨씬 많이 오기 때문**이다
(실측 10.9KB → 24.8KB, 구독자수는 동일). 이미 반영돼 있다.

---

## 3. 결정 로그

| ID | 결정 | 근거 |
|---|---|---|
| **T-1** | `platform_key` = **`username`**. `guardChatId` 는 `data.guard_chat_ids[]` 이력 배열(`string[]`)로 | ① 그 id 는 **우리가 관측한 대상의 것이 아니다** — 가드 뒤 방의 id 라 키로 쓰면 정체성은 못 본 방인데 저장된 숫자는 포털 것이 된다(79%가 그렇게 된다). ② 키로 바꿔 얻는 이득이 **실측 0** — 고유 chatId 85/85 로 합쳐질 행이 없었다. 이력 배열은 **가드 교체·가드 후행 부착** 두 경우에서 행이 갈라지지 않게 한다 |
| **T-2** | `subtype` 고착은 **첫 관측으로 두고 한계로 기록** | `resolveVenue` 는 기존 행을 갱신하지 않는다. 갱신을 넣는 것은 **Writer 계약 변경**이라 8소셜 회귀 대상이고, *"어느 필드까지 갱신하나"* 라는 더 큰 질문이 열린다. `guard_chat_ids` 가 이력이라 **`subtype='channel'` 인데 배열이 비어 있지 않은 행**이 모순으로 드러나 사후 배치로 정정 가능하다 |
| **subtype 정책** | **`shell` 을 뺀다.** `portal` / `channel` 둘만 | 판정 기준은 *"다른 저장 필드에서 못 끌어내는가"*. `shell` = `contentMessageCount === 0` 이고 임계는 소비처가 정하는 게 맞다. `portal` 은 남긴다 — **그 행의 숫자를 어떻게 읽어야 하는지를 바꾸기 때문**이다(members 11 이 안내소 것인지 커뮤니티 것인지). 얻는 것: **가장 흔한 상태 변화(빈 채널이 참 · 12%)가 발생 자체를 못 하게 된다.** 같은 축의 선례가 G-13 |
| **T-3** | `t.me/+{hash}` 는 **`UNSUPPORTED`** — 행을 만들지 않는다 | audit 이 `inviteHash` 를 *"관리자가 폐기·재발급할 수 있어 방 정체성이 아니다 → 조인 키로 쓰지 않는다"* 로 판정했다. 그 값을 키로 승격하지 않는다. 3건(0.8%)이라 잃는 것이 작고 `tokens.social_urls[]` 에 원문은 남는다 |
| **T-4** | 메시지로 **Content 를 만들지 않는다** | audit 구조 결정: *"Venue 확장만 채택했으므로 Content 레코드는 만들지 않고 링크·티커 추출용으로만 파싱한다"*. 근거는 활성 채널이 8% 뿐이라는 것. 만들면 fetcher 반환에 **메시지 배열이라는 새 축**이 생기고, 프리뷰 20건 상한 때문에 재관측마다 다른 20건이 와서 중복·유실이 함께 생긴다 |
| **T-5** | 채널 아이콘 → **`data.iconUrl`** | fetcher 가 `og:image` 를 이미 담고 자리도 이미 있다(image-asset-fields T-010). G-16 을 그대로 적용하는 건이고 해상도 변형이 없어 폴백 규칙도 필요 없다. **선행 작업 0건** |
| **T-6** | 자리 없는 **10필드를 `VenueDataInput` 에 전부 추가** | G-7(*"data 키는 소셜 원래 필드명 그대로"*)을 따르면 이름 논쟁이 없다. 이 소셜의 가치가 거의 전부 그 필드들에 있다 — 빈껍데기 판별(`contentMessageCount`) · 자기 선언(`externalLinks`) · 결측 신호(`previewAvailable`) |
| **T-7** | `subscriberCount` → **`data`** (metrics 아님) | G-17 이 세운 기준은 *"audit 등급이 자리를 정한다"* 이고 audit 판정이 **context** 다 — *"절대값은 포털/채널에 따라 의미가 다르다. 규모 필터로 쓰면 안 된다"*. 실측 포털 중앙 11명 vs 활성 채널 83명으로 **같은 컬럼에 뜻이 다른 두 수가 섞인다.** 결과: **Telegram Venue 는 `metrics` 를 통째로 비운다** |
| **T-8** | `nameMatch` · `tokenName` 인자 **제거** | D-011(derivable 제외). audit decision 은 `keep` 이지만 유용성 칸이 스스로 *"원본 두 값을 저장하면 질의 시점에 재계산 가능"* 이라 같은 말이다. `title` 이 저장되므로 원천은 보존된다 |
| **T-9** | `TELEGRAM_VALUE_NATURE` **제거** | 읽는 곳이 하나도 없다. YouTube D-10 이 같은 질문에 *"쓰는 곳이 없는 선언을 8종으로 늘릴 이유가 없다"* 로 답했고 그 결론을 소급한다. 변동성은 `sources/telegram.html` 에 문서로 남아 있다 |
| **T-10** | `guard_chat_ids` 는 `data` 밑. **인덱스는 지금 걸지 않는다** | top-level 로 올리면 X 커뮤니티·Reddit 서브레딧에 없는 개념이라 **6소셜이 영원히 비우는 컬럼**이 된다. 인덱스를 미루면 `data` 의 성격(*"인덱스 대상이 아닌 부가값"*)이 유지돼 `VenueDataInput.iconUrl` 주석과 어긋나지 않는다. 이 배열을 쓰는 조회는 **상시 질의가 아니라 점검성 배치** 하나뿐이고, 실측에서 그런 중복이 0건이라 당장 돌릴 이유도 없다 |

### 질문 없이 둔 가정

- `t.me/s/{name}` 프리뷰를 쓰는 것 · 판정 우선순위(가드 > 메시지 0 > 그 외) · HTML 파싱 로직은 **한 줄도 바꾸지 않는다**. 이미 커밋 `998199df` 로 반영돼 있다.
- 유료 게이트가 없다 — 무료 소스라 `PAID_SOURCE_TYPES` 에 넣지 않는다.

---

## 4. URL 종류별 산출

| URL 형태 | 관측 | sourceType | sourceKey | 호출 | 묶음 | 산출 | status |
|---|---|---|---|---|---|---|---|
| `t.me/{name}` | 359 | `tg_channel` | name 소문자 | 1회 | 1 | Venue 1 | `OK` / `NOT_FOUND` |
| `t.me/{name}/{msgId}` | 1 | `tg_channel` | 〃 (msgId 버림) | 1회 | 1 | 〃 | 〃 |
| `t.me/s/{name}` | 0 | `tg_channel` | segments[1] 소문자 | 1회 | 1 | 〃 | 〃 |
| `t.me/+{hash}` | 3 | `tg_guard_group` | hash **원형** | 0회 | 0 | 없음 | `UNSUPPORTED` |
| `t.me/joinchat/{hash}` | 0 | `tg_guard_group` | segments[1] **원형** | 0회 | 0 | 없음 | `UNSUPPORTED` |
| 빈 경로 · `t.me/s/` | — | `unknown` | `null` | 0회 | 0 | 없음 | `UNSUPPORTED` |

**대소문자 규칙이 갈린다** — 채널 username 은 소문자화(대소문자 무관 + dedup), 초대 해시는 **원형 유지**.

**`NOT_FOUND` 로 가는 경우** — `og:title` 이 없거나 `"Telegram: Contact @x"` 인 페이지. 비공개·개인계정·미존재가 여기로 수렴한다.
실측 107건에서는 **0건**이었다.

**catch-all 이 없다.** `t.me/{name}` 자체가 정상 채널 형태라 R-16 을 이미 지킨다.

---

## 5. 필드 매핑 — `VenueInput` ← `TelegramChannel`

```ts
venues 한 행
  platform              telegram
  subtype               portal | channel              // guard 유무. shell 제거(subtype 정책)
  platform_key          username                      // URL 의 이름. 숫자 id 아님
  name                  title
  description           description
  venue_created_at      channelCreatedAt              // string → Date
  creator_id            (없음)
  metrics               (비운다)                       // T-7
  data
    guard_chat_ids[]    string[]                      // T-1. 인덱스 없음(T-10)
    guardBot            string
    guardSetupLagSec    number
    subscriberCount     number                        // T-7 — metrics 아님
    previewAvailable    boolean
    photoCount · videoCount · linkCount   number
    contentMessageCount number
    deletedMessageCount number
    firstMessageAt · lastMessageAt        Date
    externalLinks       string[]
    iconUrl             string                        // T-5
```

### 버리는 것

| 원본 | 사유 |
|---|---|
| `nameMatch` | derivable — D-011 (T-8) |
| `accountCreatedDate` | 무료·공식·유료 **어떤 경로로도 불가**. Telegram 이 서버 응답 어디에도 노출하지 않는다 |
| `verifiedBadge` | 무료 HTML 경로에 없다. MTProto 면 얻지만 전화번호·계정정지 리스크 |
| `authorSignature` | audit skip — 실측 표본에서 관측되지 않았다 |
| `gateType` | audit remove — `subtype` 으로 이미 표현된다 |
| 메시지 6종 | T-4 — Content 를 만들지 않는다 |

---

## 6. 실패 표현

| 상황 | 표현 |
|---|---|
| 404 · 응답 없음 · `"Telegram: Contact"` | `null` → `NOT_FOUND` |
| 네트워크 · 5xx | **throw** (공통 규칙) → `ERROR` |
| `tg_guard_group` · `unknown` | 메서드 도달 안 함 → `UNSUPPORTED` |
| 조인키 부재 | **발생하지 않는다** — `username` 은 URL 에서 온 값이라 응답을 안 봐도 항상 있다 |

**새 `FetchStatus` 가 필요 없다.** 구 fetcher 의 `blocked` 는 `tg_guard_group` 전용이었는데
T-3 이 그 경로를 `UNSUPPORTED` 로 확정하면서 어휘가 하나 줄었다.

> ⚠️ `blocked`(*"실재하지만 원천적으로 못 연다"*)와 `UNSUPPORTED`(*"우리가 지원 안 함"*)는
> 다른 사실이고, 전자는 **재시도가 영원히 무의미하다**는 정보다. 3건(0.8%)이라 이번엔 손실을 받아들인다.
> `outcome` 에 `blocked` 를 살릴지는 T-010(어댑터 정리)에서 다시 본다.

---

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

프로세스 §5 체크포인트 — **한 곳만 고치면 조용히 틀린다.** 입력 계약에 필드가 없으면 mongoose strict 가 값을 버린다.

- [x] **`VenueDataInput` 에 13키 추가** — `guardChatId` · `guardBot` · `guardSetupLagSec` ·
      `subscriberCount` · `previewAvailable` · `photoCount` · `videoCount` · `linkCount` ·
      `contentMessageCount` · `deletedMessageCount` · `firstMessageAt` · `lastMessageAt` · `externalLinks`
      → **세 곳 전부**: `social-graph.types.ts` 입력 계약 · `venue.model.ts` `@prop` · 그 모델의 `toData()`
- [x] **`VenueSubtype` 에서 `SHELL` 제거** (`social-graph.consts.ts`)
- [x] **fetcher 시각 3종을 `Date` 로** — `channelCreatedAt` · `firstMessageAt` · `lastMessageAt` (D-008)
- [x] **`nameMatch` · `tokenName` 인자 제거** (T-8) — `FetchOptions` 의존도 함께 사라졌다
- [x] **`TELEGRAM_VALUE_NATURE` 제거** (T-9)
- [x] **`tg_shell` 을 `TelegramSourceType` 에서 제거** — 생성기가 안 쓰는 값을 fetcher 가 만들 이유가 없다.
      가드 판정만 남아 `guard ? 'tg_portal' : 'tg_channel'` 두 갈래다
- [x] **`SocialGeneratorRouter` 에 등록** — 배열과 `inject` **둘 다**.
      함께 `SocialFetcherModule` 의 `exports` 에 `TelegramFetcher` 를 추가했다 —
      빠뜨리면 컴파일은 통과하고 **부팅에서** `UnknownDependencies` 로 터진다.
      `wiring-contract.spec.ts` 가 이것을 잡아냈다(G-19)

> **입력이 `guardChatId`(단수)인 이유.** 모델은 `guard_chat_ids: string[]` 이지만 입력은 스칼라다 —
> fetcher 가 관측 1회에 값 하나를 주기 때문이다. `{ value, observedAt }` 짝으로 두려면
> `VenueInput` 에 `observedAt` 을 추가해야 하는데, 그 필드가 지금 없고 넣으면 X·Reddit 생성기가 함께 바뀐다.
> **지금은 원소가 항상 1개 이하다** — `resolveVenue` 가 기존 행을 갱신하지 않아 두 번째 관측이 반영될 경로가 없다(T-2).
> 배열 모양으로 둔 것은 나중에 스칼라→배열 마이그레이션을 피하기 위해서다.

검증: `npm run test:unit` **682 passed** · `tsc --noEmit` 0 · `lint` 0 errors (2026-08-10)

---

## 8. 알려진 한계 (design §9 로 올린다)

**🔴 `platform_key` 가 불변이 아니다.** Telegram username 은 놓으면 타인이 가져간다.
같은 키가 시점에 따라 **다른 채널**일 수 있고, 유일성 제약은 지켜지므로 **DB 가 못 막고 조용히 틀린다.**
탐지 수단은 `venue_created_at` 하나다 — *"토큰이 링크한 시점보다 채널 생성일이 나중"* 이면 그 사이에 이름이 넘어간 것이다.
실측 2건이 그렇게 발견됐다.

**🔴 관측 지표가 커뮤니티 지표가 아니다.** 79% 가 포털이고 그 `subscriberCount` 는 안내소 구독자다.
`subtype` 과 함께 읽지 않으면 규모 비교가 무의미하다. T-7 이 `data` 로 내린 이유다.

**🟡 `subtype` 이 첫 관측으로 고착된다** (T-2). 가드가 나중에 붙어도 `portal` 로 안 바뀐다.
`guard_chat_ids` 가 비어 있지 않은데 `subtype='channel'` 인 행이 모순으로 남는다.

**🟡 가드봇 사칭 판정이 대부분 불가능하다.** 실측 85건 중 `safeguard` 가 **65건(76%)** 인데
공식 봇 목록이 없다. 사칭 확정은 `coIIab_Iands_bot`(대문자 I 위장) 5건 + `Collabslands_bot` 13건뿐이다.

**🟡 `guardSetupLagSec` 의 해석이 무너졌다.** audit 이 본 *"2~161초 = 자동화 스크립트"* 는 표본이 작아서였다.
실측 중앙 2,930초 · 최대 **35,604,366초(412일)** — 묵힌 채널에 나중에 가드를 건 경우가 섞인다.
값은 담되 **"셋업 자동화 정도" 로 읽지 않는다.**

**🔴 프리뷰가 반나절 만에 사라진다.** 2026-08-10 종단 테스트에서 실측했다 — 오전 probe 에서
`portal · subs 8 · guardChatId -1004464129918` 로 정상 관측된 `acebanana` 가 같은 날 저녁
`t.me/s/acebanana → 302 → t.me/acebanana → og:title "Telegram: Contact @acebanana"` 로 바뀌었다.
공개 프리뷰가 없어진 것이다.

> **"프리뷰 실패 0%"(§9)는 한 시점 스냅샷이다.** 시간축의 이탈률은 그 숫자에 안 잡힌다.
> `resolveVenue` 가 기존 행을 갱신하지 않으므로(T-2), 오전에 저장했다면 그 행은
> **다음 갱신까지 "portal · subs 8"** 로 남는다. 갱신은 별도 로직 소관이라(H-008) 생성기가
> 할 일은 아니지만, **그 로직이 "채널이 사라졌다" 를 판정할 수 있는 재료를 남기는 것**은
> 여기 몫이다. T-2 가 `subtype` 만의 문제가 아니었다는 뜻이다.

**🔴 프리뷰 불가와 레이트리밋이 같은 모양으로 온다.** Telegram 은 프리뷰가 없을 때 429 가 아니라
**302 리다이렉트 + Contact 페이지(HTTP 200)** 를 준다. 우리 파서는 그것을 `og:title` 로 걸러
`null` → `NOT_FOUND` 로 만든다. **레이트리밋이 걸려도 같은 모양이면 구분이 안 된다.**

> 2026-08-10 에는 같은 시점에 `Beep_pump` 가 정상 응답해 레이트리밋이 아님을 확인했다.
> 하지만 **대량 수집에서는 그 대조를 매번 할 수 없다.** 이 소셜만의 문제다 — 다른 소셜은
> 429·403 으로 와서 transport 가 `RATE_LIMITED` 로 분류한다.
> 관측되면 쓸 카드: 연속 `NOT_FOUND` 비율이 임계를 넘으면 배치를 멈추는 서킷.

**🟡 그룹은 여전히 못 본다.** `t.me/s/` 는 broadcast 채널 전용이라 그룹은 빈손이다.

**🟡 `channelCreatedAt` 결측 13%** — 프리뷰가 20건 상한이라 메시지가 많은 채널은 #1 이 안 보인다.
`t.me/s/{name}?before={id}` 로 페이지네이션하면 얻지만 요청이 1회 늘어난다.

---

## 9. 실측 기록 (2026-08-10 · 비용 0)

`sol-alpha-finder-tracker/report.html`(2026-07-25 수집)에서 **고유 채널 107개**를 뽑아 우리 fetcher 로 전량 재관측했다.
착수 근거였던 audit 분포(표본 69개 · 2026-07-31)와 크게 달랐다.

| 항목 | audit (69) | 실측 (107) |
|---|---|---|
| 가드 포털 | 52% | **79%** (85건) |
| 빈껍데기 | 30% | 12% (13건) |
| 활성 채널 | 6% | 8% (9건) |
| 프리뷰 실패 | 7% | **0%** |
| `guardChatId` 고유성 | 36/36 | **85/85** |
| `channelCreatedAt` 확보 | 91% | 87% (93/107) |

**`guardChatId` 가 실재하는 방의 id 라는 간접 증거.** 직접 확인은 세 경로가 모두 막혀 있다
(`t.me/c/{id}` 는 비멤버에게 안 렌더 · Bot API `getChat` 은 봇이 이미 멤버여야 함 · MTProto 는 전화번호 세션).
Telegram chat id 는 대체로 순차 발급되므로 **값이 클수록 최근 방**이어야 하는데,
85건에서 **id ↔ 포털 생성시각 상관계수 r = 0.918** 이 나왔다(임의 숫자면 0 근처여야 한다).
접두 `-100` 준수 85/85, 내부 id 자릿수 전부 10자리로 균일했다.

> 다만 **"검증 통과 후 실제로 입장하는 방"** 인지는 확인 불가다 — id 가 진짜여도 봇이 다른 방을 가리킬 수 있다.
> 딥링크 페이로드는 **봇이 임의로 정하는 문자열**(최대 64자)이라 Telegram 이 의미를 강제하지 않는다.

---

## 10. 구현 순서

1. §7 선행 작업 (스키마 · 타입 · fetcher 정리)
2. `telegram/telegram.generator.ts` — `TelegramChannel` → `SocialObjectGroup { venue }`
3. 단위 테스트 — portal / channel / `NOT_FOUND` / `UNSUPPORTED` 4갈래
4. 루브릭 R-1 ~ R-18 전수 + **적대적 입력**(빈 문자열 · 가드 링크만 있고 메시지 0 · 카운터 축약값)
5. 실물 `SocialGraphWriter` 통과 확인 — R-1·R-3·R-4·R-5 는 Writer 가 런타임에 던진다

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

R-1 ~ R-18 을 코드(`telegram.generator.ts` · `telegram.fetcher.ts`)와 대조했다. **위반 1건 ·
개선 1건**이 나왔고 전부 닫았다. X·GitHub 와 같은 날 같은 방식으로 점검했다.

### 🔴 발견 1 — `t.me/+` 가 R-16 을 어겼다 (`classify`)

초대 링크 분기가 `first.startsWith('+')` 만 보고 `first.slice(1)` 을 키로 썼다. **해시가
없는 `t.me/+` 하나만 와도** `sourceKey: ''` 가 나가는데 `sourceType` 은 `tg_guard_group`
그대로였다 — 종류는 있는데 키가 없는, 판별 유니온이 거짓말하는 값이다.

더 나쁜 것은 **고치는 방법을 잘못 잡으면 새 버그가 생긴다는 점**이었다 — 단순히
`first.length > 1` 조건만 추가하면 `+` 하나가 아래 채널 분기로 흘러 `tg_channel` +
`platformKey: '+'` 로 **잘못 분류**된다. 그래서 빈 해시는 명시적으로 `unknown` 을 반환하도록
고쳤다.

**지금은 무해했다** — `TelegramGenerator` 가 `tg_guard_group` 의 `sourceKey` 를 애초에 안
읽는다(`NOT_HANDLED` 로 끝난다, T-3). 그런데 R-16 이 존재하는 이유가 정확히 이거다 —
*"나중에 읽는 코드가 붙으면 빈 키로 조용히 틀린다."* `classify-contract.spec.ts` 의
`DEGENERATE_PATHS` 에도 `/+` 가 없었다 — Reddit 점검 때 남긴 주석 그대로 *"한 소셜의
사각지대는 목록의 사각지대다"* 였다. 목록에 추가해 8소셜 전부가 이 모양을 공유하면 다시
안 걸린다.

**고의로 되돌려서 확인** — 가드를 지우자 `classify-contract.spec.ts` 의 `tg — degenerate URL
전수` 가 정확히 빨개졌다.

### 🟡 발견 2 — `iconUrl` 이 정규화를 안 거쳤다 (R-9)

X·YouTube·TikTok·Instagram 의 `avatarUrl` 은 전부 `normalizeOne()` 을 거치는데, Telegram
의 `iconUrl` 만 `channel.iconUrl ?? undefined` 로 원본 `og:image` 문자열을 그대로 저장하고
있었다. `expectGeneratorContract` 의 자동 검사(`/Urls?$/` 패턴)가 걸려야 할 자리인데,
기존 테스트 fixture 값이 **우연히 이미 정규화된 형태**(쿼리·트레일링슬래시 없음)라 통과하고
있었다 — 거짓양성이었다.

**고쳤다** — `normalizeOne()` 을 신설해 적용했다. 회귀 테스트는 트레일링 슬래시가 있는
입력(`normalizeUrl()` 이 실제로 값을 바꾸는 경우)으로 만들어 *"우연히 통과"* 를 재발시키지
않게 했다. 고의로 되돌려서 그 테스트가 빨개지는 것도 확인했다.

### 참고 — 공용 타입 주석이 실제 결정과 반대였다

`VenueInput.platformKey` 의 문서 주석(`social-graph.types.ts`)이 *"tg 는 `groupId`"* 라고
적혀 있었다 — 실제로는 **T-1 이 `username` 을 명시적으로 선택**했고 이유까지 자세히 남겨
뒀다(가드봇이 주는 `-100…` 은 우리가 관측한 대상이 아니라는 것). 루브릭 위반은 아니지만
B-1 과 같은 종류의 문서 오류라 같이 고쳤다.

### 통과 확인한 항목

| | 근거 |
|---|---|
| **R-1~R-6 (그래프 형태)** | 묶음이 항상 1개다(Venue 하나뿐, 사슬·순환·`parentRef` 개념 자체가 없다) — 전부 자명하게 통과 |
| **R-7** | `platformKey = username` 이 `route()` 를 거친 비어있지 않은 값(발견 1로 더 확실해짐). 못 만들면(`fetchChannel` 이 `null`) 객체를 안 만들고 `NOT_FOUND` |
| **R-8** | `toVenue()` 가 `TelegramChannel` 의 필드를 전부 매핑한다 — 스팟체크로 인터페이스 15필드 대조, 빠진 것 없음 |
| **R-9** | 발견 2로 닫힘. `externalLinks`(배열)와 `iconUrl`(단일) 둘 다 이제 정규화를 거친다 |
| **R-10** | 스프레드 없음 — `data` 15필드 전부 필드 단위 명시 |
| **R-11** | `subtype`(PORTAL/CHANNEL)이 `platformKey` 에 안 들어간다. 재관측 시 고착되는 것은 별개 문제로 이미 §8 한계에 있다(R-8/갱신 정책 영역이지 이 규칙 위반이 아니다) |
| **R-12** | `creatorId`·`venueId`·`parentContentId` 를 어디서도 채우지 않는다 |
| **R-13·R-14** | `OK`+`graph:null` 조합을 만드는 경로가 없다 — `fromChannel` 은 `ok`/`attempted(NOT_FOUND)` 둘뿐. `expectGeneratorContract` 가 매 호출 자동 검증 |
| **R-15** | 보조 호출이 없다(가장 단순한 소셜) — G-10 예외의 판정 자체가 필요 없다. 본 호출(`fetchChannel`)은 try/catch 없이 그대로 던진다 — 예전엔 잡아서 `null` 을 돌려줬는데 그러면 5xx 가 `NOT_FOUND` 로 뭉개진다는 경고 주석이 코드에 남아 있다 |
| **R-16** | 발견 1로 닫힘. 나머지 분기(`joinchat`·`s`·기본 채널명)는 전부 빈 값이면 `unknown` |
| **R-17** | `normalizeUrl()` 이 `null` 이면 `generate()` 첫 줄에서 던진다 |
| **R-18** | **해당 없음** — Telegram 은 계정을 만드는 경로가 없다(소유자를 알 방법 자체가 없음, §0). 같은 객체로 수렴하는 두 번째 경로가 존재하지 않아 이 규칙이 적용될 여지가 없다 |
