# 검증 루브릭 — 케이스 62건의 성공 기준

> **이 표는 판정 «기준» 이지 판정 «결과» 가 아니다.** 결과는 실행마다
> `results/<날짜>/verdict.json` 에 남는다.
>
> 읽는 규칙(PASS / ALLOWED / FAIL 세 등급, 여섯 컬렉션에서 무엇을 보나, 전건 불변식 4종)은
> [`runbook.md` §4](./verification-runbook.md) 에 있다. **여기 오기 전에 그것을 읽는다.**
>
> 기계가 읽는 정본은 `cases/case-matrix.json` 의 `expect` 필드이고,
> 그 값은 `scripts/extract-cases.ts` 의 `EXPECT_OF` 표에서 나온다. **이 문서와 그 표가
> 어긋나면 코드 쪽이 맞다** — `verify.ts` 가 읽는 것이 그쪽이기 때문이다.

---

## 표 읽는 법

| 칸 | 뜻 |
|---|---|
| **호출** | 그 URL 하나가 외부를 부르는 횟수. 보조 호출(작성자 프로필·채널·owner 보충)을 포함한다 |
| **허용** | 이 값들 안이면 FAIL 이 아니다. `primary` 가 아닌 값은 ALLOWED 로 떨어져 수기 확인 목록에 오른다 |
| **기대(primary)** | 정상일 때 나와야 하는 값 |
| **subtype** | `contents.subtype` 또는 `venues.subtype`. `\|` 로 나뉜 것은 **fetch 후 세분화**되는 자리라 응답에 달렸다 |
| **행 수** | `c` = contents · `a` = accounts · `v` = venues. 그 URL **하나가** 만드는 행 수다 |
| **series** | `metric_series` 문서 수. `>=1` 은 지표가 있는 객체마다 하나 |

⚠️ **A 그룹 표에는 케이스 ID 가 없다.** ID 는 `extract-cases.ts` 가 순번으로 붙이므로
원천이 갱신되거나 종류가 하나 늘면 **전부 밀린다.** 이 문서가 답하는 것은 «이 sourceType 이
무엇을 만들어야 하나» 이지 «A-07 이 무엇인가» 가 아니라서, 키를 sourceType 으로 잡았다.
지금 어떤 ID 가 어떤 URL 을 쓰는지는 [`cases/case-matrix.html`](../../docs/features/recording-integration-verification/cases/case-matrix.html) 이 답한다.

`token_links` 행 수는 표에 따로 두지 않았다. **규칙 하나로 결정되기 때문**이다 —
객체 행 하나마다 링크 하나이고, 역도 성립한다. 즉 `링크 수 = c + a + v` 이며,
`status` 가 `ok` 가 아니면 전부 0이다.

---

## A 그룹 — URL 종류 (37건)

실서비스에 실제로 들어오는 sourceType 22종과 그 구조 변형을 담당한다(`website` 는
AW 그룹이 kind 5종으로 쪼개 담당한다). 이 그룹이 통과하지
않으면 나머지 그룹은 볼 의미가 없다 — 기본 수집이 안 되는데 중복 처리를 보는 셈이 된다.

### X (7건) — 유료, 가장 비싼 자리

| sourceType | 호출 | 허용 | 기대 | subtype | 행 수 | series |
| ---|---:|---|---|---|---|---|
| `x_tweet` (`x.com/{h}/status/{id}`) | 2 | ok · not_found | **ok** | `tweet` | c1~3 / a1~3 / v0 | ≥1 |
| `x_tweet` (`twitter.com/…`) | 2 | ok · not_found | **ok** | `tweet` | c1~3 / a1~3 / v0 | ≥1 |
| `x_profile` (`x.com/{h}`) | 1 | ok · not_found · suspended | **ok** | — | c0 / a1 / v0 | 0 |
| `x_profile` (`twitter.com/{h}`) | 1 | ok · not_found · suspended | **ok** | — | c0 / a1 / v0 | 0 |
| `x_tweet_search` | **0** | ok | **ok** | `search` | c1 / a0 / v0 | 0 |
| `x_community` | 2 | ok · unsupported | **ok** | `community` | c0 / **a1** / v1 | 0 |
| `x_community` (`twitter.com/…`) | 2 | ok · unsupported | **ok** | `community` | c0 / **a1** / v1 | 0 |

