# Social Recording — 기록 결함 대장

> 대상: 토큰 1건이 저장되기까지의 전 경로.
> `TokenSocialRecordHandler` → `SocialRecordProcessor` → `SocialGraphWriter` → 리포지토리 · 모델.
>
> **[refactoring-direction.md](./refactoring-direction.md) 와 성격이 다르다.** 그 문서는
> "구조를 어떻게 바꿀까"(R-001~R-009, 전부 완료)이고, 이 문서는 **"만들어 놓은 것이 어디서
> 틀리나"** 다. 그래서 접두어를 `H`(hole)로 분리했다. 겹치는 항목은 각 절에서 R 번호로 잇는다.
>
> **조사 전제** — Generator 구현은 완전하다고 가정한다. 즉 `plan()` 이 던지는 계약 위반
> (빈 그룹 · `rootRef` 미해결 · 순환)은 대상에서 제외한다. 남은 것은 **배선·저장 계층 자체의
> 결함**뿐이다.
>
> 조사일: 2026-08-06

## 진행 상태

| ID | 심각도 | 항목 | 상태 |
|----|--------|------|------|
| H-001 | 🔴 | `link_depth` 가 첫 등장 그룹 기준 — 자기인용이면 계정이 depth 1 로 박힌다 | ✅ 완료 (2026-08-07) |
| H-002 | 🔵 | root 계보 밖 그룹의 depth 가 0 으로 떨어진다 | ✅ 완료 (2026-08-07) |
| H-003 | 🔴 | `tokens` **문서 존재**를 마커로 쓰는데 그 컬렉션은 다목적이다 | ⏸️ **보류 유지** (2026-08-10 재확인) |
| H-004 | 🔴 | `writer.write()` 가 URL 단위 격리 밖에 있다 | ✅ 완료 (2026-08-07) |
| H-005 | 🔴 | 락 TTL 만료 방어가 없다 | ✅ 완료 (2026-08-13) — 누적으로 해소, 근거 4개 전부 |
| H-006 | 🔵 | `tokenInfo` 없음 분기만 마커를 남기지 않는다 | ✅ 현행 유지 확정 (2026-08-07) |
| H-007 | 🔴 | `metrics_latest` 와 `metric_series` 가 갈릴 수 있다 (주석은 불가능하다고 단언) | ✅ 완료 (2026-08-13) — Generator 가 원천에서 막는다 |
| H-008 | 🟡 | 재관측 객체의 모든 필드가 버려진다 | ✅ 정책 확정 (2026-08-13) — 안 덮는다. 남은 일은 주기성 갱신 프로세스 |
| H-009 | 🟡 | 한 번 실패한 URL 의 재시도 경로가 지금은 없다 | ✅ 완료 (2026-08-10) |
| H-010 | 🟡 | Generator 가 0개라 현재 전 URL 이 `unsupported` | ✅ 완료 — **8/8** 이관됨 (X·YouTube·Telegram·Instagram·GitHub·TikTok·Reddit·Website) |
| H-011 | 🟡 | 같은 대상을 가리키는 두 URL 이 dedup 되지 않는다 | ⬜ 미착수 |
| H-012 | 🟡 | 정규화 실패 URL 은 흔적이 아예 남지 않는다 | ⬜ 미착수 |
| H-013 | 🟡 | `token_links` 재시도 부산물과 재관측 이력이 구분되지 않는다 | ✅ 완료 (2026-08-10) |
| H-014 | 🔵 | `write()` 주석의 쓰기 순서가 실제와 다르다 | ✅ 완료 (2026-08-07) |
| H-015 | 🔵 | `Token` upsert 의 `$set` 이 `fingerprints` 를 덮는다 | ✅ 완료 (2026-08-10) — H-003 과 분리됐다 |
| H-016 | 🔵 | `recordFirstPoint` 는 unique upsert 인데 재시도가 없다 | ⬜ 미착수 |
| H-017 | 🔵 | 핸들러 예외를 `dispatch` 가 삼키고 ack 한다 | ⬜ 미착수 |
| H-018 | 🔴 | 빈 `groups` 가 검증을 통과해 링크 0건인데 `ok` 로 남는다 | ✅ 완료 (2026-08-07) |
| H-019 | 🔴 | 호출 0회 성공 경로에서 `attempts` 가 과대계상된다 | ✅ 완료 (2026-08-07) |

상태: `⬜ 미착수` → `🔍 원인 확정` → `📐 개선안 확정` → `🔨 반영 중` → `✅ 완료`

심각도: `🔴` 조용히 틀린 데이터가 남거나 기록이 누락된다 · `🟡` 합의된 결정이지만 실제로
"기록 안 됨" 이 발생한다 · `🔵` 문서–코드 드리프트, 저확률 경합

### 착수 순서

**~~H-001 · H-002~~(완료) → ~~H-006~~(현행 유지) → ~~H-003~~(보류) → H-004 → H-005 → H-007**

- **H-001 · H-002** ✅ — 같은 파일 같은 두 함수이고 원인이 "깊이를 언제 확정하는가" 하나로
  같아 묶어서 처리했다.
- **H-006** ✅ 현행 유지로 확정 — 결함이 아니라 재시도가 값인 분기다.
- **H-003** ⏸️ **보류 유지** (2026-08-10 재확인) — reconcile-token 이 이 판정을 **고치지 않고
  우회했다.** 재시도는 `recordOneUrl` 이라는 **URL 단위 진입점**을 새로 만들어 멱등 판정
  (`findByAddress` → `already` 면 return)을 아예 지나가지 않는다. 그래서 "문서가 있어도 진행" 경로가
  생기지 않았고 결함이 드러날 조건도 그대로 없다. 웹·지문의 거처가 정해지기 전에는 고칠 대상이
  존재할지 자체가 여전히 미정이다.
  ~~**H-015 가 여기 묶여 있다.**~~ → **묶임이 풀렸다** — 아래 H-015 참조.
- **H-004** ✅ — `try` 를 저장까지 넓혔다. `ERROR` 어휘가 뭉치는 대가는 H-009 로 넘겼다.
- ~~**H-005 는 Generator 가 붙는 순간 곧바로 드러난다**(H-010). 그 전에 닫아 둔다.~~
  → **둘 다 닫혔다**(2026-08-13). Generator 는 8/8 이 됐고, H-005 는 «닫는 작업» 없이
  다른 변경들이 근거를 하나씩 없애 해소됐다 — 아래 H-005 항목 참조.
