<!-- @format -->

# 기록 진입점 분리 — 토큰 락을 상위로, 프로세서를 신규/보충으로

> 상태: **구현 완료** (2026-08-20). 검증 결과는 §10.
> 선행 문서: `lock-before-execute-recording/design.md`(L-3·L-8·L-9·L-10),
> `social-recording-process/be-system-design.md` §3.3·§6, `reconcile-token/be-system-design.md` §6.1·§6.2

---

## §0 범위

### in-scope

1. **토큰 락을 프로세서 밖으로** 옮기고, 락 안에서 문서 존재를 판정하는 상위 서비스를 신설한다.
2. 프로세서의 public 진입점을 **신규 생성(`recordFresh`)** 과 **보충(`recordPending`)** 으로 가른다.
3. 재시도 배치(`retryUnconverged`)의 축을 **URL 에서 토큰으로** 올린다. `recordOneUrl` 은 사라진다.
4. `TokenRepository.upsertByAddress` 를 **신규 생성 전용 쓰기**로 바꾼다.
5. 반환값에서 **로직이 소비하지 않는 것을 전부 제거**한다.

### out-of-scope

- `SocialGraphWriter` · Generator · fetcher 계층은 건드리지 않는다.
- URL 락(`processOne`)은 **그대로 프로세서에 남는다**(§2 참고).
- `ReconcileOutcome`/`RetryOutcome` 을 응답에서 없애지 않는다 — 축만 토큰으로 재해석한다(§5).

---

## §1 지금 무엇이 잘못돼 있나

세 가지가 따로 난 문제로 보이지만 뿌리가 하나다 — **판정에 쓰는 정보를 판정하는 자리가 아닌
곳에서 미리 읽는다.**

### 1.1 배치가 락 밖에서 읽은 `attempts` 로 락 안에서 상한을 판정한다

`retryUnconverged` 는 `findByUrlStatuses`(락 없음)로 문서를 읽어 `RetryTarget.attempts` 를 확보한
뒤, 몇 분 뒤 `recordOneUrl` 안에서 토큰 락을 잡고 그 값을 `retryPolicy.finalStatus(attempts, …)`
에 넘긴다. URL 락 대기가 건당 최대 180초라 목록 뒤쪽 대상은 읽은 지 한참 뒤에 처리된다.

그 사이 실시간 경로가 같은 URL 을 처리해 DB 의 `attempts` 를 올려놓을 수 있다.

| 시각 | 사건 | DB `attempts` | 배치가 든 값 |
|---|---|---|---|
| T0 | 배치가 읽음 | 3 | **3** |
| T1 | 실시간이 같은 URL 처리 | 4 | 3 |
| T2 | 배치가 락 잡고 처리 | 5 | 3 |

`finalStatus` 는 `attempts + 1 >= MAX_ATTEMPTS(5)` 로 판정하므로 T2 에서 `3 + 1 = 4` 를 보고
`exhausted` 로 얼리지 않는다. **상한을 다 쓴 URL 이 재시도 목록에 남아 유료 호출을 한 번 더 낸다.**

### 1.2 호출부가 자기가 어느 갈래로 갈지 모른 채 부른다

문서 존재 판정은 락 안에서만 유효하고(L-3), 그 판정이 `collect()` **안**에 있다. 그래서 호출부는
자기 호출이 생성 갈래로 갈지 보충 갈래로 갈지 미리 알 수 없다.

결과가 `TokenSeed | null` 이다. 생성 때만 쓰이는 값을 보충 호출도 들고 다녀야 하고, 없이 생성
갈래로 떨어지면 `requireSeed()` 가 **런타임에 던져서** 막는다. 타입이 못 막아 실행 중에 터뜨리는
구조다.

### 1.3 배열 통째 교체를 «호출 규율» 만이 막고 있다

`upsertByAddress` 의 `$set` 은 `social_urls[]` 를 통째로 갈아끼운다. 문서가 있는데 부르면 그
토큰의 다른 URL 처리 결과가 전부 사라진다(H-015). 이걸 막는 것은 `collect()` 가 세 줄 위에서
존재를 확인했다는 사실 하나뿐이고, **DB 는 이 실수를 막지 않는다. 오류 없이 조용히 지워진다.**

> 이 레포는 같은 문제를 `demand` 에 대해서는 이미 구조로 풀었다 — `$setOnInsert` 로 뺀 한 줄에
> *"H-015 를 «호출 규율» 에서 «구조» 로 옮긴다"* 는 주석이 붙어 있다. 배열 쪽만 아직 규율이다.