**`x_tweet` 의 행 수가 범위인 이유** — 인용 사슬의 길이에 달렸다. 인용이 없으면 콘텐츠 1·계정 1,
인용의 인용까지 있으면 콘텐츠 3·계정 3까지 간다. 그 사슬 자체를 겨누는 것은 D-01 이다.

**`twitter.com` 변형 케이스가 세 종류에 하나씩 있다.** 정규화가 `x.com` 으로 접어 주지 않으면
같은 대상이 두 행이 된다. 그래서 `x.com` 케이스와 `twitter.com` 케이스가 **서로 다른 `platform_key` 를 만들면 FAIL** 이다.

**`x_tweet_search` 는 호출 0회가 합격 조건이다.** 검색어가 곧 키이자 본문이라 부를 것이 없다. 1회라도
불렸으면 유료 API 를 이유 없이 쓴 것이다.

**`x_community` 가 계정 행을 하나 만든다.** `fromCommunity()` 가 개설자를 **1콜 더** 부르기
때문이다 — `XCommunity` 응답이 `creatorId`·`creatorUserName` 만 줘서, 그것만으로 계정을
만들면 나머지 필드가 영구 미상태가 된다(G-6, 기존 행은 갱신되지 않으므로 첫 생성이 곧 최종).
그래서 호출도 2회다.

`x_profile` 에 `suspended` 를 허용에 넣은 이유 — 정지·탈퇴 계정은 `platform_key` 를 만들 수
없어서 별도 어휘를 받았다. 밈 코인이 거는 계정은 실제로 자주 정지된다.

### TikTok · Instagram (8건) — 유료(Apify)

| sourceType | 호출 | 허용 | 기대 | subtype | 행 수 | series |
| ---|---:|---|---|---|---|---|
| `tiktok_video` (`/video/{id}`) | 1 | ok · not_found | **ok** | `video` \| `photo` | c1 / a1 / v0 | ≥1 |
| `tiktok_video` (`/photo/{id}`) | 1 | ok · not_found | **ok** | `photo` | c1 / a1 / v0 | ≥1 |
| `tiktok_profile` | 1 | ok · not_found | **ok** | — | c0~1 / a1 / v0 | 0~1 |
| `tiktok_search` (`/search`) | **0** | ok | **ok** | `search` | c1 / a0 / v0 | 0 |
| `tiktok_search` (`/search/video`) | **0** | ok | **ok** | `search` | c1 / a0 / v0 | 0 |
| `tiktok_shortlink` (`vm.tiktok.com`) | 2 | ok · not_found · unsupported | **ok** | `video` \| `photo` | c1 / a1 / v0 | ≥1 |
| `instagram_post` | 2 | ok · not_found | **ok** | `post` \| `reel` \| `tv` | c1 / a1 / v0 | ≥1 |
| `instagram_story` | 1 | ok · not_found | **ok** | — | c0 / **a1** / v0 | 0 |

**`/photo/{id}` 케이스가 `photo` 가 아니면 FAIL 이다.** URL 경로가 이미 `/photo/` 라 슬라이드쇼임이 확정인데
`video` 로 저장되면 세분화가 응답을 안 읽은 것이다. 반면 `/video/{id}` 케이스는 경로가 그렇더라도 응답이
`isSlideshow` 를 주면 `photo` 가 맞다 — **경로가 아니라 fetch 결과가 정체성을 정한다**(R-11).

**`tiktok_profile` 의 행 수가 범위인 이유** — 프로필 응답이 최신 영상 1건을 함께 준다. 그 영상이 있으면
콘텐츠 1행이 함께 생기고, 계정이 영상을 하나도 안 올렸으면 0행이다.

