# 소셜 기록 파이프라인 — 통합 검증 수행 문서

> **이 문서가 정본이다.** 케이스 목록(`cases/case-matrix.json`)과 판정 기준(`rubric.md`)은 여기서
> 파생된 자료이고, 규칙이 바뀌면 이 문서를 먼저 고친다.
>
> 케이스를 훑을 때는 [`cases/case-matrix.html`](../../docs/features/recording-integration-verification/cases/case-matrix.html) 을 연다 — 같은 내용을
> 플랫폼·그룹·과금 여부로 걸러 볼 수 있다.
>
> 기준 코드(2026-08-13): `social-record.processor.ts` · `url-dedupe.gate.ts` ·
> `social-graph.writer.ts` · 생성기 8종
> 원천 데이터: `social-link-stats — 2026-07-25` (`report.html`, 상류 트래커 산출물)

---

## §1 목적과 보증 범위

### 이 실행이 답하는 질문

> **"실서비스에 실제로 들어오는 URL 종류와 중복 상황에서, 이 파이프라인이 DB 6개 컬렉션에 정확히 무엇을 남기는가."**

단위 테스트는 클래스 하나의 판정이 옳은지를 본다. 통합 테스트(`test/integration/`)는 배선이
이어져 있는지를 본다. 그 둘 모두 **외부 응답을 가짜로 준다.** 그래서 아무도 답하지 못하는
질문이 하나 남는다 — 진짜 트위터 응답, 진짜 죽은 사이트, 진짜 리다이렉트가 들어왔을 때
우리가 만드는 행이 맞는 모양인가. 이 실행이 그 질문을 담당한다.

### 통과하면 보증되는 것

- 실측 URL 종류 **23종**이 각자 기대한 `FetchStatus` 와 객체 행을 만든다.
- 같은 URL 을 여러 토큰이 공유할 때 **유료 외부 호출이 한 번만** 나간다.
- 정규화가 붙여 준 중복(원문은 다른데 같은 대상)도 재사용된다.
- 실패한 URL 이 `tokens.social_urls[]` 에 남아, 다음 토큰이 같은 실패를 다시 돈 주고 확인하지 않는다.
- 인용 사슬의 `link_depth` 가 0·1·2 세 값으로 실제로 갈린다.

### 통과해도 여전히 모르는 것

- **동시성의 드문 경합.** 이 실행은 락 케이스를 두 갈래(E-03)만 만든다. 워커 여러 대가
  붙은 실환경의 경합은 재현하지 않는다.
- **시간이 오래 걸려야 드러나는 것.** `metric_series` 의 200포인트 상한은 관측 200회가 쌓여야
  발동하는데, 이 실행은 2회까지만 만든다.
- **상류 이벤트 경로.** 진입점을 `SocialRecordProcessor` 직접 호출로 통일했으므로 RabbitMQ
  배달과 AF 투자자 임계값 판정은 여기서 안 밟힌다 — 그쪽은 `social-recording.integration.spec.ts` 가 본다.
- **재수집 배치.** `POST /reconcile` 과 `POST /retry` 는 별개 진입점이라 케이스가 다시 배가된다.
  §9 에 다음 라운드로 남겼다.

---

## §2 케이스 인벤토리

### 원천과 각자가 답하는 것

케이스 목록을 손으로 적지 않았다. 손으로 적으면 흔한 종류를 빠뜨려도 아무도 모르고, 원천이
갱신됐을 때 무엇이 새로 생겼는지 대조할 방법이 없다. 그래서 `scripts/extract-cases.ts` 가
원천 넷을 읽어 케이스를 **도출**한다.

| 원천 | 답하는 것 | 이 원천이 없으면 |
|---|---|---|
| `report.html` 의 `coverage` | 실서비스에 어떤 sourceType 이 **얼마나** 있나 | 흔한 종류를 빠뜨린다 |
| `report.html` 의 `dupGroups` | 어느 URL 을 **어느 토큰들이** 공유하나 | 중복 케이스를 합성해야 한다 |
| `docs/features/social-url-structure-audit/samples/*.json` | 한 종류 안의 **URL 구조 변형** | `twitter.com/…` 같은 변형을 놓친다 |
| `test/unit/social-fetcher/website-kinds-cases.json` | website 6,264건이 **어떤 kind 로** 갈리나 | website 를 한 덩어리로 본다 |

### 원천의 실측치

`report.html` 은 2026-07-25 기준이고, 대상 토큰은 **25,959개**다(가장 흔한 `x_tweet` 이 19,282건
74.28% 라 역산한 값이다 — 리포트가 총수를 따로 싣지 않는다).

