# Social URL Entry — 미룬 것

이 작업(`design.md` E-1 ~ E-7)에서 **의도적으로 빼기로 한 항목.** 근거와 «다시 하려면 무엇이
필요한가» 를 함께 남긴다 — 그것이 없으면 다음 사람이 같은 조사를 처음부터 다시 한다.

| ID | 항목 | 상태 |
|---|---|---|
| [B-1](#b-1) | `tokens.first_transfer_at` 을 실제로 채운다 | ✅ 완료 (2026-08-13) — A안 |

---

## B-1

### ~~`tokens.first_transfer_at` 이 스키마에만 있고 아무도 안 채운다~~ ✅ 완료 (2026-08-13)

**실측 (2026-08-12): 토큰 106건 중 값이 있는 것 0건.**

### 이 필드가 무엇인가

```ts
/** 나이 앵커 — as-of 절단축. 불변이라 복사 안 안전(D-4). */
@prop({ type: Schema.Types.Date })
first_transfer_at?: Date;
```

토큰이 얼마나 오래됐는지 재는 기준 시각이다. `FetchOptions.launchAt`(*"age anchor
(= firstTransferAt)"*)이 이 값을 받도록 설계돼 있고, `createdAt` 류 고정 필드와의 차로
«런칭 후 며칠» 을 뽑는 데 쓰인다. **비어 있는 동안 그 계산은 전부 불가능하다.**

`symbol` 과 같은 성격의 반정규화다 — SoT 는 sol-alpha-finder-tracker 이고 우리는 사본을
갖는다. 스키마 주석이 *"불변이라 복사 안전(D-4)"* 이라고 이미 축복해 두었으므로,
**복사해도 되는가는 이미 결론이 나 있다.** 남은 것은 값을 어디서 얻느냐다.

### 왜 이번에 안 했나 — 창구가 부르는 API 가 이 값을 안 준다

업스트림 응답 두 개를 실제로 확인했다.

| 원본 응답 | 엔드포인트 | `firstTransferAt` |
|---|---|---|
| `RawTrackedToken` | `GET /tokens` (기간 범위) | ✅ **있다** — SDK 가 `Date` 로 변환까지 해 둔다 |
| `RawTokenInfo` | `GET /tokens/by-addresses` | ❌ **없다** |

`RawTokenInfo` 는 18필드를 전부 선언해 두었는데 그중에 없다. **우리 매핑 누락이 아니라
응답에 없는 것이다.** `createdOn`·`poolCreatedAt` 이 있지만 뜻이 다르다(토큰 생성 · 풀 생성
≠ 첫 전송).

E-1 창구는 **주소로 부르는 경로**(`getTokenByAddress`)에 달린다. 그래서 창구는 구조적으로
이 값을 손에 넣을 수 없다.

### 경로마다 사정이 다르다

| 진입점 | 값을 갖고 있나 |
|---|---|
| **backfill** (`reconcileByRange` → `TrackedToken`) | ✅ **지금도 손에 있고 버리고 있다** |
| 실시간 (`token-social-record.handler`) | ❌ 주소만 안다 |
| 수동 (`social-record-test.controller`) | ❌ 주소만 안다 |

### 다시 할 때의 선택지

| | 방식 | 대가 |
|---|---|---|
| **A** | `record()` 가 선택적 `firstTransferAt` 을 받고 **backfill 만** 넘긴다 | 값이 **경로에 따라 있고 없다.** backfill 로 들어온 토큰만 채워짐 |
| **B** | 창구가 범위 API 를 추가로 부른다 | 토큰마다 업스트림 호출 1회 추가. **E-2(범위 스캔은 창구 대상이 아니다)와 충돌** |
| **C** | 업스트림에 `by-addresses` 응답에도 넣어 달라고 요청 | 우리 손 밖. 되면 가장 깨끗하다 |

**A 가 현실적이다.** 공짜인 곳에서만 채우고 없으면 `null` 이다 — `symbol` 도
`tokenSymbol ?? ''` 로 같은 성격을 이미 감수하고 있다.

### 결론 (2026-08-13) — A안으로 했고, 실시간 경로도 함께 채워졌다

이 문서는 실시간을 «❌ 주소만 안다» 로 적었는데 **그게 틀렸다.** `event.firstFlowTimestamp`
가 **토큰 단위 최초 유입**이라(2026-08-13 확인) 그대로 앵커로 쓸 수 있다. 위 표에서 그 값을
후보로 올리지 않은 것은, 다른 문서들이 그것을 *"그 AF 의 첫 유입"* 으로 적어 둔 것을 그대로
믿었기 때문이다 — 그 세 곳도 함께 고쳤다(`01-database-schema.html` · `be-system-design.md` ·
`service-overview.md`).

| 진입점 | 원천 |
|---|---|
| 실시간(`token-social-record.handler`) | ✅ `event.firstFlowTimestamp` |
| backfill(`reconcileByRange`) | ✅ `TrackedToken.firstTransferAt` (버리고 있던 값) |
| 수동(`social-record-test.controller`) | ❌ 없다 — `null` 로 남는다 |

그래서 대가는 원문이 적은 그대로 남되 **범위가 줄었다** — «값이 있는 토큰과 없는 토큰이
섞인다» 는 이제 backfill 편향이 아니라 **수동 수집 경로 편향**이다. B 안(창구가 범위 API 를
추가 호출)과 C 안(업스트림에 요청)은 채택하지 않았다.

대가는 **«값이 있는 토큰과 없는 토큰이 섞인다»** 이고, 그 상태에서 나이 기반 분석을 하면
표본이 backfill 경유분으로 편향된다. 그것을 감수할지가 실제 결정 지점이다.

### 왜 이번 작업에 끼우지 않았나

축이 다르다. 이번 작업은 **URL 이 들어오는 문을 하나로 만드는 것**이고, 이 항목은
**토큰 메타데이터를 어디서 얼마나 채우는가**다. 창구 시그니처에 `firstTransferAt` 을 넣는
순간 창구가 *"주소 하나 → 프로필 하나"* 라는 단순함을 잃고 두 업스트림을 합치는 자리가 된다.

지금 0건이므로 **빼도 잃는 것이 없다** — 이미 없는 값이다.
(→ 그 판단은 그때로서 맞았고, 2026-08-13 에 실시간 원천이 확인되면서 착수했다.)

---

## 진입점 통합 — `record()` 와 `recordOneUrl()` (url-lock L-13)

`lock-before-execute-recording` 이 L-8 · L-9 를 넣으면서 **두 경로의 차이가 «업스트림을
부르는가» 하나로 줄었다.**

| | `record()` | `recordOneUrl()` |
|---|---|---|
| 대상 URL 선별 | 자기가 한다(`pendingWork`) | 배치가 정해 준다 |
| 업스트림 조회 | 문서가 없을 때만 | 안 한다 |
| URL 락 · 판정 · 쓰기 | `processOne` 하나로 같다 | 같다 |
| 재시도 정책 | `RetryPolicy` 공유 | 공유 |

**합치지 않은 이유는 축이 달라서가 아니라 «그 작업의 주제가 동시성이었기 때문»** 이다.
창구 일원화는 이쪽(E-*)의 주제이므로 여기로 넘긴다.

합칠 때 정할 것: 진입점 하나가 «URL 목록» 을 받게 할 것인가(배치는 1건, 실시간은 N건),
아니면 «토큰 + 선별 전략» 을 받게 할 것인가. 전자가 단순하지만 실시간 경로가 선별 결과를
밖으로 노출하게 된다.