**`tiktok_shortlink` 는 리다이렉트 해제가 먼저다.** 2회 중 1회가 그것이다. `unsupported` 를 허용에 남긴
이유는 단축 URL 해제 실패가 아직 알려진 한계이기 때문이고, 그 값이 나오면 ALLOWED 로 올라간다.

**`instagram_story` 는 소유 계정을 만든다**(2026-08-13 확정). 스토리 자체는 24시간이면
사라지지만 **누가 올렸나는 남는 정보**라 그대로 두기로 했다. 콘텐츠 행은 안 만든다 —
24시간 뒤에는 대상이 없다.

생성기는 이것을 이미 정확히 적어 두고 있었다(I-4) — actor 가 story 경로를 통째로 무시하고
그 계정의 프로필을 돌려주므로, 스토리 내용은 얻을 수 없지만 **계정은 온전히 온다**.
어긋나 있던 것은 «fetch 보류» 라고만 적힌 두 줄(`ContentSubtype.STORY` 와 fetcher 의
`classify` 주석)이었고, 그 표현이 «URL 을 안 부른다» 로 읽혔다. 둘 다 고쳤다.

### Reddit (3건) — 유료(Apify)

| sourceType | 호출 | 허용 | 기대 | subtype | 행 수 | series |
|---|---:|---|---|---|---|---|
| `reddit_post` | 1 | ok · not_found | **ok** | `post` | c1 / **a0** / **v0** | ≥1 |
| `reddit_share` (`/s/{code}`) | 2 | ok · not_found · unsupported | **ok** | `post` | c1 / a0 / v0 | ≥1 |
| `reddit_user` | 1 | ok · not_found | **ok** | — | c0 / a1 / v0 | 0 |

🔴 **Reddit 게시물은 작성자·서브레딧 행을 만들지 않는다.** 생성기가 그렇게 못박고 있다 —
`toContent()` 주석: *«`creatorId` 를 비운다 — 그리고 영원히 빈다»*(V-2). 다른 소셜에서는
Writer 가 같은 묶음의 계정으로 그 자리를 채우는데(R-12), Reddit 은 **묶음에 계정이 없다.**

처음에는 이 표가 `c1 / a1 / v1` 이었다. 2026-08-13 실행에서 FAIL 이 났고, 확인해 보니
파이프라인이 아니라 **이 문서가 틀렸다** — 실측에서 그 게시물은 upvote 44,305 · 댓글 454 에
본문과 지표가 전부 저장됐지만 `creator_id`·`venue_id` 는 `null` 이었다.

⚠️ **그래서 «누가 썼나» 와 «어느 서브레딧인가» 가 이 파이프라인에 저장되지 않는다.**
그것이 받아들일 만한 설계인지는 이 문서의 질문이 아니다 — 코드가 의도라고 적어 두었으므로
루브릭은 코드를 따르고, 바꿀지 말지는 별도 판단이다(부록 참조).

**`reddit_share` 는 공유 링크라 해제가 먼저다.** 해제 후에는 `reddit_post` 와 완전히 같은
모양이 나와야 한다 — 같은 객체를 만드는 경로가 둘이면 산출이 같아야 한다는 규칙(R-18)이
여기서 검사된다.

### YouTube · GitHub · Telegram (7건) — 무료

| sourceType | 호출 | 허용 | 기대 | subtype | 행 수 | series |
| ---|---:|---|---|---|---|---|
| `youtube_video` | 2 | ok · not_found | **ok** | `video` | c1 / a1 / v0 | ≥1 |
| `youtube_channel` | 1 | ok · not_found | **ok** | — | c0 / a1 / v0 | 0 |
| `github_repo` | 2 | ok · not_found | **ok** | `repo` | c1 / a1 / v0 | ≥1 |
| `github_owner` | 1 | ok · not_found | **ok** | — | c0 / a1 / v0 | 0 |
| `tg_channel` (`t.me/{name}`) | 1 | ok · not_found · blocked | **ok** | `channel` \| `portal` | c0 / a0 / **v1** | 0 |
| `tg_channel` (`t.me/{name}/7`) | 1 | ok · not_found · blocked | **ok** | `channel` \| `portal` | c0 / a0 / v1 | 0 |
| `tg_guard_group` (`t.me/+hash`) | **0** | unsupported | **unsupported** | — | c0 / a0 / v0 | 0 |