### 1.4 덤으로 드러난 것 — `recordOneUrl` 의 분류 실패가 배치를 통째로 죽인다

`pendingWork` 는 `platformOf` 가 던지면 그 URL 만 빼고 계속한다. 그런데 `recordOneUrl` 은 같은
호출을 **try 없이** 하고, `retryUnconverged` 루프에도 try/catch 가 없다. 저장된 URL 하나가
canonical 이 아니면 **재시도 배치 전체가 그 자리에서 죽는다.** 이번 변경으로 그 경로가 사라진다.

---

## §2 목표 구조

### 락 두 개는 지키는 축이 다르다 — 그래서 소유자도 다르다

| 락 | 직렬화 축 | 막는 것 | 소유 |
|---|---|---|---|
| 토큰 락 | 토큰 문서 | 같은 토큰 문서를 둘이 만드는 것 | **상위 서비스**(신설) |
| URL 락 | URL | 같은 URL 에 유료 호출을 두 번 내는 것 | **프로세서**(그대로) |

**URL 락은 위로 못 올린다.** 그 락의 존재 이유가 «다른 토큰이 같은 URL 을 걸었을 때» 이고,
토큰 단위 상위단은 그 축을 표현할 수 없기 때문이다. 그래서 이 리팩토링은 "락을 전부 위로" 가
아니라 **"토큰 축 직렬화는 위, URL 축 직렬화는 아래"** 다.

### 계층

```
TokenSocialRecordHandler ─┐
SocialReconcileService   ─┼→ TokenRecordService ─→ SocialRecordProcessor ─→ SocialGraphWriter
social-record-test        │   · 토큰 락            · recordFresh
run.ts (검증 하네스)      ─┘   · findByAddress ×1   · recordPending
                                · 갈래 결정          · URL 락 + 루프
```

### 상위 서비스가 하는 일은 세 줄이다

```ts
락(토큰 주소)
  └ findByAddress 1회          ← 이 한 번이 세 가지를 동시에 준다
      ├ 없음 → recordFresh(context, seed)
      └ 있음 → recordPending(token, context, scope)
```

이 `get` 하나가 ① 갈래 결정 ② 상한 판정에 쓸 신선한 `attempts` ③ `retryCandidates` 의 입력을
동시에 만든다. §1.1 이 여기서 사라진다 — 읽은 시점부터 판정까지 아무도 그 값을 못 바꾼다.

---

## §3 시그니처 계약

> 이 절이 구현 착수의 승인 대상이다(레포 규약: 시그니처 문서가 코드보다 먼저).

### 3.1 신설 — `src/modules/social-recording/token-record.service.ts`

```ts
@Injectable()
export class TokenRecordService {
  constructor(
    private readonly lock: RedlockSDK,
    private readonly tokenRepository: TokenRepository,
    private readonly processor: SocialRecordProcessor,
  ) {}

  /**
   * 생성 의도가 있는 호출. 문서가 없으면 만들고, 있으면 실시간 범위로 보충한다.
   * @returns `false` 는 토큰 락 미획득. 실패가 아니라 «이번엔 건너뛴다» 다(L-10).
   */
  async ensureToken(context: RecordContext, seed: TokenSeed): Promise<boolean>;

  /**
   * 보충 전용 호출. 문서가 있다는 것이 전제이고, 없으면 던진다.
   * `RecordContext` 를 인자로 받지 않는다 — 심볼이 문서 안에 있어 락 안에서 만든다.
   */
  async retryToken(address: string, scope: RetryScope): Promise<boolean>;
}
```

`retryToken` 이 `TokenEntity` 가 아니라 **주소**를 받는 것이 계약의 핵심이다. 엔티티를 받으면
그것이 곧 배치가 락 밖에서 읽은 낡은 값이라 §1.1 이 그대로 남는다.

### 3.2 신설 타입 — `social-recording.types.ts`

```ts
/**
 * «어디까지 다시 열까». 축이 둘이라 한 객체로 묶는다.
 *
 * 실시간 경로는 상수 한 벌을 쓰고, 배치는 요청값을 쓴다. 배치가 간격을 고를 수 있어야 하는
 * 이유는 `reopen` 과 짝이기 때문이다 — Generator 를 고치고 `invalid` 를 다시 열 때 60분을
 * 기다리면 복구 요청의 의미가 없다.
 */
export interface RetryScope {
  /** `reopen` 해석은 상위단이 끝내고 온다. 프로세서는 목록만 본다. */
  statuses: readonly FetchStatus[];
  minIntervalMs: number;
}

/** 실시간 경로가 쓰는 고정 범위. `social-recording.consts.ts` 에 둔다. */
export const REALTIME_RETRY_SCOPE: RetryScope = {
  statuses: REALTIME_RETRY_STATUSES,
  minIntervalMs: RETRY_MIN_INTERVAL_MS,
};
```