| 항목 | 값 | 이 숫자가 뜻하는 것 |
|---|---|---|
| sourceType 종류 | **23** | 커버해야 할 URL 종류의 하한이다 |
| 중복 묶음 | **5,478** | 한 URL 을 둘 이상의 토큰이 공유한 사례 수 |
| 그중 raw URL 이 서로 다른 묶음 | **815** | 정규화가 붙여 준 것 — 원문 비교로는 안 잡힌다 |
| 한 URL 을 공유하는 최대 토큰 수 | **310** | 링크 복사가 폭발하면 여기서 터진다 |
| URL 이 실린 상류 필드 | `twitter` 3,119 · `website` 2,253 · `telegram` 106 | 필드가 셋뿐이라 종류는 URL 이 결정한다 |

### 한 행이 케이스가 되기까지

```mermaid
flowchart TD
    A["report.html 의 sourceType 1종"] --> B{"audit samples 에<br/>이 종류의 template 이 있나"}
    B -->|있다| C["template 상위 2개를 뽑는다<br/>구조 변형이 케이스가 된다"]
    B -->|없다| D{"dupGroups 에<br/>이 종류가 있나"}
    D -->|있다| E["묶음의 첫 멤버를 뽑는다"]
    D -->|없다| F["unmatched 로 보고<br/>케이스를 만들지 않는다"]
    C --> G{"실측 tokenAddress 가<br/>URL 에 붙어 있나"}
    E --> G
    G -->|붙어 있다| H["A 그룹 케이스로 채택"]
    G -->|없다| F
    H --> I{"website 인가"}
    I -->|그렇다| J["kind 5종으로 다시 쪼갠다<br/>AW 그룹"]
    I -->|아니다| K["확정"]
```

**`unmatched` 로 떨어진 종류는 케이스가 되지 않고 보고된다.** 조용히 합성 주소를 붙이면 그
케이스만 현실과 다른 것을 검증하게 되는데, 표에서는 그 사실이 안 보이기 때문이다. 현재
실행에서 `unmatched` 는 **0건**이다.

### 도출된 케이스 62건

| 그룹 | 건수 | 무엇을 겨누나 | 도출 방식 |
|---|---:|---|---|
| **A** URL 종류 | 37 | sourceType 22종 × 구조 변형, website kind 5종 | 도출 |
| **B** status 어휘 | 6 | `not_found`·`blocked`·`unsupported`·`exhausted`·`skipped_paid`·`pending` | 수기 |
| **C** 중복·재사용 | 9 | 같은 raw · 정규화가 합침 · **못 합침** · 대규모 fan-in · Telegram 예외 · 앵커 없음 | 도출 |
| **D** 그래프 모양 | 5 | 인용 사슬 · fan-out 계정 공유 · 시계열 · venue+creator · 이미지 | 수기 |
| **E** 실행 경로 | 5 | 멱등 · pending 회수 · URL 락 · 백오프 · TTL 만료 | 수기 |

`website` 는 A 그룹에서 빠져 있다. AW 그룹이 kind 5종으로 쪼개 이미 담당하고, 둘 다 두면
**같은 URL·같은 토큰의 케이스가 두 개**가 되기 때문이다(첫 생성에서 실제로 A-03 과 AW-01 이
둘 다 `axiom.trade/pulse` 였다).

### 케이스를 만들면서 이미 나온 것 하나

케이스 추출기는 `toCanonical()` 을 **프로덕션 코드에서 그대로 import 한다.** 규칙을 복제하면
두 벌이 되고, 갈린 순간 케이스가 조용히 틀린 것을 겨누기 때문이다. 그 덕분에 실행 전에
숫자 하나가 나왔다.

> **상류가 «같은 대상» 으로 묶은 815묶음 중 우리 정규화기가 합치는 것은 101개뿐이고,
> 714개(87.6%)는 끝까지 서로 다른 문자열로 남는다.**

상류는 `sourceKey`(트윗 id · 영상 id)로 묶지만 중복 게이트는 **canonical 문자열 완전 일치**로
조회한다(`findBySocialUrls`). 두 판정이 다른 것 자체는 설계대로다 — `TRACKING_PARAMS` 가
`t`·`s` 를 일부러 빼 둔 이유가 주석에 적혀 있다. 다만 **그 대가가 얼마인지는 지금까지 아무도
재지 않았다.** C-02 와 C-02b 가 그 둘을 각각 담당한다.

B·D·E 를 손으로 적은 이유는 그것들이 **데이터가 아니라 상황**이기 때문이다. "락을 못 잡은
토큰" 은 `report.html` 어디에도 없고, "14일 지난 앵커" 는 실행 중에 만들어야 한다. 케이스
JSON 의 `origin` 필드가 `derived` 와 `authored` 를 가른다 — 원천이 갱신돼도 수기 케이스는
자동으로 늘지 않으므로, 그 표시가 있어야 나중에 누가 손으로 늘려야 할지 안다.