**`t.me/{name}/7` 케이스는 게시물 번호가 붙은 URL 이다.** 그런데도 `t.me/{name}` 과 **같은 venue 행**으로 접혀야 한다 —
Telegram 은 채널만 다루고 개별 메시지는 대상이 아니다. 두 행이 생기면 정규화가 경로를 안
버린 것이다.

**`channel` 과 `portal` 의 구분** — 가드 봇이 앞을 막고 있으면 `portal` 이다. 진짜 방은 그
뒤에 있어서, 우리가 본 것은 포털일 뿐이라는 사실을 subtype 이 담는다.

**`tg_guard_group` 의 `unsupported` 는 결함이 아니라 구조적 한계다.** 초대 전용 링크는 가입하지
않으면 원리적으로 못 연다. 이 케이스가 확인하는 것은 그 사실을 **호출 0회로** 판정하는가다.

### website (5건) — 무료, 단 `web_site` 만 urlscan 쿼터를 쓴다

| sourceType | 호출 | 허용 | 기대 | subtype | 행 수 |
|---|---:|---|---|---|---|
| `web_site` | 4 | ok · not_found | **ok** | `site` | c1 / a0 / v0 |
| `web_reference` | 2 | ok · not_found | **ok** | `reference` | c1 / a0 / v0 |
| `web_directory` | 2 | ok · not_found | **ok** | `directory` | c1 / a0 / v0 |
| `web_tool` (`axiom.trade`) | **1** | unsupported | **unsupported** | — | c0 / a0 / v0 |
| `web_social` (코드의 `WEB_SOCIAL_HOSTS`) | **1** | unsupported | **unsupported** | — | c0 / a0 / v0 |

⚠️ **`facebook.com` 은 `web_social` 이 아니다.** audit 의 `website-kinds-cases.json` 은
그것을 `social` 로 분류했지만 코드의 `WEB_SOCIAL_HOSTS` 에는 없고 **reference 목록**에 있다.
2026-08-13 실행에서 facebook 릴스 URL 이 `web_reference` 로 잡혀 실제 페이지를 불렀고,
그렇게 이 불일치가 드러났다. **둘 중 하나가 틀렸고 아직 안 정했다**(부록 참조).

**website 는 kind 가 호출 여부부터 가른다.** 툴과 소셜은 도메인이 남의 것이라 페이지도 지문도
그 토큰을 말하지 않는다 — 실측에서 `axiom.trade` 310건이 전부 같은 플랫폼 소개문이었다.
**Generator 가 부르지 않는 것이 정상 경로**다.

⚠️ **그런데도 호출 수가 0이 아니라 1이다.** `ShortlinkResolver` 가 «담당 소셜이 없으면 풀어
본다» 로 판정해서(`return !this.router.owner(host)`), 창구에서 **HEAD 1회**가 먼저 나간다.
2026-08-13 스모크에서 `axiom.trade` 를 실제로 1회 부르는 것으로 확인했다.

**그 HEAD 는 `attempts` 에 안 들어간다.** 창구에서 일어나므로 Generator 의 «외부를 불렀는가»
판정 밖이다. 그래서 `web_tool` 의 **`attempts` 는 여전히 0이어야 하고, 1이면 FAIL** 이다 —
호출 예산과 `attempts` 는 서로 다른 것을 센다.

**`web_site` 의 4회**는 단축 해제 HEAD 1 · 페이지 1 · RDAP(도메인 등록 정보) 1 · urlscan 1 이다.
RSS 보정이 필요한 페이지면 5회까지 간다. `web_site` 만 지문을 받는 이유는 도메인 나이와
추적기 유무가 러그 판별에서 가장 검증된 신호이기 때문이다.