- **H-007 은 H-008 의 갱신 정책과 함께 결정해야 한다** — 재관측 시 무엇을 덮을지가 정해지면
  같이 풀린다. 그래서 🔴 중 마지막이다.
  → **2026-08-13 에 그 정책이 정해졌다**(H-008 참조, "아무것도 안 덮는다"). 다만 H-007 은
  **그것으로 풀리지 않는다** — 두 필드가 갈리는 원인이 «덮느냐» 가 아니라 **«둘의 최초
  시점이 다를 수 있다»** 이기 때문이다(지표 없이 첫 관측 → 지표와 함께 재관측). 안 덮는
  규칙을 확정할수록 그 어긋남이 영구히 남으므로, H-007 은 이제 **독립 항목**이다.
- **H-014** ✅ — 주석 한 줄. 부분 실패 역추적의 기준이라 대장에 올렸다.

### 스코프별 잔여 (2026-08-07)

| 스코프 | 항목 |
|---|---|
| **이 세션(기록 파이프라인 구조)** | ~~H-007~~ ✅ · ~~H-008~~ ✅ 정책 확정 · H-016 |
| **X Generator 세션** | H-010 은 여기서 해소된다 |
| **크론 세션** | ~~H-009~~ ✅ · H-011(R-001) · ~~H-013~~ ✅ — reconcile-token(2026-08-10)에서 둘 다 닫혔다. 남은 것은 H-011 뿐이다 |
| **웹 수집 세션** | H-003 · ~~H-015~~ ✅ — H-015 는 reconcile-token 이 별도 경로로 먼저 닫았다 |
| **직접 처리** | H-005 |
| **다른 레포까지 영향** | H-017 (base 클래스라 kline 구독도 함께 바뀐다) |

각 항목은 **원인 → 개선 방안 확정 → 구현** 순으로 하나씩 진행한다. 개선 방안이 컨펌되기
전에는 코드를 건드리지 않는다.

---

## 🔴 실제 버그

### H-001. `link_depth` 가 첫 등장 그룹 기준이라, 자기인용이면 계정이 depth 1 로 박힌다

**위치** — `src/modules/social-recording/social-graph.writer.ts:105-143`
**시각 설명** — [link-depth-defect.html](./link-depth-defect.html) §3 케이스 B (단계 재생)

> **2026-08-07 완료.** `Set` + `continue`(첫 등장 채택)를 `Map` + 무조건 `set`(덮어쓰기)으로
> 바꿨다. 사슬이 **깊은 것부터** 정렬돼 있으므로 나중에 덮는 쪽이 항상 더 얕고, 마지막에
> 남는 값이 곧 최소 깊이다.
>
> 최솟값 비교문을 따로 두지 않은 이유는 바로 위 `depth = ordered.length - 1 - index` 가
> **이미 같은 정렬에 기대고** 있기 때문이다. 정렬이 뒤집히면 깊이 값 자체가 틀리므로,
> 비교문을 덧대도 틀린 값들 중 최솟값을 고를 뿐 아무것도 구제하지 못한다.
>
> ⚠️ **테스트 두 건이 이 회귀를 못 잡고 있었다.** 하나는 기준 `_id` 를 `object_raw` 대상에서
> 뽑았는데 그 대상도 같은 깊이 판정으로 정해져 **함께 틀리며 통과**했고, 다른 하나는 깊이
> 목록을 정렬해 `[0, 1]` 과 비교해 **누가 0 인지를 묻지 않았다.** 둘 다 Generator 가 준
> `platform_key` 로 기준을 고정하도록 고쳤다. 같은 맥락에서 저장소 mock 의
> `findByPlatformKey` 가 항상 `null` 을 돌려주던 것도 고쳤다 — 실제 `accounts` 는 그 축에
> unique 인덱스가 있어 같은 키로 두 행이 생길 수 없는데, mock 이 그것을 어겨 자기인용
> 케이스가 애초에 재현되지 않았다.

`topoSort` 는 인용 원본이 먼저 오도록 정렬하므로 `ordered` 는 **항상** `[quoted, root]` 다.
그리고 `linkedObjectIds` 는 **첫 등장에서만** 링크를 만든다.

```ts
for (const group of plan.ordered) {          // [quoted(depth 1), root(depth 0)]
  const depth = plan.depthByRef.get(group.ref) ?? 0;
  ...
  for (const object of [creator, venueCreator, venue, content]) {
    if (linkedObjectIds.has(key)) continue;   // ← root 차례에 자기 계정이 여기서 걸린다
    links.push(this.buildLink(object, context, depth));
  }
}
```

root 트윗과 인용 트윗의 **작성자가 같으면**(X 자기인용) 그 계정은 quoted 그룹에서 `depth=1`
로 링크되고, root 그룹 차례에는 `continue` 로 건너뛴다.

**결과** — `link_depth: 0` 으로 "이 토큰이 직접 홍보한 계정" 을 조회하면 **그 계정이 결과에서
빠진다.** `token-link.model.ts:97-103` 이 이 필드의 존재 이유로 든 바로 그 용도가 깨진다.
같은 구조가 venue 에도 적용된다(같은 subreddit 의 원본과 인용).

에러가 나지 않고, 조회 결과가 조용히 한 건 적어질 뿐이다.

**관련** — R-008 이 확정한 "깊이는 묶음 단위" 규칙 자체는 맞다. 깨지는 것은 그 규칙과
객체 dedup 이 만나는 지점이다.

---

### H-002. root 계보 밖 그룹의 depth 가 0 으로 떨어진다

**위치** — `social-graph.writer.ts:221-236` (`buildDepth`) · 소비는 line 108
**시각 설명** — [link-depth-defect.html](./link-depth-defect.html) §3 케이스 C · §6 (계보 엣지 vs 도달 엣지)