### ⚠️ 원천의 분류는 현재 라우터의 분류가 아니다

`report.html` 의 `sourceType` 은 **2026-07-25 시점 상류 트래커의 판정**이다. 이 레포의
`SocialFetcherRouter` 는 그 뒤로 여러 번 바뀌었다. 그래서 이 스크립트는 URL 을 재분류하지 않고
원천의 판정을 그대로 실어 보낸다. 실행 시점에 현재 라우터가 그 URL 을 무엇으로 부르는지는
`verify.ts` 가 대조하며, **다른 것 자체가 발견이다.**

가장 두드러진 자리가 `unknown` 516건이다. 상류는 못 갈랐지만 실제로는 대부분 `x.com/search`
이고, 이 레포는 X fetcher 가 가져간다. 그래서 케이스 A-14·A-15 는 원천 분류가 `unknown` 인데
플랫폼은 `x` 로 잡혀 있다 — 비용 추산을 맞추기 위해 그 자리만 호스트로 다시 봤다.

---

## §3 샘플 토큰과 픽스처

### 토큰의 URL 집합을 어떻게 정했나

확정된 방침이 "**report.html 실측 조합을 그대로**" 다. 그래서 URL 은 실측 `(url, tokenAddress)`
쌍이 있는 것만 채택했다.

다만 한 가지 한계가 있고, 이것을 알고 읽어야 한다. **`report.html` 은 토큰의 전체 소셜 필드를
담지 않는다.** 중복 묶음의 멤버십만 담으므로 어떤 토큰이 원래 URL 을 몇 개 걸었는지는 복원할
방법이 없다. 그래서 한 주소의 URL 집합을 **«실측에서 그 주소에 대해 관측된 URL 들의 합집합»**
으로 정했다. 진짜 조합의 부분집합이며, 없는 URL 을 지어내지 않는다.

### 명부

- **샘플 토큰 60개** (`cases/sample-tokens.json`)
- URL 1개짜리 47개 · 2개짜리 8개 · 3개짜리 1개 · 4개짜리 4개

토큰이 60개로 늘어난 것은 의도한 결과가 아니라 위 방침의 귀결이다. 케이스마다 실측 주소가
따로 붙어 있어서, 하나의 토큰에 여러 케이스를 몰아 주려면 실측에 없는 조합을 만들어야 한다.
**토큰 수를 줄이는 것보다 조합이 진짜인 쪽을 택했다.**

수기 확인은 토큰 60개를 전부 훑는 방식이 아니다 — §7 이 정하는 것은 **케이스 62건**이고,
그중 사람이 눈으로 봐야 하는 것은 일부다.

⚠️ **슬롯이 4개다.** 업스트림의 소셜 필드가 `website`·`twitter`·`telegram`·`discord` 넷뿐이라
한 토큰에 URL 5개 이상은 실을 수 없다. `build-fixtures.ts` 가 넘치는 것을 `overflow` 로
보고한다 — 현재 최대가 4개라 발생하지 않는다.

### 업스트림은 스텁이다

`SolTrackerApiSdk` 하나만 가짜다. 이 명부가 정한 URL 집합을 그대로 돌려주는 스텁으로 바꾼다.
그 아래(fetcher · SDK · HTTP)는 **전부 실물**이다.

스텁이 필요한 이유는 두 가지다. 첫째, 실제 상류를 부르면 그 토큰의 현재 소셜 URL 이 돌아오는데
그 값은 매번 바뀌어서 루브릭을 고정할 수 없다. 둘째, 케이스가 겨누는 URL 이 실제로 그 토큰에
들어온다는 보장이 없다 — 상류가 이미 그 필드를 지웠을 수 있다.

스텁이 함께 실어야 하는 값이 하나 더 있다. `Token.image` 가 required 이고
`TokenProfileReader.read()` 는 `imageUrl` 없는 프로필을 `null` 로 접는다 — 그러면 **그 토큰의
문서가 아예 안 생기고 케이스가 통째로 못 돈다.** `report.html` 에 이미지 정보가 없어서,
`build-fixtures.ts` 가 상류를 한 번 불러 `symbol` 과 `imageUrl` 을 굳힌다(§6). 상류가 모르는
주소는 더미로 떨어지고 그 사실이 픽스처의 `source` 필드에 남는다.

---

## §4 루브릭 — 무엇이 나와야 통과인가

케이스별 표는 [`rubric.md`](./verification-rubric.md) 에 있다. 여기서는 **그 표를 읽는 규칙**을 정한다.

### 판정은 세 등급이다