**`web_site` 는 콘텐츠 패턴이 아니어도 `ok` 로 끝날 수 있다.** 패턴 판정에 실패해도 지문은
이미 받았고, 그것을 버리면 영구 유실이다 — 러그된 도메인은 나중에 다시 봐도 값이 달라진다.
그래서 «패턴 실패 + 지문 있음 → `ok`» 가 정상이고, 이 갈래가 없어져서 `not_found` 가 되면
ALLOWED 가 아니라 회귀다.

### 상류 버킷 (6건) — 판정 대상이 결과가 아니라 «분류» 다

| 원천 분류 | 실제 URL | 이 케이스가 묻는 것 | 2026-08-13 실측 판정 |
|---|---|---|---|
| `unknown` | `x.com/search?q=…` | 무엇으로 재분류되나 | `x_tweet_search` |
| `unknown` | `x.com/i/trending/{id}` | 무엇으로 재분류되나 | `x_trend` |
| `instagram` | `instagram.com/{handle}/` | 프로필로 잡히나 | `instagram_profile` |
| `instagram` | `instagram.com/p/{code}` | 게시물로 잡히나 | `instagram_post` |
| `reddit` | `reddit.com/r/…/comments/…` | 게시물로 잡히나 | `reddit_post` |
| `reddit` | `reddit.com/user/…?js_challenge=1&token=…` | **쿼리 파라미터를 버리고** 사용자로 잡히나 | `reddit_user` |

이 여섯은 상류 트래커가 못 갈라서 뭉뚱그린 버킷이다. **기대값을 미리 못 박지 않는다** —
현재 라우터가 무엇으로 부르는지가 1차 판정 대상이고, 그 결과가 정해진 뒤에야 그 종류의
루브릭이 적용된다. `verify.ts` 는 재분류 결과를 `verdict.html` 의 «재분류» 절에 모아 올리고,
어느 쪽이 맞는지는 사람이 판단한다(runbook §7).

**오른쪽 칸은 2026-08-13 에 실제로 나온 값이다.** 여섯 전부 타당한 세분화로 보이지만,
그 판단을 이 문서가 대신하지 않는다 — 다음 실행에서 값이 바뀌면 그것이 곧 라우터가 바뀌었다는
신호이고, 바뀐 것이 옳은지는 그때 다시 본다.

**마지막 행이 특히 중요하다.** 실측 URL 에 `js_challenge` · `token` · `solution` 파라미터가
그대로 붙어 있다. 정규화가 이것을 안 버리면 같은 사용자가 URL 마다 다른 행이 되고, 중복
처리는 영원히 안 먹는다.

## B 그룹 — status 어휘 (6건)

`ok` 는 A 그룹이 이미 밟는다. 여기서는 **나머지 어휘**를 하나씩 만든다.

| ID | 어휘 | 만드는 방법 | 합격 조건 | 왜 이것을 보나 |
|---|---|---|---|---|
| B-01 | `not_found` | 삭제 확인된 트윗 1건 | status=`not_found` · attempts=**1** · attempted_at **있음** · 링크 0 | 실패는 링크를 안 남긴다. 그런데도 `social_urls[]` 에 남아야 다음 토큰이 같은 실패를 돈 주고 재확인하지 않는다 |
| B-02 | `blocked` | `t.me/+` 초대 링크 | status=`unsupported`\|`blocked` · attempts=**0** | 대상 쪽 사정만 별도 어휘를 받는다. 우리 쪽 실패와 섞이면 재시도 정책이 틀린다 |
| B-03 | `unsupported` | `gist.github.com/…` | status=`unsupported` · attempts=**0** · attempted_at **null** | `attempts: 0` 이 핵심이다. 안 부른 호출이 세어지면 백오프와 비용 계산이 조용히 틀어진다 |
| B-04 | `exhausted` | B-01 의 URL 을 5회까지 민다 | status=`exhausted` · attempts=**5** | 상한이 없으면 죽은 URL 하나가 유료 호출을 영원히 반복한다 |
| B-05 | `skipped_paid` | 게이트를 끄고 유료 4건 | status=`skipped_paid` · attempts=**0** | ⚠️ **본 실행 밖이다** — runbook §6 (7) 의 미니 실행이 담당한다 |
| B-06 | `pending` | 신규 토큰의 URL 목록 | 처리 전 전원 `pending` · attempts=0 | 선생성이 없으면 중간에 죽은 토큰의 남은 URL 을 아무도 회수하지 못한다 |