> **2026-08-07 완료 · 🔴 → 🔵 강등.** 조사 중에는 실제 결함으로 봤으나, 검토 결과
> **현재 계약에서 재현 불가**다. 한 URL 에서 나온 계정과 글은 묶음 **하나**의 세 자리
> (`creator`·`venue`·`content`)에 같이 들어가고, 묶음이 갈리는 것은 인용일 때뿐이며 그때는
> `parentRef` 가 항상 있다. 형제 묶음이 나오려면 Generator 가 **목록**(검색 결과·최근 게시물)을
> 반환해야 하는데 그 모양은 계약에 없다.
>
> 그래서 `?? 0` 기본값을 없애고 **검증으로 승격**했다 — 도달 못 하는 묶음은 정상 입력이 아니라
> Generator 버그이므로 나머지 검증 4종과 같이 던진다. 목록형 Generator 가 필요해지는 날
> 이 검증이 막고, 그때 도달 관계를 별도 이름표로 표현해야 한다. 조용히 틀리는 대신
> 런타임에 터지는 쪽을 택했다.

```ts
let cursor = root;
while (cursor && !depthByRef.has(cursor.ref)) {
  depthByRef.set(cursor.ref, depth);
  depth += 1;
  cursor = cursor.parentRef ? byRef.get(cursor.parentRef) : undefined;
}
```

root → `parentRef` **단일 체인**만 훑는다. 형제 그룹(프로필 + 고정 트윗)이나 두 갈래 인용이
생기면 map 에 없어 `plan.depthByRef.get(group.ref) ?? 0` 에서 **0** 으로 떨어진다.

**결과** — depth 0 은 "토큰이 **직접** 건 URL 의 대상" 인데, 실제로는 fan-out 산물이다.
H-001 과 반대 방향의 오기록이고 역시 조용하다.

현재 그래프가 X 단일 체인뿐이라 안 드러나지만, `SocialObjectGraph.groups` 는 **리스트**라
Generator 가 형제를 내놓는 순간 발생한다 — 그리고 `social-recording.types.ts:115-122` 는
형제 케이스를 명시적으로 예상하고 있다.

---

### H-003. `tokens` **문서 존재**를 마커로 쓰는데 그 컬렉션은 다목적이다

**위치** — `src/modules/social-recording/social-record.processor.ts:109-114`

```ts
const already = await this.tokenRepository.findByAddress(command.tokenAddress);
if (already) return;
```

`Token` 은 소셜 기록만 담지 않는다. `web`(웹 지문 블록, `token.model.ts:147-153`) 과
`fingerprints` 도 같은 문서다. 웹 수집이나 지문 기록 주체가 먼저 `upsertByAddress` 를
부르는 순간 문서가 생기고, **소셜 기록은 그 토큰에 대해 영구히 실행되지 않는다.**

지금 그런 호출자가 없어서 안 터질 뿐이고, 스키마는 이미 그 자리를 비워 두고 있다
(v5 T-M3 — 웹 작업에서 채운다).

"처리 완료" 를 **문서 존재**가 아니라 **소셜 기록이 실제로 남긴 무엇**으로 판정해야 한다.

> **2026-08-07 보류.** 고치는 방법은 확정돼 있다 — `tokens.social_recorded_at: Date` 를 두고
> `if (already?.social_recorded_at) return` 으로 판정한다. 착수하지 않는 이유는 **웹·지문의
> 저장 방향이 아직 안 정해졌기 때문**이다(v5 T-M3 미결). 그 방향이 `tokens` 안에 남을지
> 밖으로 나갈지에 따라 이 결함이 존재 자체를 안 할 수도 있다.
>
> ⚠️ ~~**H-015 가 이 항목에 묶여 있다.**~~ → **2026-08-10 묶임 해제.** reconcile-token 이
> `recordSocialUrlResult`(arrayFilters 부분 갱신)로 H-015 를 먼저 닫았다. 그 답이 여기 적어 둔
> "소셜이 소유한 필드만 `$set` 하는 전용 메서드" 와 사실상 같은 형태다.
>
> **H-003 자체는 보류 유지다 (2026-08-10 재확인).** reconcile-token 은 이 판정을 **고치지 않고
> 우회했다** — 재시도가 `recordOneUrl` 이라는 URL 단위 진입점으로 들어가 `findByAddress` 를
> 아예 부르지 않는다. 그 선택의 근거는 여기 오는 토큰이 **정의상 이미 `tokens` 행을 가진**
> 토큰이라는 것이다. 문서의 존재는 "이 토큰을 봤다" 이지 "이 URL 이 수렴했다" 가 아니므로,
> 멱등 판정을 태우면 재시도가 통째로 조기 종료한다.
>
> 그래서 "문서가 있어도 진행" 경로는 **여전히 생기지 않았고**, 이 결함이 드러날 조건도
> 그대로 없다. 웹·지문의 거처가 정해지기 전에는 고칠 대상이 존재할지 자체가 미정이다.
>
> 착수 조건: 웹 수집 작업에서 `tokens.web` 의 거처가 확정되는 시점.

---

### H-004. `writer.write()` 가 URL 단위 격리 밖에 있다

**위치** — `social-record.processor.ts:203-228`

```ts
try {
  result = await generator.generate(...);
} catch (error) {
  // 소스 단위 격리 — 한 URL 의 실패가 나머지를 막지 않는다.   ← 주석의 주장
  ...
  return this.attempted(...);
}

if (result.graph) {
  await this.writer.write(result.graph, this.linkContext(routed, context));  // ← try 밖
}
```

try/catch 가 `generate()` 만 감싼다. `write()` 에서 던지면 — Mongo 일시 오류, `accounts`
unique E11000(동시 생성), `metric_series` uniq upsert 경합(H-016) — 예외가 `collectOne` →
`collect` → `record` 를 그대로 뚫는다.

**결과 셋**

1. **남은 URL 은 시도조차 되지 않는다.** URL 1/4 에서 터지면 2·3·4 는 실행되지 않는다.
2. `tokens` 마커도 안 써진다 → 토큰 전체가 미기록.
3. **앞서 성공한 URL 의 행·링크는 이미 커밋돼 있다.** 다음 flow 이벤트가 통째로 재처리하면
   `token_links` 가 그만큼 중복 append 된다(H-013).

line 211 의 주석이 실제 코드보다 넓게 주장하고 있다는 것 자체가 이 결함의 신호다.