실 호출로 돌리기 때문에 «통과/실패» 둘로는 부족하다. 트윗은 삭제될 수 있고 사이트는 죽을 수
있는데, 그것은 우리 결함이 아니다.

| 등급 | 조건 | 그래서 무엇을 하나 |
|---|---|---|
| **PASS** | `primary` 로 지정한 값이 그대로 나왔다 | 넘어간다 |
| **ALLOWED** | `allow` 집합 안이지만 `primary` 는 아니다 | **§7 의 수기 확인 목록에 올린다** — 대상이 삭제된 것인지 우리가 못 읽은 것인지는 사람만 가른다 |
| **FAIL** | `allow` 집합 밖이다 | 결함이다. 원인을 적고 이슈로 옮긴다 |

`ALLOWED` 를 통과로 접지 않는 것이 이 등급의 존재 이유다. 접으면 `not_found` 가 늘어나도
리포트가 초록색으로 남는다.

### 여섯 컬렉션에 무엇을 보나

| 컬렉션 | 보는 것 | 왜 이것을 보나 |
|---|---|---|
| `tokens.social_urls[]` | `status` · `attempts` · `attempted_at` | **성공과 실패를 함께 담는 유일한 곳**이다. 중복 게이트의 판정 근거이기도 하다 |
| `token_links` | 행 수 · `object_id` · `entry_url` · `link_depth` · `subtype` | "이 토큰이 어느 객체에 걸렸나" 의 답. `status: ok` 일 때만 생긴다 |
| `contents` | `platform_key` · `subtype` · `creator_id` · `venue_id` · `metrics_latest` | 객체의 정체성과 참조가 맞는지 |
| `accounts` | `platform_key` · 행 수 | **행 수가 핵심이다.** URL 유니크가 강등돼 중복 행을 DB 가 못 막는다 |
| `venues` | `platform_key` · `subtype` | Telegram·Reddit·X 커뮤니티만 만든다 |
| `metric_series` | `points[]` 길이 | 덮어쓰기가 아니라 쌓기인지 |

### 모든 케이스가 함께 지켜야 하는 것

케이스별 기대와 별개로, **62건 전부에 적용되는 불변식**이 넷 있다. `verify.ts` 가 전건에 대해
검사한다.

1. **`platform_key` 없는 객체 행이 없다** (루브릭 R-7). 스키마가 `required: true` 로 막고 있으므로
   위반이 있다면 저장이 실패했다는 뜻이다.
2. **`token_links` 행 하나마다 대응하는 객체 행이 있다.** 역참조가 끊긴 링크는 조회에서만
   드러나는 종류의 결함이라, 여기서 잡지 않으면 오래 산다.
3. **`attempts > 0` 인데 `attempted_at` 이 `null` 인 원소가 없다.** 둘은 같은 사실의 두 면이다.
4. **`status: ok` 인데 링크가 0건인 원소가 없다.** 이 상태는 다음 토큰에게 «성공했다» 고
   거짓말하는 자리다 — `UrlDedupeGate` 가 그것을 미스로 강등하는 분기를 갖고 있는데,
   그 분기가 발동했다면 애초에 그런 상태를 만든 곳이 있다는 뜻이다.

### status 어휘와 그 사이의 이동

```mermaid
stateDiagram-v2
    [*] --> pending : 신규 토큰의 URL 목록 선생성
    pending --> ok : 객체를 확정했다
    pending --> not_found : 불렀는데 대상이 없다
    pending --> blocked : 대상 쪽 사정(비공개·초대전용·IP차단)
    pending --> invalid : 200 을 받았는데 응답이 계약을 깼다
    pending --> error : 외부 호출 실패 · DB 일시 오류
    pending --> unsupported : 담당 Generator 가 없다 (호출 0회)
    pending --> skipped_paid : 유료라 안 불렀다 (호출 0회)

    not_found --> ok : 재시도로 결과가 바뀐다
    blocked --> ok : 차단이 풀린다
    error --> ok : 일시 오류가 지나간다
    error --> exhausted : attempts 가 MAX_ATTEMPTS(5) 에 닿았다

    ok --> ok : TTL(14일) 만료 후 재수집 — points 가 쌓인다
    unsupported --> ok : 우리가 그 Generator 를 만들면
    skipped_paid --> ok : 게이트를 켜면

    invalid --> [*] : 100% 같은 실패다 — 배치가 명시적으로 열어야 한다
    exhausted --> [*] : 상한에 닿았다 — 배치가 명시적으로 열어야 한다
```

**`unsupported` 와 `skipped_paid` 가 실시간 재시도 대상이 아닌 것**에 주의한다. 둘은
`attempted_at` 이 없어 백오프를 원리적으로 통과하고, 처리해도 같은 값이 나와 이벤트마다 도는
고리가 된다. 그 둘이 열리는 조건은 배포이지 거래가 아니다.