**B-01 과 B-03 의 차이가 이 그룹의 핵심이다.** 둘 다 객체를 못 만들었지만, B-01 은 **불렀고**
B-03 은 **안 불렀다.** `attempts` 가 그 차이를 담는다. 이 값이 뭉개지면 «얼마를 썼나» 와
«언제 다시 부를까» 둘 다 틀린다.

---

## C 그룹 — 중복·재사용 (9건)

**모든 케이스의 공통 합격 조건: 두 번째 토큰이 Generator 를 부르지 않는 것이다** —
`attempts: 0` 이 그 증거다. 예외는 C-05 하나이고, 그 예외인 것이 C-05 의 존재 이유다.

⚠️ **«호출 0회» 와 «`attempts` 0» 은 다르다.** website 계열은 창구의 단축 해제 HEAD 가
중복 게이트보다 **먼저** 나가므로, 재사용이 완벽히 동작해도 HTTP 요청 1회는 찍힌다.
그 HEAD 는 Generator 호출이 아니라 `attempts` 에 안 들어간다 — 판정은 `attempts` 를 본다.

| ID | 상황 | 종류 | 두 번째 토큰의 합격 조건 |
|---|---|---|---|
| C-01 | 같은 raw URL | `x_tweet` | 호출 **0** · status `ok` · attempts **0** · attempted_at **null** · 첫 토큰과 **같은 `object_id`** |
| C-02 | raw 가 다른데 **정규화가 합침** | `tiktok_video` (`?_r=1&_t=…`) | 호출 **0** · 두 토큰의 canonical URL 이 **같다** |
| C-02b | raw 가 다르고 **정규화도 못 합침** | `youtube_video` (`watch?v=X&t=596s` vs `youtu.be/X?t=14`) | 호출 **>0** · entry_url 은 **다르되** `object_id` 는 같아야 한다 |
| C-03 | 대규모 fan-in (10토큰 이상) | `youtube_video` | 호출 **0** · **토큰마다 링크 수가 일정** |
| C-04 | website 중복 (같은 apex) | `website` | **`attempts` 0**(Generator 미호출) · urlscan 쿼터 소모 0. 호출 수는 창구 HEAD 1회를 포함한다 |
| C-05 | Telegram 중복 | `tg_channel` | 호출 **1** · attempts **1** · attempted_at **있음** ← **재사용하지 않는 것이 합격** |
| C-06 | 앵커 없는 재사용 | `x_tweet_search` | 호출 **0** · attempted_at 은 계속 **null** |
| C-07 | TikTok 중복 | `tiktok_video` | 호출 **0** · attempts **0** |
| C-08 | Instagram 중복 | `instagram_post` | 호출 **0** · attempts **0** |

### C-02 와 C-02b — 같은 질문의 두 답

원문이 다르면 눈으로는 다른 URL 이다. 우리 중복 게이트는 **canonical 문자열 완전 일치**로
조회하므로(`findBySocialUrls`), 정규화가 붙여 준 것만 재사용된다.

케이스를 만들면서 그 비율이 나왔다.

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

**C-02** 는 합쳐지는 쪽이다 — `?_r=1&_t=…` 같은 TikTok 공유 파라미터는 `TRACKING_PARAMS` 에
있어서 지워진다. 여기가 깨지면 **중복 처리가 조용히 무효**가 되는데 겉으로는 정상으로 보인다.