> **2026-08-07 완료.** `try` 를 `write()` 와 반환문까지 덮도록 넓혔다. 저장이 던져도 그 URL 만
> `ERROR` 로 끝나고 남은 URL 은 계속 돌며 마커도 남는다.
>
> ⚠️ **가드 하나가 필수다.** `result.graph` 는 `status` 가 `ok` 가 아니면 `null` 이다
> (`GenerateResult` 계약). `if (result.graph)` 없이 `write()` 를 부르면 `not_found` ·
> `skipped_paid` 가 `plan()` 에서 터지고, **넓힌 `catch` 가 그것을 잡아 `ERROR` + `attempts: 1`
> 로 기록한다** — "소셜이 대상을 안 줬다" 가 "우리가 실패했다" 로 바뀌고 부르지도 않은 호출이
> 세어진다. `tsconfig` 의 `strictNullChecks: false` 라 **컴파일은 통과한다.** 작업 중 실제로
> 이 상태가 됐고 기존 테스트 1건이 잡았다.
>
> ⚠️ **남은 대가 — `ERROR` 하나에 세 가지가 뭉친다.** 외부 호출 실패 · DB 일시 오류 ·
> Generator 계약 위반(`plan()` 의 throw). 앞 둘은 재시도로 결과가 바뀌지만 마지막은 100% 같은
> 실패라, 재수집 배치가 영영 안 열릴 URL 을 계속 두드리게 된다. `TARGET_BLOCKED` 를 `ERROR`
> 에서 가른 것과 같은 종류의 문제다.
>
> 지금 미루는 근거는 **대가가 아직 발생하지 않는다**는 것이다 — 그 배치가 없고(H-009)
> Generator 도 0개다(H-010). **H-009 를 만들 때 계약 위반을 별도 어휘로 가르는 것을 함께
> 판단한다.** 이 한계는 `collectOne` 주석에도 남겼다.

---

### H-005. 락 TTL 만료 방어가 없다

**위치** — `social-record.processor.ts:85-106` · `src/nest-core/config/namespaced-configs/social-record.config.ts:12`

`tryLock(key, 120_000)` 이후 `extend()` 호출이 없다. 설정 주석이 요구하는 것은
"토큰 1건의 전체 수집(업스트림 1회 + 소셜 최대 8회 **순차**)보다 길어야 한다" 인데,
그 요구를 **강제하는 코드가 없다.** 넘으면 락이 먼저 풀린다.

그 시점엔 마커가 아직 없으므로(순서상 맨 마지막) **두 번째 워커가 그대로 진입**한다.

- `contents` · `venues` 는 `platform_key` 인덱스가 unique 가 **아니다**
  (`content.model.ts:60-64`) → 같은 콘텐츠가 **두 행**
- `accounts` 는 unique 라 E11000 → H-004 경로로 토큰 전체가 죽는다

**부수 효과** — TTL 이 지난 뒤 `finally` 의 `acquired.release()` 가 `ExecutionError` 를 던지면,
저장은 다 성공했는데 `record()` 가 실패로 보고된다. 핸들러가 거짓 실패 로그를 남긴다.

> **2026-08-13 완료 — 이 항목을 겨냥한 작업은 없었고, 근거 넷이 각각 다른 이유로 없어졌다.**
>
> | 위 본문의 주장 | 지금 |
> |---|---|
> | `tryLock` 뒤 `extend()` 가 없다 | `src/modules` 전체에 `tryLock` 이 **0곳**이다. 토큰·URL 락은 url-lock 작업에서, 배치 락은 2026-08-13 에 `tryUsingExtended` 로 넘어갔다 |
> | `contents`·`venues` 가 unique 가 아니라 두 행이 된다 | `venues` 는 원래 unique 였고 `contents` 는 `uniq_contents_platform_key` 로 승격했다 |
> | `accounts` 는 E11000 → 토큰 전체가 죽는다 | `findOrCreateByPlatformKey` 가 재조회로 접는다 |
> | `release()` 가 던져 성공이 실패로 보고된다 | 해제가 SDK 안으로 들어갔고 `runUsing` 이 «일 끝나고 락 잃음» 을 별도로 가른다. 서비스에 `releaseQuietly` 가 없다 |
>
> ⚠️ **그래도 «락을 잃은 채 계속 일한다» 가 0이 된 것은 아니다.** 자동연장은 연장 실패를
> 예외로 드러낼 뿐이고, 그 사이 두 워커가 겹칠 창은 남는다. 달라진 것은 **그 창에서 벌어지는
> 일이 조용한 중복이 아니라 unique 충돌 → 재조회로 흡수된다**는 점이다.

---

### H-006. `tokenInfo` 없음 분기만 마커를 남기지 않는다

**위치** — `social-record.processor.ts:116-122`

```ts
const tokenInfo = await this.solTrackerApiSdk.getTokenByAddress(...);
if (!tokenInfo) return;          // ← 마커 없음
```

바로 아래 line 127 의 주석은 정반대를 말한다.

> 소셜이 하나도 없어도 마커는 남긴다(§4 D-1) — 남기지 않으면 그 토큰의 flow 이벤트가
> 올 때마다 업스트림 조회가 반복된다.

그 근거가 **100% 똑같이** 적용되는 분기에서만 빠져 있다. 업스트림에 없는 주소는
flow 이벤트가 올 때마다 매번 SDK 를 호출한다.

> **2026-08-07 — 현행 유지로 확정. 결함이 아니다.**
>
> 업스트림에 없는 주소는 **다음 이벤트에서 다시 시도한다.** 마커를 남기면 그 토큰은 영영
> 다시 보지 않는데, `token.flow` 가 왔다는 것은 온체인 거래가 있다는 뜻이고 tracker 가
> 아직 인덱싱하지 않았을 뿐일 수 있다. **소셜이 0개인 경우와는 성질이 다르다** — 그쪽은
> 업스트림이 "이 토큰에 소셜이 없다" 고 답한 것이라 다시 물어도 같은 답이 온다.
>
> 대가는 그 주소에 대한 업스트림 호출이 flow 이벤트마다 반복되는 것이고, 그 비용을
> 받아들인다. 재조회가 결과를 바꿀 수 있는 유일한 분기라 자동 재시도가 그 자체로 값이다.
>
> ⚠️ 이 판단이 코드에는 적혀 있지 않다. 바로 아래 §4 D-1 주석이 정반대("마커는 남긴다")를
> 말하고 있어, 다음에 읽는 사람이 **일관성 결여로 오해하고 고칠 수 있다.**
> 착수할 일이 생기면 `!tokenInfo` 분기에 이 근거를 한 줄로 남긴다.