### 3.3 변경 — `SocialRecordProcessor`

```ts
// 신설
async recordFresh(context: RecordContext, seed: TokenSeed): Promise<void>;
async recordPending(
  token: TokenEntity,
  context: RecordContext,
  scope: RetryScope,
): Promise<void>;

// private
private async runAll(work: UrlWork[], context: RecordContext): Promise<void>;
```

**삭제**: `record()` · `recordOneUrl()` · `collect()` · `requireSeed()` ·
`freshWork()`(→ `recordFresh` 본문) · `pendingWork()`(→ `recordPending` 본문).
`RedlockSDK` 주입은 **남는다** — URL 락이 그대로 있다.

`runAll` 이 지금 `collect` 의 루프다. `creditExhausted` 지역 변수와
`ExternalErrorCode.CREDIT_EXHAUSTED` 감지가 여기로 온다. `recordOneUrl` 이 갖고 있던
`creditExhausted: false` 하드코딩 특례는 사라진다.

### 3.4 변경 — `TokenSeed`

```ts
export interface TokenSeed {
  firstTransferAt: Date | null;   // 그대로. 정직한 결측이다
  demand: TokenDemand;            // 그대로
}
```

타입은 안 바뀌고 **`TokenSeed | null` 이라는 사용처가 사라진다.** `recordFresh` 는 non-nullable
로 받고, `recordPending` 에는 인자가 없다.

### 3.5 변경 — `TokenRepository`

```ts
/**
 * 신규 토큰 문서 삽입. **덮어쓰지 않는다.**
 *
 * `upsertByAddress` 를 대체한다. 저쪽의 `$set` 은 `social_urls[]` 를 통째로 갈아끼워
 * 기존 문서에 부르면 다른 URL 의 처리 결과가 조용히 사라졌다(H-015). 존재 판정은 이제
 * `TokenRecordService` 가 락 안에서 끝내고 오므로, 여기 도달했다는 것은 «문서가 없다» 는
 * 뜻이다. 그 전제가 깨지면 `uniq_tokens_address` 가 E11000 으로 터뜨린다 —
 * 조용한 소실 대신 시끄러운 실패다.
 *
 * ⚠️ 반환값이 없다. 호출부(`recordFresh`)가 지금도 안 쓴다.
 */
async insertNew(token: Token): Promise<void>;
```

`$set`/`$setOnInsert` 분해가 통째로 사라진다 — 삽입만 하므로 «삽입일 때만» 이라는 구분이
성립하지 않는다. `demand` 소유권 주석은 `Token.of()` 쪽에 남긴다.

### 3.6 변경 — `SocialReconcileService.retryUnconverged`

토큰 축으로 재작성한다.

```ts
const scope: RetryScope = {
  statuses: command.reopen?.length ? command.reopen : RETRYABLE_FETCH_STATUSES,
  minIntervalMs: command.minIntervalMinutes * 60_000,
};

const tokens = await this.tokenRepository.findByUrlStatuses([...scope.statuses], command.tokenAddresses);

// ⚠️ **이 선별을 빼면 안 된다.** 그냥 `tokens.slice(0, limit)` 을 하면 URL 이 전부 식지 않은
// 토큰이 `limit` 자리를 차지하고 아무 일도 안 한다. 백오프가 60분이고 크론이 1시간마다 도니
// 그게 흔한 경우다.
//
// 이것은 «방문할 가치가 있나» 를 고르는 **선별**이고, 락 밖이라 낡을 수 있다. 실제 판정은
// 프로세서가 락 안에서 **같은 `retryPolicy` 인스턴스**로 다시 한다 — 값을 복사하는 것이
// 아니라 부품을 공유하는 것이라 L-9 를 어기지 않는다.
const now = new Date();
const eligible = tokens.filter(
  (token) => this.retryPolicy.eligible(token.retryCandidates(scope.statuses, now), scope.minIntervalMs).length > 0,
);
const targets = eligible.slice(0, command.limit);

for (const token of targets) {
  if (deadline) { stoppedBy = 'deadline'; break; }
  try {
    if (!(await this.tokenRecordService.retryToken(token.address, scope))) { lockedOut += 1; continue; }
    processed += 1;
  } catch (error) {
    // §1.4 — 지금은 이 catch 가 없어서 URL 하나가 배치를 죽인다.
    this.logger.warn(`토큰 재시도 실패 — 배치는 계속한다 token=${token.address}: …`);
  }
}
```