**C-02b** 는 안 합쳐지는 쪽이고, **기대하는 것이 «재사용 실패» 다.**
`youtube.com/watch?v=X&t=596s` 와 `youtu.be/X?t=14` 는 호스트도 경로도 다르다. `t` 는
`TRACKING_PARAMS` 에서 **일부러 뺐다** — 한 글자 이름이라 다른 사이트에서 식별에 기여할 수
있고, 잘못 지우면 서로 다른 페이지가 한 URL 로 접혀 관측 자체가 사라진다(그쪽은 복구가 안 된다).

그래서 C-02b 가 재는 것은 결함이 아니라 **그 결정의 대가**다. 호출 1회를 더 쓰는 것으로 끝나면
설계대로이고, `contents` 가 두 행이 되면 그건 결함이다 — `platform_key` 가 같은 대상으로
수렴시켜야 한다.

### C-03 이 겨누는 것은 링크 폭발이다

재사용은 링크를 **소스 토큰 하나에서만** 복사한다. 전 토큰에서 긁으면 N번째 토큰이
`2^(N-1)` 건을 쓴다. 실측 최대가 310토큰이므로 그 자리에서 터진다. 합격 조건이 «토큰마다
링크 수가 일정» 인 이유가 그것이다 — 앞 토큰 수에 비례해 늘면 그 자리가 폭발이다.

### C-05 는 «빠져 있는지» 를 확인한다

`UrlDedupeGate` 는 Telegram 을 **쿼리조차 하지 않는다.** `platform_key` 가 가변 `username` 이라
재할당이 일어나면 옛 행이 그대로 돌아오고, 재호출이 정체성을 고쳐 주지 못하므로 TTL 이
아무것도 사주지 못하기 때문이다. 무료라 재호출로 잃는 것도 없다.

이 결정은 코드 한 줄(`filter`)이라 **누가 «최적화» 라며 지우기 쉽다.** 그래서 «호출 1회가
합격» 인 케이스를 일부러 둔다.

### C-06 은 만료가 없는 재사용이다

`ok` 인데 `attempted_at` 이 `null` 인 조합은 **호출 0회로 성공한 경로**뿐이다(검색·intent·trend).
값이 URL 문자열에서 파생되므로 상할 것이 없고, 그래서 TTL 판정 자체를 지나간다.
이 분기가 없으면 검색 URL 이 14일마다 무의미하게 다시 돈다.

---

## D 그룹 — 그래프 모양 (5건)

| ID | 겨누는 것 | 합격 조건 |
|---|---|---|
| D-01 | 인용 사슬 | `link_depth` 가 **0 · 1 · 2 로 각각 하나씩**. 링크 3건. `LINK_MAX_DEPTH=3` 초과 없음 |
| D-02 | fan-out 계정 공유 | 같은 작성자를 가리키는 URL 3건 → `accounts` **1행**. 2행이면 FAIL |
| D-03 | 시계열 | 같은 콘텐츠 2회 관측 → `points[]` **2개** · `contents.metrics_latest` 는 **최신값** |
| D-04 | venue + creator 참조 | `contents.venue_id` · `creator_id` 가 각각 실재 행의 `_id` |
| D-05 | 이미지 지문 | `tokens.image` 존재 · `fingerprints[]` 채워짐 · 외부 호출 **0회** |

**D-01 이 검사하는 것은 최근 변경이다.** `LINK_MAX_DEPTH` 를 2에서 3으로 올린 것이
2026-08-07 이고, 그 전에는 이 필드가 사실상 «직접인가 아닌가» 라는 불리언이었다. 깊이 2가
1로 접혀 인용 원본의 작성자와 그보다 한 단 먼 제3자가 같은 값을 받았다. 값 셋이 실제로
나오는지는 실 응답으로만 확인된다.

**D-02 가 이 그룹에서 가장 무서운 케이스다.** `contents` 의 URL 유니크가 강등돼 있어 **DB 가
중복 행을 막지 못한다.** 막는 것은 URL 루프를 순차로 도는 것 하나뿐이다. 누군가 그 루프를
`Promise.all` 로 «최적화» 하면 이 케이스만 깨지고, 다른 61건은 전부 통과한다.