---

### H-007. `metrics_latest` 와 `metric_series` 가 갈릴 수 있다

**위치** — `social-graph.writer.ts:273-305` (`resolveContent`) · `376-395` (`recordFirstMetrics`)

`recordFirstMetrics` 주석은 이렇게 단언한다.

> `contents.metrics_latest` 는 여기서 안 건드린다. … 기존 행은 갱신하지 않는다는 규칙이
> 그 필드에도 그대로 적용된다 — **같은 입력값을 쓰므로 두 곳이 갈릴 수 없다.**

**갈린다.**

1. 토큰 A 가 콘텐츠 X 를 **지표 없이** 관측 → `metrics_latest` 없음 · `metric_series` 행 없음
   (`recordFirstMetrics` 가 `if (!metrics) return`)
2. 토큰 B 가 같은 X 를 **지표와 함께** 관측 → `findContent` 가 기존 행을 찾아 반환하므로
   `metrics_latest` 는 **여전히 비어 있고**, `recordFirstPoint` 는 행이 없으니
   `$setOnInsert` 로 **점을 넣는다**

→ 시계열에는 값이 있는데 파생 캐시는 비어 있다. "1차 필터 전용" 인 `metrics_latest` 로
거르면 그 콘텐츠가 조용히 빠진다.

**관련** — R-009 가 "지표는 최초 1회만" 을 확정할 때 두 필드의 **최초 시점이 다를 수 있다**는
경우를 놓쳤다.

> **2026-08-13 — H-008 과의 묶임이 풀렸고, 그날 닫혔다.** ✅
>
> 갱신 정책이 «재관측은 아무것도 안 덮는다» 로 확정되면서 «덮기로 하면 같이 풀린다» 는
> 기대가 사라졌다. 이 결함의 원인은 «덮느냐» 가 아니라 **두 필드의 최초 시점이 다를 수
> 있다** 는 것이라, 안 덮는 규칙을 확정할수록 한 번 어긋난 값이 영구히 남는다.
>
> **고친 자리는 Writer 가 아니라 Generator 다.** 두 «최초» 를 사후에 맞추는 대신 그것들이
> 갈리는 **전제 조건**을 없앴다 — `requireContentMetrics` 가 «지표를 주기로 한 콘텐츠인데
> 비었다» 를 `SocialContractError` 로 끊으므로, 같은 콘텐츠의 지표 유무가 항상 같아지고
> 두 «최초» 가 원리적으로 같은 순간이 된다.
>
> 덤으로 **«지표를 받았다» 와 «빈 지표를 받았다» 의 구분**이 생겼다. Generator 들이
> `metrics: { viewCount: tweet.viewCount, … }` 로 객체를 항상 만들어서 안쪽이 전부
> `undefined` 여도 truthy 였고, 그대로 빈 `metrics_latest` 와 빈 시계열 점이 저장되고 있었다.
>
> ⚠️ **판정 단위는 «플랫폼» 이 아니라 «콘텐츠 종류» 다.** X 의 `SEARCH`·`INTENT` 콘텐츠와
> 웹페이지 콘텐츠는 지표가 **존재할 수 없어서** 가드를 부르지 않는다. 그래서 전역 규칙이
> 아니라 각 Generator 가 고르는 도구로 만들었다.

---

## 🟡 합의된 결정이지만 "기록 안 됨" 이 실제로 발생하는 지점

### H-008. 재관측 객체의 모든 필드가 버려진다

**위치** — `social-graph.writer.ts:286-296` · `311-328` · `331-353` (MEMO 주석 319 · 340)

기존 행을 찾으면 **그대로 반환**한다. 그래서 아래가 전부 첫 관측 값에서 얼어붙는다.

| 컬렉션 | 얼어붙는 값 |
|---|---|
| `accounts` | `metrics.followers` · `handles[]` · `bio` · `outbound_urls` · `unavailable` · `display_name` |
| `venues` | `metrics.members` · `name` · `description` · `data.moderators` |
| `contents` | `metrics_latest` · `text` · `outbound_urls` · `tags` · `mentions` |

**첫 관측이 fan-out 경로일 때가 특히 문제다.** 인용 원본의 author 는 응답에 실린 축약
정보뿐인데, 그 빈약한 행이 확정되고 나면 나중에 그 계정을 **직접 fetch 해도 갱신되지
않는다.** 어느 토큰이 먼저 오느냐가 데이터 품질을 결정한다.

`Account.addHandleObservation` 은 누적 전용으로 설계돼 있으나 **생성 시 1회만 불린다**
(모델 주석 200-201 이 이미 인정).

> **2026-08-13 확정 — 이것은 결함이 아니라 최종 동작이다. 고칠 대상이 옮겨졌다.**
>
> v5 §7 이 «재관측 시 무엇을 덮을 것인가» 를 미결로 남겼고, 그 답이 **"아무것도 안 덮는다"**
> 로 정해졌다. `handles`·`outbound_urls`·`bio`·`subtype` 이 전부 **지표와 같은 처리**를
> 받는다 — 재관측 경로는 손대지 않고 **주기성 갱신 프로세스**가 맡는다(R-009 의 그 프로세스와
> 같은 것이다).
>
> 근거는 R-009 와 같다. 발견될 때마다 찍으면 «누군가 이 대상을 건 시점» 에만 찍힌 **불규칙
> 표본**이 되는데, 그건 지표만의 성질이 아니다 — 재관측으로 쌓은 `handles[]` 는 "언제
> 바뀌었나" 가 아니라 "언제 누가 이 URL 을 걸었나" 를 기록하게 되고, 그것으로 핸들 재할당
> 시점을 추정하면 틀린 답이 조용히 나온다.
>
> **위 표는 그대로 유효하다.** 다만 «버려진다» 가 아니라 **«주기성 프로세스가 생길 때까지
> 얼어붙어 있다»** 로 읽는다. 첫 관측이 fan-out 경로일 때의 문제도 그대로 남는다 — 그것을
> 아는 상태로 고른 것이고, 통제하지 않는 시점의 갱신은 정보성이 떨어진다는 판단이 앞선다.
>
> 그래서 이 항목의 남은 일은 **주기성 갱신 프로세스 하나**이며, 「재관측 시 덮을지」를 정하는
> 별도 결정(구 A-M1-a)은 사라졌다.