`reconcileByRange` 는 `processor.record(…)` 를 `tokenRecordService.ensureToken(…)` 으로만 바꾼다.
루프 구조와 카운터는 그대로다.

---

## §4 변경 대상

| 파일 | 변경 |
|---|---|
| `social-recording/token-record.service.ts` | **신설** |
| `social-recording/social-record.processor.ts` | 진입점 2개로 분리, 토큰 락 제거, `requireSeed` 삭제 |
| `social-recording/social-recording.types.ts` | `RetryScope` 추가, `RecordContext`/`TokenSeed` 주석 개정 |
| `social-recording/social-recording.consts.ts` | `REALTIME_RETRY_SCOPE` 추가, `RETRY_LIMIT` 축 변경 |
| `social-recording/social-reconcile.service.ts` | `retryUnconverged` 토큰 축 재작성, `reconcileByRange` 호출 교체 |
| `social-recording/token-social-record.handler.ts` | `ensureToken` 호출로 교체 |
| `social-recording/social-recording.module.ts` | `TokenRecordService` 등록·export |
| `social-graph/token/token.repository.ts` | `upsertByAddress` → `insertNew` |
| `apis/v1/social-reconcile/social-reconcile.dto.ts` · `.controller.ts` | `RetryOutcome` 축 변경(§5) |
| `apis/v1/social-record-test/social-record-test.controller.ts` | `before` 유무로 `ensureToken`/`retryToken` 선택 |
| `docs/features/recording-integration-verification/scripts/run.ts` | `record` 6곳 · `recordOneUrl` 1곳 교체 |

### run.ts 의 B-04 는 주의가 필요하다

지금 B-04 는 `recordOneUrl` 을 `MAX_ATTEMPTS` 까지 반복 호출해 `exhausted` 도달을 검증한다.
`retryToken` 은 **그 토큰의 자격 있는 URL 을 전부** 처리하므로 호출 1회의 의미가 달라진다.
`minIntervalMs: 0` 인 scope 로 같은 횟수를 돌리되, **외부 호출 수 기대값을 URL 개수만큼
곱해서 다시 계산**해야 한다. 케이스 문서(`overview.md`)의 기대값도 함께 고친다.

---

## §5 배치 응답 계약 — 축만 토큰으로 옮긴다 (A안 확정)

`RetryOutcome` 은 유지하되 세는 단위가 URL 에서 토큰으로 바뀐다.

| 필드 | 지금 | 이후 |
|---|---|---|
| `candidateUrls` | 후보 URL 수 | **`candidateTokens`** — 상태축에 걸린 토큰 수 |
| `skippedByInterval` | 백오프에 걸린 URL 수 | 자격 URL 이 하나도 없는 토큰 수 |
| `processed` | 처리한 URL 수 | 처리한 토큰 수 |
| `lockedOut` | 락 미획득 URL 수 | 락 미획득 토큰 수 |
| `remaining` | 남은 URL 수 | 남은 토큰 수 |
| `stoppedBy` | 그대로 | 그대로 |
| `converged` | `ok` 로 넘어간 URL 수 | **삭제** — §6 |

`RETRY_LIMIT` 기본값을 **50(URL) → 10(토큰)** 으로 내린다. 토큰당 소셜 URL 이 최대 8개라
같은 기본값을 유지하면 최악 400 URL 이 되고, tt·ig·rd 는 건당 유료다. 10 이면 최악 80 으로
지금(50)과 같은 자리다. `MAX` 는 200 → 50.

---

## §6 잃는 관측 신호 하나와 그 대체

`converged` 는 죽는다. 세려면 프로세서가 URL 별 결과를 돌려줘야 하는데 그것이 정확히
«카운트를 위한 반환» 이라, 이번 리팩토링의 전제와 정면으로 어긋난다.

설계 §7 이 *"`converged` 가 0 으로 계속 남는 것이 재시도가 헛돌고 있다는 신호이고,
Observability 가 스코프 밖이라 그런 신호가 이것 하나뿐"* 이라고 못박아 둔 값이므로 **대체를
반드시 함께 넣는다.**