**D-05 의 «호출 0회» 가 조건인 이유** — 이미지 분석은 순수 계산이고, 그래서 유료 호출보다
**먼저** 실행되도록 순서가 잡혀 있다. 파서 버그 하나가 토큰마다 유료 호출을 먼저 지불하게
만들지 않기 위해서다. 그 순서가 유지되는지를 본다.

---

## E 그룹 — 실행 경로 (5건)

| ID | 겨누는 것 | 합격 조건 | 깨지면 무슨 일이 나나 |
|---|---|---|---|
| E-01 | 멱등 | 같은 토큰 재실행 → 호출 **0** · `token_links` 행 수 **불변** | `token_links` 에 유니크가 없어 DB 가 못 막는다. 중복 링크가 그대로 쌓인다 |
| E-02 | `pending` 회수 | 재실행이 **업스트림을 안 부르고** 남은 `pending` 만 가져간다 | 중간에 죽은 토큰의 남은 URL 을 아무도 안 가져간다 |
| E-03 | URL 락 | 두 토큰 동시 실행 → 외부 호출 **1회** | 판정을 락 밖에서 하면 둘 다 미스로 보고 둘 다 부른다 — 락이 있으나 마나다 |
| E-04 | 재시도 백오프 | 실패 후 즉시 재실행 → 호출 **0** · attempts **불변** | 고빈도 스트림이라 실패한 유료 URL 을 이벤트마다 다시 부른다 |
| E-05 | TTL 만료 | 앵커를 15일 전으로 되돌린 뒤 → **정상 경로** · attempts **1** | 만료가 안 터지면 지표가 영원히 안 갱신되고, 너무 자주 터지면 dedupe 가 무의미해진다 |

**E-03 이 C 그룹과 다른 점** — C 그룹은 «먼저 끝난 것을 나중 것이 재사용하는가» 를 본다.
E-03 은 «**동시에** 시작한 둘 중 하나만 부르는가» 를 본다. 뒤에 온 쪽은 락을 기다렸다가,
락을 얻은 «지금» 다시 판정해서 앞의 결과를 재사용해야 한다. 루프 밖에서 미리 뽑아 둔 판정을
쓰면 그 순간 락은 아무것도 보장하지 않는다.

**E-04 와 E-05 는 시계를 조작해서 만든다.** 60분과 14일을 실제로 기다릴 수 없다. `run.ts` 가
`attempted_at` 을 직접 되돌리며, 그 조작은 `run-log.json` 에 남는다 — 조작 사실이 기록되지
않으면 나중에 결과를 믿을 수 없다.

---

## 부록 — 이 루브릭이 아직 안 정한 것

| 항목 | 왜 비어 있나 | 언제 채우나 |
|---|---|---|
| `invalid`(계약 위반) 케이스 | 실 응답이 계약을 깨는 상황을 일부러 만들 수 없다 | 단위 테스트가 담당한다. 여기서는 안 만든다 |
| `SERIES_POINT_CAP`(200) | 관측 200회가 필요하다 | 별도 부하 시나리오로 분리 |
| `accounts.unavailable` | 이 필드를 채우는 경로가 아직 코드에 없다 | 그 경로가 생기면 |
| 재수집 배치(`/reconcile` · `/retry`) | 별개 진입점이라 케이스가 배가된다 | 다음 라운드 |
| 저장된 **값**의 타당성 | 자동으로 판정할 기준이 없다 | 사람이 본다(runbook §7) |
| **Reddit 이 작성자·서브레딧을 안 만드는 것** | 코드는 V-2 로 «의도» 라고 적었으나, 그러면 «누가 썼나»·«어느 서브레딧인가» 가 영영 저장되지 않는다 | 설계 판단 — 루브릭을 코드에 맞춰 뒀다 |
| **`facebook.com` 의 kind** | audit 은 `social`, 코드는 `reference`. 둘 중 하나가 틀렸다 | 어느 쪽이 맞는지 정해야 한다 |