---

## §5 사전 준비와 안전장치

### `.env.dev` 하나로 전부 돈다

이 실행에 필요한 것이 `.env.dev` 에 이미 전부 있다.

| 필요한 것 | `.env.dev` |
|---|---|
| 실 API 키 5종 (Twitter · Apify · urlscan · GitHub · YouTube) | 있다 |
| `SOCIAL_RECORD_ENABLE_PAID=true` · `ENABLE_COMMUNITY=true` | 있다 |
| MongoDB (`af-social-scanner` @ localhost:27017) | 있다 |
| Redis · SolTracker 접속 정보 | 있다 (SolTracker 는 스텁으로 대체돼 실제로는 안 쓴다) |

**환경변수 오버라이드가 없다.** 명령에 붙는 것은 `APP_ENV=dev` 하나뿐이다.

그 DB 는 개발용 스크래치이고 지금 6개 컬렉션이 전부 비어 있다(2026-08-13 확인).
`--fresh` 가 그것을 비우고 시작하는 것이 정상 동작이다.

### 가드는 호스트 하나를 본다

`.env.prod` 의 DB **이름이 dev 와 완전히 같다** — 둘 다 `af-social-scanner` 이고 호스트만
`192.168.50.9` 로 다르다. 즉 **이름으로는 운영 DB 와 개발 DB 를 구별할 수 없고, 호스트가
유일한 판별 기준이다.**

`run.ts` 와 `verify.ts` 는 부팅 직후 `assertVerifyDatabase(connection)` 을 부르고,
규칙은 둘뿐이다.

1. **호스트가 localhost 여야 한다.** 이 한 줄이 운영 DB 를 막는 전부다.
2. **`nestjs_test` 가 아니어야 한다.** 통합 스펙들이 `deleteMany({})` 로 통째로 지우는 DB 라,
   거기 결과를 남기면 다음 `npm run test:integration` 한 번에 사라진다 — 실행이 실패하는 것이
   아니라 **결과가 조용히 없어지는 쪽**이라 더 나쁘다.

어느 하나라도 어긋나면 **부팅을 중단한다.** 경고만 남기고 진행하면 그 경고를 아무도 안 본다.

### 인프라 — 도커가 필요 없다

`.env.dev` 가 가리키는 로컬 Mongo(27017)와 개발 Redis 를 그대로 쓴다. 통합 테스트용
컨테이너를 띄우지 않는다.

이득이 하나 있다. 통합 테스트용 Mongo 는 `tmpfs` 위에 떠 있어서 컨테이너를 내리면 데이터가
사라지는데, 로컬 Mongo 는 디스크에 남는다. **수기 확인을 며칠에 걸쳐 해도 데이터가 그대로
있다** — DB 를 안 지우기로 한 이 작업의 전제와 맞는다.

대신 주의할 것이 둘이다.

- **검증 중에는 개발 인스턴스를 내린다.** 같은 DB 와 같은 Redis 를 쓰므로, dev 앱이 돌면
  같은 토큰 주소의 락(`social-record:<주소>`)을 두고 경쟁하고, dev 가 만든 토큰이 섞여
  **C 그룹이 «이미 처리된 URL» 을 보게 되어 재사용 케이스가 증명력을 잃는다.**
- **RabbitMQ 는 쓰지 않는다.** 진입점이 `SocialRecordProcessor` 직접 호출이라 큐가 필요 없다.
  `run.ts` 는 모듈 그래프에서 `RabbitMQModule` 을 빼고 조립한다 — 넣어 두면 브로커가 안 떠
  있을 때 부팅부터 실패한다.

## §6 실행 절차

### 스크립트 셋과 각자의 계약

| 스크립트 | npm | 입력 | 출력 | 부수효과 |
|---|---|---|---|---|
| `extract-cases.ts` | `verify:cases` | `report.html` · audit samples · website kinds | `cases/*.json` · `case-matrix.html` | 없음 |
| `build-fixtures.ts` | `verify:fixtures` | `cases/sample-tokens.json` | `cases/upstream-fixtures.json` | **상류 조회 1회** |
| `run.ts` | `verify:run` | `cases/*.json` | `results/<날짜>/run-log.json` | **DB 쓰기 · 외부 호출** |
| `verify.ts` | `verify:check` | `cases/*.json` · run-log · DB | `results/<날짜>/verdict.{json,html}` | 읽기만 |

넷으로 나뉜 이유는 **재실행 단위가 다르기 때문**이다. 판정 기준을 고쳤을 때 `verify:check` 만
다시 돌리면 되고, 그러면 외부 호출이 한 번도 안 나간다. 하나로 합치면 루브릭 오타 하나에
45회 과금이 다시 발생한다.