`recordPending` 이 토큰 1건을 끝낼 때 `log` 한 줄을 남긴다.

```
재시도 token=<addr> 대상=<n> 처리=<m> 백오프=<k> 수렴=<c>
```

반환값으로 실어 나르는 것보다 오히려 정확하다 — 지금 `converged` 는 배치가 돌 때만 세지만,
이 로그는 실시간 경로의 수렴도 같이 잡는다. `skippedByInterval` 이 토큰 축으로 뭉개지는 것도
이 줄이 URL 축으로 보완한다.

---

## §7 이 변경이 만드는 위험과 그 처리

| 위험 | 왜 생기나 | 처리 |
|---|---|---|
| 락 없이 프로세서를 부를 수 있다 | 진입점이 public 이고 NestJS DI 라 감출 수 없다 | `insertNew` 가 E11000 으로 터뜨린다(§3.5). URL 락은 그대로 남아 유료 중복 호출은 여전히 막힌다 |
| Redlock 실패 시 두 워커가 동시 생성 | 자동연장도 완벽한 뮤텍스가 아니다 | **의도된 동작이다** — 지금은 조용히 배열이 지워지고, 이후에는 한쪽이 E11000 으로 실패한다 |
| 상위 서비스가 얇다 | 몸통이 «락 → 조회 → 분기» 세 줄 | 그 세 줄이 모든 호출부의 nullable 씨앗을 없앤다. 값을 한다 |
| 테스트 대량 이전 | `social-recording.spec.ts` 의 `record()` 60여 회 | 기계적 치환. 락 미획득 케이스는 상위 서비스 테스트로 이사한다(§9) |

---

## §8 태스크 분할

| ID | 내용 | 선행 |
|---|---|---|
| T-001 | `RetryScope`·`REALTIME_RETRY_SCOPE` 추가, `TokenRecordService` 신설, 프로세서 진입점 분리 + 토큰 락 제거 | — |
| T-002 | `upsertByAddress` → `insertNew`(insert 전용) | T-001 |
| T-003 | 호출부 갱신 — handler · `reconcileByRange` · 수동 수집 컨트롤러 | T-001 |
| T-004 | `retryUnconverged` 토큰 축 재작성 + `RetryOutcome`·DTO·`RETRY_LIMIT` 개정 | T-001 |
| T-005 | `recordPending` 수렴 로그 추가(§6) | T-004 |
| T-006 | `run.ts` 검증 하네스 갱신 (B-04 기대값 재계산 포함) | T-002 · T-004 |
| T-007 | 테스트 이전 — 유닛 60여 곳, `url-lock.integration`, `demand-merge.integration`, `social-graph.schema.integration`, `social-graph.repository.spec` | T-002 · T-004 |
| T-008 | 문서 갱신 — `social-recording-process/be-system-design.md` §3.3·§6, `reconcile-token` §6.2, `guides/social-recording/{service-overview.md,url-pipeline.html,verification-runbook.md}` | 전부 |

### T-007 이 건드려야 하는 «계약을 고정한» 테스트 둘

- `demand-merge.integration.spec.ts:354` — *"upsertByAddress 를 다시 불러도 쌓인 demand 가
  살아남는다"*. 전제가 «다시 부를 수 있다» 인데 insert 전용이 되면 두 번째 호출이 던진다.
  **더 강한 보장으로 바뀌는 것**이므로 테스트를 «두 번째 호출이 E11000 으로 실패한다» 로 고친다.
- `social-graph.schema.integration.spec.ts:570` — *"tokens 는 address 로 upsert 되고…"*.
  같은 주소로 두 번 부르고 `symbol` 이 갱신되는지 본다. upsert 의미 자체가 사라지므로 케이스를
  «최초 발견 시각은 `created_at` 이 답한다» 만 남기고 갱신 검증은 제거한다.
- `social-graph.repository.spec.ts:254` — `$set` 이 4필드로 좁혀지는지 보는 단위 테스트.
  `$set` 이 사라지므로 «`insertNew` 가 삽입만 한다» 로 대체한다.

---

## §9 검증

1. `npx tsc --noEmit` — 진입점 삭제가 호출부를 전부 컴파일 에러로 드러낸다. `TokenSeed | null`
   이 사라지는 것도 여기서 확인된다.
2. `npm run test:unit` — 프로세서 유닛은 **상위 서비스를 통해** 부르도록 옮긴다.
   CLAUDE.md 의 *"공개 진입점에 주입하고 효과를 단언한다"* 에 맞춘다.