> **2026-08-07 추가 — `accounts.unavailable` 은 현재 채우는 경로가 아예 없다.**
>
> 그 필드는 "살아있는 계정만" 이라는 실제 쿼리 축이라 `data` 가 아니라 최상위에 있다.
> 그런데 두 경로가 다 막혀 있다.
> - **처음부터 정지된 계정** — `platform_key` 를 만들 수 없어 **행 자체를 안 만든다**(G-3 · X-3).
>   X 의 `XUnavailableProfile` 에 `id` 가 없는 것이 그 예다
> - **행이 있는 계정이 나중에 정지** — 이 항목(H-008) 때문에 **재관측이 반영되지 않는다**
>
> 결과적으로 정지 신호는 `tokens.social_urls[].status` 에만 남고, "어느 계정이 정지됐나" 를
> 알려면 그 URL 을 다시 `route()` 해서 역추적해야 한다.
>
> audit 이 이 신호를 **actionable**(*"런칭 후 계정 정지 = 팀 이탈 강신호"*)로 판정했으므로,
> **그 신호를 실제로 쓰려면 이 항목이 먼저 풀려야 한다.** `FetchStatus.SUSPENDED` 를 추가하는
> 것(G-5)만으로는 URL 단위 상태가 하나 늘 뿐, 계정 쪽에서는 여전히 알 수 없다.

---

### H-009. 한 번 실패한 URL 의 재시도 경로가 지금은 없다 ✅ 완료 (2026-08-10)

**위치** — `social-record.processor.ts:112-114` + `collectOne` 전체

`error` · `blocked` · `skipped_paid` · `unsupported` 로 끝나도 마커는 **똑같이** 쓰인다.
그리고 마커가 있으면 `already` 로 영구 스킵된다.

크론이 그 자리를 맡기로 했지만(R-002 에서 "크론 자체는 별도 feature" 로 분리) 미구현이라,
**현재 코드만 놓고 보면 일시 실패 = 영구 미기록**이다. `enablePaid=false` 기간의 유료
소셜도 같다 — config 주석이 이미 "그 기간에 들어온 토큰의 해당 소셜 데이터는 **영구히
비어 있다**" 로 인정하고 있다.

> **✅ 해소 (2026-08-10, reconcile-token).** `POST /internal/social-recording/retry` 가 그 경로다.
> 마커가 있어도 `recordOneUrl` 이 **URL 단위로** 들어가므로 `already` 스킵에 걸리지 않는다.
>
> **원인의 절반은 어휘였다.** "재시도 경로가 없다" 보다 나빴던 것은 `error` 하나에 세 가지가
> 뭉쳐 있던 것이다 — 외부 호출 실패 · 일시적 DB 오류 · **Generator 계약 위반**. 앞 둘은 재시도로
> 결과가 바뀌지만 마지막은 **100% 같은 실패**라, 경로를 만들어 놓으면 영영 안 열릴 URL 을
> `maxAttempts` 만큼 두드리게 된다(유료 플랫폼에서는 그대로 돈이다). 그래서 이 작업은 경로만
> 만든 것이 아니라 **`invalid` 을 신설해 계약 위반을 먼저 갈랐다**. `plan()` 이 이제
> `SocialContractError` 를 던지고, `collectOne` 의 catch 가 그것을 `ExternalFetchError` **보다 먼저**
> 판별한다 — 순서가 뒤집히면 계약 위반이 다시 `error` 로 주저앉는다.
>
> `exhausted` 도 같이 생겼다. 상한 도달분이 `error` 로 남으면 재시도 배치의 **유일한 조회축**인
> `idx_tokens_social_url_status` 가 안 열릴 행을 계속 돌려주기 때문이다.
>
> ⚠️ **대가**: 둘 다 흡수 상태라 자동 복구가 없다. Generator 버그를 고쳐도 과거 `invalid` 는
> 그대로이고, 탈출구는 사람이 `/retry statuses=[invalid]` 를 부르는 것뿐이다 — 런북 항목이다.
>
> 상세: [reconcile-token 설계 §1.2 · §3.3](../reconcile-token/be-system-design.md)

> **이 항목이 착수될 때 함께 결정해야 할 것**(H-004 에서 넘어옴) — `ERROR` 하나에
> "외부 호출 실패 · DB 일시 오류 · Generator 계약 위반" 셋이 뭉쳐 있다. 앞 둘은 재시도로
> 결과가 바뀌지만 계약 위반은 **100% 같은 실패**다. 배치가 그 셋을 같은 값으로 읽으면
> 영영 안 열릴 URL 을 계속 두드린다. 어휘를 가를지, 배치 쪽에서 시도 횟수로 자를지를
> 이 작업에서 정한다.

---

### H-010. Generator 가 0개라 현재 전 URL 이 `unsupported`

**위치** — `src/modules/social-recording/social-recording.module.ts:41-43`

```ts
provide: SocialGeneratorRouter,
useFactory: () => new SocialGeneratorRouter([] as SocialGenerator[]),
```

쓰기 경로 전체가 아직 **한 번도 실행된 적이 없다.** H-001 · H-002 · H-004 · H-007 은
Generator 가 붙는 순간 동시에 드러난다. 이 항목 자체는 "결함" 이 아니라 **위 항목들의
발현 시점**을 알려주는 표지다.

---

### H-011. 같은 대상을 가리키는 두 URL 이 dedup 되지 않는다

**위치** — `social-record.processor.ts:157-181` (`routeSocialUrls`)

dedup 축이 **정규화 URL 문자열**이다. `x.com/a/status/1` 과 `x.com/a/status/1?s=20` 은
다른 문자열이다 — `s` 는 `url-normalizer.ts:52-58` 이 **의도적으로** denylist 에서 뺐다.

→ **fetch 2회(비용) + `token_links` 2세트.**

행은 `platform_key` 로 하나에 수렴하므로 마스터 데이터는 안 깨진다. 남는 것은 비용과
링크 중복이다.

**관련** — R-001 이 다루려던 지점. `token_links.entry_url` 로 판정하는 안이 이미 정리돼
있고, 인덱스 `{entry_url: 1}` 추가와 착수 시점만 남아 있다.

---

### H-012. 정규화 실패 URL 은 흔적이 아예 남지 않는다

**위치** — `social-record.processor.ts:167-171`