**`build-fixtures` 가 따로 있는 이유** — `report.html` 에 이미지 정보가 없다. 그런데
`TokenProfileReader.read()` 는 `imageUrl` 없는 프로필을 `null` 로 접고, 그러면 **그 토큰의
문서가 아예 안 생긴다.** 그래서 상류를 한 번만 실제로 불러 `symbol` 과 `imageUrl` 을 굳힌다.
굳히고 나면 `run.ts` 는 상류를 부르지 않는다. 상류가 모르는 주소는 더미로 떨어지고 그 사실이
픽스처의 `source` 필드에 남는다.

**스크립트는 TypeScript 다.** `extract-cases.ts` 가 `toCanonical()` 을 프로덕션 코드에서 그대로
import 하기 때문이다 — 중복 묶음이 «정규화로 합쳐지는가» 를 판정해야 하는데, 그 규칙을
복제하면 규칙이 두 벌이 되고 둘이 갈린 순간 케이스가 조용히 틀린 것을 겨눈다.

### 순서

```bash
# (1) 케이스 갱신 — report.html 이 새로 나왔을 때만
npm run verify:cases -- --report ../sol-alpha-finder-tracker/report.html

# (2) 업스트림 스냅샷 — symbol, imageUrl 을 굳힌다. 상류를 1회 부른다.
APP_ENV=dev npm run verify:fixtures

# (3) 개발 인스턴스를 내린다 (5절)

# (4) 스모크 — 무료 1건 + 유료 1건. 45회를 태우기 전에 키가 살아 있는지 본다.
APP_ENV=dev npm run verify:run -- --smoke

# (5) 본 실행 — 외부를 실제로 부른다. 약 76회, 그중 48회가 과금이다(실측 66회).
APP_ENV=dev npm run verify:run -- --fresh

# (6) 판정 — 읽기만 한다. 몇 번을 돌려도 안전하다.
APP_ENV=dev npm run verify:check

# (7) skipped_paid 미니 실행 — 게이트를 끄고 4건만. 호출 0회다.
APP_ENV=dev SOCIAL_RECORD_ENABLE_PAID=false SOCIAL_RECORD_ENABLE_COMMUNITY=false \
  npm run verify:run -- --only B-05

# (8) 결과 확인
open docs/features/recording-integration-verification/results/<날짜>/verdict.html

# (9) 수기 확인 — token-inspector
APP_ENV=dev npm run start   # http://localhost:30004/token-inspector.html
```

**(4) 스모크를 건너뛰지 않는다.** `.env.dev` 의 Apify·TwitterAPI 크레딧이 아직 남아 있는지는 부르기
전에 알 수 없고, 크레딧이 떨어진 상태로 본 실행을 돌리면 케이스 45건이 전부 `error` 로
떨어져 **아무것도 확인되지 않은 채 한 시간이 지나간다.**

**⑤ 를 따로 두는 이유** — `SKIPPED_PAID` 는 게이트가 꺼져 있을 때만 나오는 값이다. 본 실행이
게이트를 켜고 돌므로 그 분기가 한 번도 안 밟히고, X·TikTok·Instagram·Reddit 네 생성기가
모두 그 분기를 갖고 있다. 미니 실행은 외부 호출이 0회라 비용이 없고 몇 초면 끝난다.

### 호출 예산

케이스 JSON 에서 계산한 값이다. 실행 후 `verdict.json` 의 실제 호출 수와 대조하며, **예산을
넘으면 그 자체가 발견이다** — 재사용이 안 먹었거나 재시도가 돌았다는 뜻이다.

| 제공자 | 과금 | 예상 호출 | 실제 | 부르는 케이스 |
|---|:---:|---:|---:|---|
| TwitterAPI | 💰 | 26 | | X 전체 — 트윗·프로필·커뮤니티·트렌드 |
| Apify | 💰 | 19 | | TikTok · Instagram · Reddit |
| HTTP + RDAP + urlscan | 무료/쿼터 | 14 | | website 페이지 · 도메인 나이 · 지문 · **단축 해제 HEAD** |
| YouTube API | 무료 쿼터 | 7 | | 영상은 채널 보충으로 2회 |
| GitHub API | 무료 | 3 | | repo 는 owner 보충으로 2회 |
| Telegram | 무료 | 3 | | 공개 채널 스크레이프 |
| 파생 케이스 | — | 1 | | E-02 등 |
| **합계** | | **76** | | **그중 과금 48회** |
| 실측 2026-08-13 | | | **66** | 재사용이 예상보다 더 먹었다 |