3. `npm run test:integration:full` — 선재 실패 13건(reddit 5 / schema 6 / social-recording 2)
   외에 새 실패가 없는지 확인. schema 6 중 하나는 T-007 로 성격이 바뀐다.
4. `npm run verify:all` — B-04·E-03·E-04 기대값이 §4 대로 갱신됐는지.
5. **수동 확인 1건** — 존재하는 토큰 주소로 `recordFresh` 를 강제로 불러 E11000 이 나는지.
   §1.3 이 규율에서 구조로 넘어갔다는 증거가 이것 하나다.

---

## §10 구현하면서 드러난 것

### 10.1 ⚠️ `demand` 가 스키마 검증을 **한 번도 통과한 적이 없다**

`insertNew` 로 바꾸자 통합 테스트 넷이 같은 에러로 터졌다.

```
Token validation failed: demand.cumm_buy_usd: Path `cumm_buy_usd` is required.,
                         demand.cumm_sell_usd: Path `cumm_sell_usd` is required.
```

원인은 **테스트 픽스처가 아니라 예전 쓰기 경로**다. `upsertByAddress` 는
`findOneAndUpdate({upsert:true})` 였고, **mongoose 는 업데이트에 검증기를 안 돌린다.** 그래서
`required: true` 로 선언된 필드가 비어 있어도 그대로 저장됐다 — 스키마가 선언한 계약이 **한
번도 강제된 적이 없었다.** `create()` 로 바꾸면서 비로소 걸리기 시작했다.

터진 픽스처들은 `token.flow` 이벤트 JSON 에서 `approxBuyUsd`·`approxSellUsd` 를 빠뜨리고
있었다. `TokenFlowEventDto.from()` 은 두 값을 검증 없이 통과시키므로 `TokenDemand.fromFlowEvent()`
가 `undefined` 를 담았고, 그것이 조용히 저장됐다.

**조치**: 픽스처 넷을 채우고 검증은 그대로 켜 둔다. 스키마가 required 라고 말했으면 그것이
계약이고, 지금까지는 그 계약이 강제되지 않았을 뿐이다.

⚠️ **운영 쪽에 남는 판단이 하나 있다.** 업스트림이 실제로 그 두 필드를 빠뜨린 이벤트를 보내면,
이제 그 토큰은 **조용히 반쪽 `demand` 로 저장되는 대신 예외로 떨어진다.** `dispatch` 가 핸들러
예외를 삼키고 ack 하므로 그 이벤트는 사라진다. 데이터 정합성과 수집 커버리지를 맞바꾼 것이고,
**바꾼 쪽이 맞다고 판단했다** — 반쪽 `demand` 는 DB 에서 «관측했더니 0 이더라» 와 구분되지
않아 나중에 복원할 수 없다. 실제 유실이 관측되면 `fromFlowEvent()` 에서 두 값의 결측을 명시적
사유로 다루는 별개 작업이 필요하다.

### 10.2 재시도 배치가 URL 하나에 죽던 경로가 사라졌다 (§1.4 확인)

`recordOneUrl` 은 `platformOf` 를 `try` 없이 불렀고 `retryUnconverged` 루프에도 catch 가 없었다.
토큰 축으로 올리면서 ① 분류 실패가 프로세서 안에서 URL 단위로 격리되고 ② 배치 루프에
토큰 단위 catch 가 생겼다. 두 층 다 테스트로 고정했다.

### 10.3 검증 결과

| 검사 | 결과 |
|---|---|
| `npx tsc --noEmit` | 0 errors |
| `npm run test:unit` | 53 files · **1308 passed** |
| `npm run test:integration` | 13 failed / 87 passed — **선재 실패와 정확히 동일**(reddit 5 · schema 6 · social-recording 2) |
| `npm run test:e2e` | 22 passed |
| `eslint`(변경 파일) | 0 errors (선재 warning 21건, 전부 미사용 import·목 파라미터) |

⚠️ **`npm run verify:all` 은 아직 안 돌렸다.** 실제 소셜 API 크레덴셜과 네트워크가 필요하다.
코드와 케이스 주석은 §4 대로 고쳤고, B-04 판정기(`verify.ts` `judgeExhausted`)가 호출 횟수가
아니라 **대상 URL 의 최종 상태**를 보므로 축 변경이 판정을 흔들지 않는다는 것까지는 소스로
확인했다. 실행 기대값 재확인은 다음 실행 때 한다.