```ts
const source = this.urlRouter.route(rawUrl);
if (!source) {
  this.logger.warn(`URL 정규화 실패 — 건너뛴다 [${field}]: ${rawUrl}`);
  continue;                    // ← social_urls[] 에 항목이 안 생긴다
}
```

"이 토큰이 이 값을 걸었다" 는 사실이 DB 어디에도 없다. v5 규칙 ②(정규화된 값만 저장)로
합의된 대가지만, **나중에 정규화 규칙을 고쳐도 대상 집합을 복원할 수 없다**는 뜻이다.
`"none"` · `"TBA"` 같은 쓰레기와 우리 파서가 못 읽은 정상 URL 이 로그에서만 구분된다.

---

### H-013. `token_links` 재시도 부산물과 재관측 이력이 구분되지 않는다 ✅ 완료 (2026-08-10)

**위치** — `src/modules/social-graph/token/token-link.repository.ts:31-37`

append-only + 유니크 없음은 의도다(Phase 1.6 결정). 문제는 **H-004 · H-005 경로로 재처리가
일어났을 때** 같은 `(token_address, object_id, entry_url)` 이 쌓이는데, 그게
"시차를 두고 다시 관측했다" 인지 "실패해서 재시도한 부산물" 인지 **읽는 쪽에 구분 근거가
없다**는 것이다. 리포지토리 주석이 말하는 "그 간격이 곧 재관측 이력이다" 가 성립하지 않는다.

> **✅ 해소 (2026-08-10, reconcile-token).** `SocialGraphWriter.write()` 가 `appendMany` **앞에**
> `TokenLinkRepository.deleteByTokenAndEntryUrl(tokenAddress, entryUrl)` 를 넣는다. 그 진입 URL 이
> 만든 기존 링크를 지우고 다시 넣는 **교체**다.
>
> **유니크 인덱스를 쓰지 않은 것이 핵심이다.** `(token_address, object_id, entry_url)` 유니크는
> 중복을 막는 대신 **재관측 이력을 구조적으로 없앤다** — schema-v5 Phase 1.6 이 반대로 결정한
> 사항이라 뒤집을 수 없었다. 그래서 방어를 인덱스가 아니라 **쓰기 경로**에 뒀다.
>
> **지워지는 것이 잔해뿐인 근거는 `ok` 가 흡수 상태라는 것이다** — 이미 성공한 URL 은 재시도
> 후보에 다시 들어오지 않으므로 이 경로를 두 번 밟지 않는다. 즉 삭제 대상은 항상 "실패한
> 시도가 남긴 것" 이다.
>
> 보상이 롤백이 아니라 **교체**라 실패 시점에 되돌리는 단계가 없고, 따라서 보상 자체가 두 번째
> 실패 지점이 되지 않는다. `entry_url` 인덱스는 추가하지 않았다 — 이 술어는
> `idx_links_token_linked (token_address, created_at)` 의 접두를 탄다.
>
> ⚠️ **남은 틈**: `invalid` 에는 재시도가 없다. 흡수 상태라 후보가 아니므로, 계약 위반 직전까지
> 커밋된 행·지표는 남고 링크는 0건인 채로 굳는다. 복구는 수동 `/retry statuses=[invalid]` 다.
>
> 상세: [reconcile-token 설계 §1.5 · §6.4](../reconcile-token/be-system-design.md)

---

## 🔵 문서–코드 드리프트 · 저확률 경합

### H-014. `write()` 주석의 쓰기 순서가 실제와 다르다

**위치** — `social-graph.writer.ts:58-61`

주석: **행 → 원본 → 지표 → 링크**
실제: **행 + 지표(그룹마다 교차) → 원본 → 링크**

지표는 `resolveContent` 안에서 행 생성 직후에 쓰인다. 이 주석은 "부분 실패를 만났을 때
어디서 끊겼는지가 이 메서드를 위에서 아래로 읽는 것만으로 나온다" 의 근거로 인용되고
있어서, 어긋나면 그 근거가 무너진다.

> **2026-08-07 완료.** 순서를 코드 블록으로 바꿔 적고, **지표가 원본보다 먼저인 것이
> 의도**라는 근거를 붙였다 — "객체 하나를 확정하면 그 자리에서 그 객체 몫을 끝낸다"(R-009).
> 근거 없이 순서만 고치면 다음 사람이 "주석 순서에 맞추자" 며 코드를 옮긴다.
>
> 왜 5분짜리인데 대장에 올렸는지도 함께 남겼다. `metric_series` 는 있는데 `object_raw` 가
> 없는 상태를 만났을 때, 옛 주석대로면 "원본은 됐는데 지표에서 죽었다" 로 읽어야 하지만
> 실제로는 **정반대**다. 트랜잭션이 없어 순서가 유일한 도구인데 그 목록이 틀리면
> **역추적이 정확히 거꾸로 틀린다.**

### H-015. `Token` upsert 의 `$set` 이 `fingerprints` 를 덮는다 ✅ 완료 (2026-08-10)

**위치** — `src/modules/social-graph/token/token.repository.ts:31-46`

리포지토리 주석이 이미 인정하고 있다 — `Token.of()` 가 기본값 `[]` 를 채우므로
`$set: {...token}` 이 기존 지문을 지운다. 지문을 채우는 주체가 생기는 순간 H-003 과 함께
터진다. `$addToSet` 전환이 필요하다.

> **✅ 해소 (2026-08-10, reconcile-token) — 단, 예상과 다른 방식이다.**
>
> `upsertByAddress` 는 **그대로 두었다.** 대신 재시도 경로가 그것을 쓰지 않는다 —
> `TokenRepository.recordSocialUrlResult(address, url, status, attemptedAt)` 가 `arrayFilters` 로
> **`social_urls[]` 원소 하나만** 갱신한다. 쓰기 경로가 전부 `social_urls.$[target].*` 접두를
> 벗어나지 않으므로 `fingerprints`·`web` 과 나머지 URL 들이 손대지지 않는다.
>
> **부분 갱신은 최적화가 아니라 전제였다.** 재시도는 URL 하나를 여는 연산인데 문서를 통째로
> 덮으면 나머지 URL 의 `status`·`attempts` 까지 날아가, 그 배치가 자기 진행 상태를 스스로 지운다.
>
> **H-003 과의 묶임이 풀렸다.** 원래 근거는 "H-003 을 고쳐 '문서가 있어도 진행' 경로가 생기는
> 순간 함께 터진다" 였는데, 재시도가 H-003 판정을 **우회**해(`recordOneUrl` 이 `findByAddress` 를
> 부르지 않는다) 그 경로 없이 도달했다. H-003 은 보류 그대로다.
>
> ⚠️ **`upsertByAddress` 의 통째 `$set` 은 실시간 경로에 그대로 남아 있다.** 지금은 최초 1회만
> 쓰이므로 드러나지 않지만, **지문을 채우는 주체가 생기면 그때는 여전히 `$addToSet` 전환이 필요하다.**
> 이 항목이 닫힌 것은 "재시도가 덮지 않는다" 까지다.
>
> 상세: [reconcile-token 설계 §6.2](../reconcile-token/be-system-design.md)