**website 케이스의 호출 수에 `+1` 이 들어 있다.** `ShortlinkResolver.needsResolving()` 이
«담당 소셜이 없으면 풀어 본다» 로 판정해서(`return !this.router.owner(host)`),
website 계열 URL 은 예외 없이 **HEAD 1회**를 먼저 쓴다. 스모크 실행에서 «호출 0회여야 하는»
`web_tool`(axiom.trade)이 1회를 부르는 것으로 드러나 예산에 반영했다.

⚠️ 이 HEAD 는 창구에서 일어나므로 **`attempts` 에는 안 들어간다.** `web_tool` 이
`attempts: 0` 인 것은 여전히 맞다 — 예산과 `attempts` 는 서로 다른 것을 센다.

재시도·락 케이스(E 그룹)가 같은 URL 을 한 번 더 여는 몫이 이미 포함돼 있다. 실패가 겹쳐
재시도가 늘면 상한은 100회 근처다.

**호출 0회가 합격 조건인 케이스가 있다.** C 그룹 8건 중 7건은 두 번째 토큰이 외부를 부르지
않아야 하고, `x_tweet_search`·`tiktok_search`·`web_social_post` 는 애초에 부를 것이 없다.
그 0 이 지켜지지 않으면 예산 표의 합계가 먼저 틀어진다.

### 소요 시간

외부 응답 대기가 지배한다. Apify 는 액터 실행이라 건당 수십 초가 걸릴 수 있고, URL 락 대기
상한이 180초다. **전체 30~60분**을 잡는다. E-04(백오프 60분)와 E-05(TTL 14일)는 시계를 기다리지
않고 `attempted_at` 을 직접 되돌려 만든다 — 그 조작은 `run.ts` 가 하며 로그에 남긴다.

---

## §7 판정

### 자동이 보는 것과 사람이 보는 것

| | 자동 (`verify.ts`) | 사람 |
|---|---|---|
| `status` 가 `allow` 집합 안인가 | ✅ | |
| 행 수 · 참조 무결성 · 불변식 4종 | ✅ | |
| 호출 수가 예산 안인가 | ✅ | |
| `ALLOWED` 로 떨어진 케이스의 **원인** | ❌ | ✅ 대상이 삭제된 것인가, 우리가 못 읽은 것인가 |
| 저장된 **값이 말이 되는가** | ❌ | ✅ 팔로워 수가 0인가, 본문이 잘렸는가, 날짜가 1970년인가 |
| 현재 라우터의 재분류가 **타당한가** | ❌ | ✅ 원천과 다르게 잡힌 URL 이 더 맞는 판정인가 |
| `subtype` 세분화가 맞는가 | 부분 | ✅ `photo` 여야 할 것이 `video` 로 저장됐는지 등 |

자동이 값의 타당성을 못 보는 이유는 기준이 없기 때문이다. 팔로워 수가 3인 계정은 실재하고,
본문이 짧은 트윗도 실재한다. 그 둘과 «파서가 잘랐다» 를 가르는 것은 사람뿐이다.

### 수기 확인 절차

`token-inspector` 가 그 도구다. 이 실행이 쓰는 DB 를 가리키도록 띄운다.

```bash
APP_ENV=dev npm run start   # http://localhost:30004/token-inspector.html
```

훑는 순서는 다음과 같다. **위에서 아래로 가면 넓은 것에서 좁은 것으로 좁혀진다.**

- [ ] **Tokens 탭** — 60개 토큰이 전부 있는가. 하나라도 없으면 그 토큰은 저장 단계에서 죽었다.
- [ ] `ALLOWED` 로 떨어진 케이스를 하나씩 연다. `verdict.html` 이 그 목록을 맨 위에 올린다.
- [ ] **Contents 탭** — `subtype` 별로 한 건씩 열어 본문·지표·발행일이 말이 되는지 본다.
- [ ] **Accounts 탭** — 팔로워 수가 0이거나 `display_name` 이 비어 있는 행을 찾는다. 실재할 수도
      있지만, 같은 플랫폼에서 여러 건이 그러면 매핑이 빠진 것이다.
- [ ] **Venues 탭** — Telegram 채널과 Reddit 서브레딧이 각자 맞는 `subtype` 인가.
- [ ] **역참조** — 콘텐츠 하나를 골라 «이 콘텐츠를 건 토큰들» 을 연다. C 그룹의 공유 URL 이면
      여러 토큰이 나와야 한다. **여기가 중복 처리가 실제로 동작했는지 눈으로 보이는 유일한 자리다.**
- [ ] **원천과 다르게 분류된 URL** — `verdict.html` 의 «재분류» 절을 본다. 다른 것이 발견이므로,
      어느 쪽이 맞는지 판단하고 적는다.

### 판정 결과를 어디에 적나

`results/<날짜>/verdict.md` 를 손으로 만들어 세 가지만 적는다. 길게 쓰지 않는다.