### H-016. `recordFirstPoint` 는 unique upsert 인데 재시도가 없다

**위치** — `src/modules/social-graph/observations/metric-series.repository.ts:46-57`

`uniq_series_content` 위의 upsert 라 동시 실행 시 E11000 이 가능하다(MongoDB upsert 의
알려진 경합). 재시도가 없어 그대로 던지고 → H-004 경로로 토큰 전체가 죽는다.
확률은 낮다(H-005 로 락이 풀렸거나 서로 다른 두 토큰이 같은 콘텐츠를 동시에 볼 때).

### H-017. 핸들러 예외를 `dispatch` 가 삼키고 ack 한다

**위치** — `src/nest-core/rabbit-mq/base/abstract-event-manager.ts:161-174`

설계 §6.5 F-4 에서 인정한 동작이다. 회복 수단은 "같은 토큰의 다음 flow 이벤트" 하나뿐이고
**거래가 끊긴 토큰은 복구되지 않는다.** base 클래스 변경이라 kline 구독에도 영향을 준다 —
이 문서의 다른 항목과 달리 social-recording 밖의 결정이다.

---

---

## 구조 확정 후 재점검 (2026-08-07)

큰 틀이 확정된 시점에 전체 경로를 다시 훑어 나온 것들이다. **둘 다 이번 세션의 변경이 만든
새 결함**이라, 위 H-001~H-017 과 성격이 다르다 — 원래 있던 게 아니라 **고치면서 생겼다.**

### H-018 🔴 빈 `groups` 가 검증을 통과한다 ✅ 완료

**원인 — `object_raw` 제거(`06e85b0`)의 회귀.**

```ts
// 지워진 코드
if (!root) throw new Error(`root 를 확정하지 못했다: ${graph.rootRef}`);
```

그 `!root` 검사가 빈 `groups` 를 잡던 **유일한 자리**였다. `object_raw` 를 쓰기 위해 `root` 를
추적했으므로 함께 지웠는데, 부수적으로 하던 검증까지 사라졌다.

`plan()` 의 마지막 길이 비교는 `0 === 0` 이라 통과한다. 그래서 Generator 가
`{ status: OK, graph: { groups: [], rootRef: 'x' } }` 를 돌려주면 **링크 0건인데 `ok` 로
기록된다** — 루브릭 R-13 이 경고하는 실패 모드 그대로다.

`insertMany([])` 자체는 안전하다(mongoose 8 이 `docAttributes.length === 0` 에서 조기 반환).
**크래시가 아니라 조용한 무해동작이라 더 나쁘다.**

> **교훈** — 어떤 코드가 **부수적으로** 하던 검증은, 그 코드를 지울 때 함께 사라진다.
> `root` 는 `object_raw` 의 재료였지만 `!root` 는 그래프 검증이었다. 두 역할이 한 변수에
> 얹혀 있었고, 지울 때 그것을 구분하지 못했다.

**수정** — `plan()` 맨 앞에 `if (!graph.groups.length) throw`. 검증을 본래 자리로 옮겼다.

### H-019 🔴 호출 0회 성공 경로에서 `attempts` 가 과대계상된다 ✅ 완료

**원인 — H-004(저장을 격리 안으로)의 부작용.**

```ts
/** 외부 호출을 실제로 한 URL 의 결과. 성공이든 실패든 `attempts` 는 1 이다. */
private attempted(...) { return { ..., attempts: 1 }; }
```

주석의 전제가 H-004 로 깨졌다. `try` 가 `write()` 를 덮으면서 **외부를 부르지 않고 성공한 뒤
저장에서 터지는** 경로가 처음 생겼다.

```
generate() → { status: OK, attempted: false, graph }   ← 호출 0회 (검색·intent)
write()    → throw
catch      → attempted() → attempts: 1                 ← 부르지도 않았는데
```

**영향 범위가 작지 않다.** G-4 로 확정된 X 의 URL 6종 중 **3종**(`x_tweet_search` ·
`x_intent` · `x_trend`)이 `attempted: false` 로 `OK` 를 낸다.

아이러니는 **루브릭 R-14 가 경고한 것을 Processor 자신이 하고 있었다**는 것이다 —
*"부르지 않은 것과 불러서 실패한 것이 같은 횟수로 세어지면 비용·백오프 계산이 조용히 틀린다."*

**수정** — `try` 밖에 `let attempted = false` 를 두고 `generate()` 직후에 채운다. `catch` 는
그 값을 읽되, `ExternalFetchError` 가 왔으면(`code !== null`) 외부를 부른 뒤이므로 `true` 로
보정한다. `attempted()` 를 `outcome(routed, status, attempted, context)` 로 바꿔 **추정하지 않고
받아 적게** 했다.

---

## 조사 범위 밖 (명시적 제외)

- **Generator 내부** — 완전하다고 가정했다. `plan()` 의 검증 예외 4종(중복 ref · 빈 그룹 ·
  `rootRef` 미해결 · 순환)은 전부 Generator 버그라 여기서 다루지 않는다.
- **통합 테스트 15건 실패** — v5 인덱스 표 불일치. 로컬 DB 이슈로 별도 처리.
- **`outbound_urls` 정규화** — Generator 책임이라 계약 주석
  (`social-generator.interface.ts:67-69`)으로만 존재하고 강제하는 코드가 없다.
  Generator 를 완전하다고 가정한 이 조사의 전제상 제외했으나, 8개 Generator 가 각자
  기억해야 한다는 점은 착수 시 재검토 대상이다.