1. **FAIL 목록과 각각의 원인.** 이슈로 옮길 것.
2. **ALLOWED 중 사람이 «우리 결함» 으로 판정한 것.** 자동이 못 가른 것을 사람이 갈랐다는 기록이다.
3. **다음 실행에서 바꿀 것.** 케이스가 부족했던 자리, 루브릭이 너무 느슨했던 자리.

---

## §8 결과 보존과 재실행

### DB 가 남는다는 것의 의미

두 번째 실행은 **첫 번째 실행이 남긴 데이터 위에서 돈다.** 이것이 이 실행 방식의 가장 큰 함정이다.

- 토큰 문서가 이미 있으면 `record()` 는 업스트림을 부르지 않고 `pending` 만 회수한다 —
  A 그룹 대부분이 «이미 `ok`» 라 아무 일도 안 일어난다.
- `contents`·`accounts` 는 남아 있고 URL 유니크가 강등된 상태라, 재실행이 중복 행을 쌓을 수 있다.

그래서 재실행에는 두 모드가 있고, `run.ts` 가 이것을 요구한다.

| 모드 | 무엇을 하나 | 언제 쓰나 |
|---|---|---|
| `--fresh` | 케이스에 쓰이는 **6개 컬렉션을 전부 비우고** 시작한다 | 루브릭을 통째로 다시 볼 때 |
| `--resume` | 지우지 않는다. 아직 `pending`·실패인 URL 만 다시 연다 | 앞 실행이 중간에 죽었을 때 |
| `--only <케이스ID>` | 그 케이스의 토큰만 지우고 그것만 돌린다 | 한 케이스를 고친 뒤 확인할 때 |

**기본값을 두지 않는다.** 셋 중 하나를 명시하지 않으면 스크립트가 종료한다. 지우는 판단은
사람이 한다.

### 남는 것

```
results/
└── 2026-08-13/
    ├── run-log.json     # 케이스별 실제 호출 수 · 소요 · 예외
    ├── verdict.json     # 케이스별 PASS / ALLOWED / FAIL 과 근거
    ├── verdict.html     # 위를 사람이 읽는 형태로 — ALLOWED 를 맨 위에 올린다
    └── verdict.md       # 사람이 손으로 적는 결론 (§7)
```

**`results/` 는 커밋한다.** 검증 DB 는 로컬 Mongo 의 디스크에 남으므로 며칠은 버티지만,
그것은 이 머신에만 있는 데이터다. 다른 사람이 보거나 몇 달 뒤에 대조할 수 있는 유일한
기록이 이 폴더다.

### 원천이 갱신되면

새 `report.html` 이 나오면 `extract-cases.ts` 를 다시 돌린다. `cases/case-matrix.json` 의
diff 가 곧 «그동안 실서비스에 무엇이 새로 생겼나» 다. 새 sourceType 이 나타나면 A 그룹이
자동으로 늘고, 그 종류의 `EXPECT_OF` 항목이 없으면 스크립트가 `expect: null` 로 내보낸다 —
그것이 «루브릭을 아직 안 정했다» 는 표시다.

---

## §9 알려진 한계

1. **`skipped_paid` 는 본 실행 밖이다.** 유료 게이트를 켜고 도는 것이 전제라, §6 ⑤ 의 미니
   실행이 그 하나를 담당한다. 미니 실행을 건너뛰면 그 어휘는 검증되지 않는다.
2. **토큰의 URL 조합은 부분집합이다.** §3 에 적은 대로 `report.html` 이 전체 필드를 담지 않는다.
   «URL 8개를 건 토큰» 같은 상황은 이 실행에 없다.
3. **`SERIES_POINT_CAP`(200) 은 안 밟힌다.** 관측 2회까지만 만든다.
4. **`invalid`(계약 위반) 케이스가 없다.** 실 응답이 계약을 깨는 상황은 일부러 만들 수 없다.
   그 자리는 단위 테스트가 담당한다.
5. **재수집 배치를 안 본다.** `POST /reconcile` 과 `POST /retry` 는 별개 진입점이고, 케이스가
   다시 배가된다. 다음 라운드로 남긴다.
6. **`web_tool` 과 `web_social` 은 «부르지 않는 것» 만 확인한다.** 그 판단이 옳은지(정말 그
   도메인들이 토큰에 대해 아무것도 안 말하는지)는 이 실행의 질문이 아니다 — 그것은
   `social-url-structure-audit` 의 질문이다.
7. **한 번의 실행은 한 시점이다.** 소셜 API 는 조용히 바뀐다. 이 문서가 재실행 가능하도록
   만들어진 이유가 그것이고, 값이 있는 것은 한 번의 통과가 아니라 **두 실행 사이의 diff** 다.
