token_links 정책 · 규칙 R-1 ~ R-18 · 2026-08-07
언제 펴고, 무엇부터 보고, social-generator-rubric.md 와는 어떻게 다른가.
생성기 하나를 쓰기 직전과 다 쓴 직후 두 번 폅니다.
social-generator-rubric.md 입니다이 페이지는 작업 중에 보기 좋게 만든 뷰이고, 판정의 정본은 같은 폴더의 rubric.md 입니다. 규칙이 바뀌면 둘을 같이 고칩니다 — 한쪽만 고치면 다음에 읽는 사람이 어느 쪽이 맞는지 알 방법이 없습니다.
각 규칙이 왜 그렇게 정해졌는지는 decisions.md(G-*)와 recording-holes.md(H-*)에 있습니다. 여기에는 판정 결과만 담습니다.
규칙에는 두 종류의 심각도가 있고, 카드 왼쪽 색이 그것을 나타냅니다.
plan() 이 예외를 던지고 그 URL 전체가 실패합니다. 시끄럽지만 안전합니다.token_links 가 답하는 것 / 답하지 않는 것이 컬렉션에 무엇을 기대하면 안 되고, 그 사실은 어디에 사는가.
이 컬렉션이 답하는 질문은 하나입니다.
그것뿐입니다. 나머지는 전부 다른 곳이 답합니다.
| 묻고 싶은 것 | 답하는 곳 |
|---|---|
| 누가 이 글을 썼나 | contents.creator_id |
| 이 URL 을 마지막에 열어봤을 때 어떻게 됐나 | tokens.social_urls[].status |
| 이 계정이 정지됐나 | accounts.unavailable ⚠️ 채우는 경로가 없다 (§9) |
| 지표가 얼마였나 | metric_series · contents.metrics_latest |
| 이 콘텐츠가 무엇을 인용했나 | contents.parent_content_id |
상태 필드가 없는 것이 의도입니다. 같은 사실이 두 곳에 있으면, 둘이 갈렸을 때 어느 쪽이
맞는지 판단할 근거가 없어집니다. 예전에 token_links.status 가 있었는데 그 값은
accounts.moderation 의 낡은 사본이었습니다.
실무적으로 이것이 뜻하는 바는 하나입니다 — 생성기는 링크를 신경 쓰지 않습니다. 객체를 정확히 만들면 링크는 Writer 가 알아서 만듭니다.
8종을 만들면서 매번 나오는 질문이라 답을 먼저 적습니다.
token_links 행 하나 = platform_key 로 식별된 객체 하나역도 성립합니다. 객체가 없으면 링크도 없고, 그것은 정상 결과입니다.
platform_key 있음 → 객체 행 생성 → token_links 1건 platform_key 없음 → 객체 없음 → token_links 0건
이건 관례가 아니라 스키마가 강제합니다. contents·accounts·venues
의 platform_key 가 전부 required: true 라 없으면 mongoose 가 저장을
거부합니다. 그리고 buildLink() 는 이미 _id 가 확정된 객체만 받으므로,
객체 없이 링크를 만들 경로가 코드에 없습니다.
그래서 기록이 세 단계로 갈립니다.
| 단계 | token_links | tokens.social_urls[] | 언제 |
|---|---|---|---|
| 정상 수집 | ✅ | ✅ ok | 객체를 확정했습니다 |
| 객체 없음 | ❌ | ✅ 그 외 status | 부르지 못했거나, 불렀는데 대상이 없거나, 키를 못 만들었습니다 |
| 완전 소실 | ❌ | ❌ | 정규화에 실패했습니다 — 로그에만 남습니다 (H-012) |
두 번째 단계를 의도적으로 허용합니다. 예전에는 contents(subtype=unknown)
자리행과 token_links(unresolved) 를 만들어 "이 토큰이 이 URL 을 걸었다" 를
표현했는데, 두 가지가 망가졌습니다. 첫째로 "링크가 있다" 가 "수집됐다" 를 뜻하지 않게
됐습니다. 둘째로 그 자리행이 승격되지 않아, 나중에 제대로 수집해도 unknown
인 채로 남았습니다.
graph 가 null 인 status 와 그 이유OK 하나만 graph 를 갖습니다. 나머지는 전부 객체를 만들지 않습니다.
| status | 왜 객체가 없나 | 불렀나 | 재시도로 바뀌나 |
|---|---|---|---|
| UNSUPPORTED | 담당 Generator 가 없거나, 있어도 그 종류를 다루지 않습니다 | ❌ | 우리가 만들면 바뀝니다 |
| SKIPPED_PAID | 유료라 부르지 않았습니다 | ❌ | 설정을 켜면 바뀝니다 |
| SUSPENDED 예정 | 계정이 정지·탈퇴라 platform_key 를 만들 수 없습니다 | ✅ | 정지는 풀립니다 |
| NOT_FOUND | 불렀는데 대상이 없습니다 | ✅ | 거의 안 바뀝니다 |
| BLOCKED | 대상 쪽 사정입니다 — 비공개 · 차단 · 초대 전용 | ✅ | 열릴 수 있습니다 |
| ERROR | 우리 쪽 사정입니다 — 네트워크 · DB · 계약 위반 | ✅ | 바뀔 수 있습니다 |
어휘를 이만큼 쪼갠 이유가 마지막 열입니다. 재수집 배치가 무엇을 다시 두드릴지 판단하는
유일한 근거입니다. 뭉개면 영영 열리지 않을 URL 을 계속 두드리게 됩니다 — 프로세서가
TARGET_BLOCKED 를 ERROR 에서 가르는 것과 같은 근거입니다.
object_raw 컬렉션과 graph.raw 필드가 함께 제거됐습니다.
생성기는 원문을 위로 올리지 않습니다.
명분은 "무손실 원본 — 자르는 순간 재해석 가능성이 사라진다" 였습니다. 그런데 실제로 저장되던 값은 SDK 의 transform 을 이미 거친 결과였습니다. SDK 가 HTTP 응답을 함수 안에서 버리므로 fetcher 는 원문을 본 적이 없습니다.
게다가 그 값의 필드는 거의 전부 ContentInput·AccountInput 으로
가고 있어 중복이었습니다.
원문 보존이 필요해지면 그것을 실제로 볼 수 있는 SDK 층에서 완결시킵니다(G-12).
위로 전파하지 않습니다 — 지금 컬렉션을 남겨 두면 나중에 정체성 축이 바뀌면서
(object_id 는 저장 전에 존재하지 않습니다) 마이그레이션이 생깁니다.
여기가 가장 헷갈립니다. 기준은 "platform_key 를 만들고 계약된 필드를 채울 수
있나" 입니다. 외부를 못 불러도 URL 에서 뽑을 게 있으면 만들고, 부를 수 있어도 껍데기밖에
안 나오면 만들지 않습니다.
| 예 | 외부 호출 | 객체 | 근거 |
|---|---|---|---|
X 검색 (x_tweet_search) | 0회 | ✅ | URL 자체가 정보입니다 — 검색어가 곧 키이자 text |
| X intent | 0회 | ✅ | intent 텍스트가 값입니다 |
| X trending | 보조 1회 (실패 허용) | ✅ | snowflake id 에서 생성 시각이 호출 0회로 나옵니다 |
| X 정지 계정 | 1회 | ❌ | 응답에 id 가 없어 키를 만들 수 없습니다 |
grok · 예약 루트 · unknown | 0회 | ❌ | 키도 못 만들고 채울 필드도 없습니다 |
link_depth객체 몇 개가 링크 몇 건이 되고, 각 링크의 깊이는 어떻게 정해지는가.
링크는 확정된 객체마다 정확히 1건입니다. 객체의 기준은 _id 입니다.
묶음 하나는 최대 네 개의 객체를 냅니다.
묶음 하나가 낼 수 있는 객체 creator ← 이 글을 쓴 사람 venue.creator ← 채널·커뮤니티를 만든 사람 (글쓴이와 다를 수 있다) venue ← 채널·커뮤니티·서브레딧 content ← 글 자체
그런데 같은 _id 가 여러 번 나오면 1건으로 접힙니다. 그런 일이 벌어지는
경우가 둘 있고, 아래에서 직접 켜 볼 수 있습니다.
link_depth 의 정의깊이는 rootRef 묶음에서 인용을 몇 번 타고 그 객체에 닿았는가를 담습니다.
| 값 | 주장하는 사실 |
|---|---|
| 0 | 토큰이 직접 건 URL 의 대상입니다. 토큰 발행자가 자기 손으로 이 주소를 적어 넣었습니다 |
| 1 | 인용을 타고 들어가 발견한 대상입니다. 발행자가 적은 적이 없습니다 |
상한은 LINK_MAX_DEPTH = 2 이고 TokenLink.capDepth() 가 그 위를
잘라 냅니다. 그래서 저장되는 값은 0 아니면 1 둘뿐입니다. 인용이 2단으로 이어져도 가장 깊은
객체가 1 이 됩니다.
깊이는 묶음 단위입니다. 인용 원본의 작성자도 인용을 타고 온 사람이므로 1 입니다. 계정만 0 으로 두면 "이 토큰이 홍보한 계정" 을 물었을 때 제3자가 섞여 나옵니다.
Writer 는 사슬을 깊은 것부터 돌면서 링크를 덮어씁니다. 나중에 오는 쪽이 항상 더 얕으니, 마지막에 남는 값이 곧 최솟값입니다.
예전에는 먼저 만난 것을 채택했는데, 정렬이 깊은 것을 먼저 주므로 결과가 정반대였습니다.
자기 인용이면 작성자 계정이 깊이 1 로 박혀서, 토큰이 직접 건 계정인데도
link_depth: 0 조회에서 빠졌습니다.
어떤 모양이 통과하고, 어떤 모양이 Writer 에서 예외를 맞는가.
먼저 내가 만들려는 모양이 아래에 있는지 찾아보세요. 화살표는 parentRef 이고,
방향은 "내가 인용한 원본" 을 가리킵니다.
모든 묶음이 rootRef 에서 parentRef 를 타고 도달 가능해야 합니다.
가지도 고아도 없습니다.
어기면 plan() 이 던지고 그 URL 전체가 실패합니다.
왜 깊이 0 은 "토큰이 직접 걸었다" 는 강한 주장인데, 도달 경로가 없는 묶음에는 그것을 뒷받침할 근거가 없습니다. 예전에는 그런 묶음에 0 을 줘서 홍보한 적 없는 대상이 홍보 명단에 섞였습니다.
확인 묶음이 N 개면 rootRef 에서
parentRef 를 N-1 번 타서 전부 닿아야 합니다.
한 URL 에서 나온 계정·베뉴·콘텐츠는 묶음 하나의 세 자리에 담습니다. 묶음이 갈리는 것은 인용·리트윗·포크일 때뿐입니다.
어기면 R-1 에 걸려 던집니다. "프로필 + 고정 트윗" 을 두 묶음으로 내려는 순간이 정확히 그 경우입니다.
확인 새 묶음을 만들 때마다 물으세요 — "이건 앞 묶음이 인용한 원본인가?" 아니면 같은 묶음입니다.
parentRef 대상은 콘텐츠를 가진 묶음이어야 한다어기면 '인용 원본이 콘텐츠 있는 묶음이 아니다' 로 던집니다.
허공을 가리킨 경우도 같은 검사에 걸립니다.
왜 아니면 contents.parent_content_id 가 에러 없이 빕니다.
인용은 글이 글을 가리키는 것입니다.
null 인 묶음을 만들지 않는다어기면 '빈 그룹' 으로 던집니다.
객체를 하나도 못 만들겠으면 그 묶음 자체를 내지 않습니다. 그것이 유일한 묶음이면
graph 를 null 로 돌려줍니다.
ref 는 그래프 안에서 유일하다지역 이름표라 무엇이든 됩니다('main'·'tweet:111'). 겹치면 안 됩니다.
어기면 조회표가 하나를 삼켜 사슬이 짧아지고,
'도달할 수 없는 묶음' 으로 던집니다.
주의 자기 인용은 ref 가 겹치는 게 아닙니다.
겹치는 것은 platform_key 이고 그건 정상입니다.
parentRef 방향 — "내가 인용한 원본" 을 가리킨다A.parentRef = B 는 "A 가 B 를 인용했다" 는 뜻입니다. 반대가 아닙니다.
어기면 사슬이 거꾸로 서서 깊이가 뒤집힙니다. 던지지 않습니다 — 모양은 여전히 유효한 사슬이기 때문입니다.
확인 rootRef 묶음의 parentRef 가
인용 원본을 가리키는지 봅니다.
객체를 만들 때 무엇을 반드시 채워야 하는가.
platformKey 가 있어야 한다계정·베뉴·콘텐츠 전부입니다. 만들 수 없으면 그 객체를 아예 넣지 않습니다.
어기면 정체성 축이 없어 같은 대상이 관측할 때마다 새 행이 됩니다.
멘션도 MentionInput.platformKey 는 필수입니다. 핸들만 담으면
같은 KOL 이 인용 경로와 멘션 경로에서 다른 키로 잡힙니다.
못 만들 때 객체를 빼고 신호는 status 로 보존합니다.
예를 들어 X 의 정지 계정은 id 가 응답에 없어 키를 만들 수 없으므로, 계정 행 대신
SUSPENDED 로 끝냅니다.
만들 거면 계약된 필드를 채우는 것을 보장합니다. 못 채우면 ① 추가 호출로 채우거나 ② 아예 만들지 않습니다.
어기면 영구히 빕니다.
resolveAccount·resolveVenue·resolveContent 는 전부
기존 행을 갱신하지 않습니다.
가장 위험한 자리는 fan-out 으로 처음 만들어지는 객체입니다. 인용 원본의 작성자가 그렇습니다 — 응답에 실린 축약 정보뿐인데, 그 빈약한 행이 확정되고 나면 나중에 그 계정을 직접 fetch 해도 갱신되지 않습니다. 즉 어느 토큰이 먼저 오느냐가 데이터 품질을 결정합니다.
resolveAccount 가 platform_key 로 기존 행을 찾아
그대로 반환합니다. @bob 은 영원히 빈 계정입니다.outboundUrls 는 본문에서 뽑은 값이라 생성기가 직접
normalizeUrl() 을 부릅니다 — 라우터를 거치지 않는 유일한 경로입니다.
정규화에 실패한 값은 버립니다.
어기면 outbound_urls 의 쓸모는 "이 콘텐츠가 토큰의
사이트를 가리키는가" 인데, 그 판정은 tokens.social_urls[].url 과 문자열을 맞대는
방식입니다. 한쪽만 정규화돼 있으면 같은 주소인데도 안 맞습니다.
주의 강제하는 코드가 없습니다. 컴파일도 런타임도 안 잡습니다. 이 항목이 루브릭에 있는 이유가 그것입니다.
data 는 필드 단위 명시 매핑{ ...response.stats } 같은 스프레드는 금지합니다.
어기면 TypeScript 의 초과 속성 검사가 스프레드에는 적용되지 않아 컴파일을 통과하고, mongoose 의 strict 모드가 스키마에 없는 키를 조용히 버립니다. 지표가 통째로 사라져도 에러가 없습니다.
키 이름 지어내지 않고 fetcher 반환 타입의 실제 필드명을 그대로 씁니다.
어느 소셜의 값인지는 같은 문서의 platform 이 말해 줍니다.
subtype 은 fetch 후 판정이며 정체성이 아니다같은 대상을 다시 만나면 값이 달라질 수 있습니다. 메시지가 없던 텔레그램 채널이 차면
shell 에서 channel 로 바뀝니다.
어기면 조회 키에 넣으면 같은 대상이 두 행으로 갈립니다.
정체성은 platform_key 하나뿐입니다.
creatorId·venueId·parentContentId 를 비운다생성기는 저장 전에 돌기 때문에 _id 를 만들 수 없습니다. Writer 가 채웁니다.
status·attempted·graph 를 어떻게 조합해야 하는가.
셋은 함께 움직입니다. 정확히는 FetchStatus 값 7종이 세 갈래로 묶여 있고, 갈래마다 나머지 둘이 함께 고정됩니다.
// 갈래 1 — 성공. 부른 경우와 안 부른 경우가 둘 다 있다 | { status: OK; attempted: boolean; graph: SocialObjectGraph } // 갈래 2 — 부르지 않고 끝났다 | { status: SKIPPED_PAID | UNSUPPORTED; attempted: false; graph: null } // 갈래 3 — 불렀는데 남길 게 없었다 | { status: NOT_FOUND | BLOCKED | ERROR | SUSPENDED; attempted: true; graph: null }
FetchStatus 값은 갈래에 편입시킵니다. 갈래를 늘리지 않습니다SUSPENDED 는 갈래 3 입니다. X 에 실제로 물었고, 응답이 "정지됨" 이었고,
id 가 없어 platformKey 를 못 만듭니다(X-3 · G-5).
전용 갈래를 새로 만들면 SUSPENDED + attempted: false 같은 조합이 통과합니다.
그건 부른 호출이 안 부른 것으로 기록되는 것이고, 그 숫자가 비용과 재수집 판단의 근거입니다.
표로 보면 정상 조합과 함정이 이렇게 갈립니다.
GenerateResult 는 판별 유니온입니다. OK 가 아닌데 graph 가
있거나, attempted 가 status 와 안 맞으면 타입 오류가 납니다.
⚠️ 딱 하나가 안 막힙니다 — OK 인데 graph: null.
tsconfig 의 strictNullChecks: false 때문에 null 이 모든 타입에
assignable 이라 그대로 통과합니다. 이 하나는 눈으로 확인하는 수밖에 없습니다.
status | attempted |
graph | 뜻 |
|---|---|---|---|
| OK | true | 있음 | 불렀고 대상이 있었다. 이때만 링크가 생긴다 |
| NOT_FOUND | true | null | 불렀는데 대상이 없었다 |
| SKIPPED_PAID | false | null | 유료라 부르지 않았다 |
| UNSUPPORTED | false | null | 지원하지 않아 부르지 않았다 |
| SUSPENDED | true | null | 불렀더니 계정이 정지·탈퇴였다. 갈래 3 에 편입한다 |
| OK | — | null | 함정 · 컴파일 통과 저장이 건너뛰어지고 OK 로 기록됩니다. 링크가 0건인데 성공으로 남습니다. 넷 중 유일하게 안 막히는 조합입니다 |
| OK 아님 | — | 있음 | 컴파일이 막음 |
| SKIPPED_PAID · UNSUPPORTED | true | null | 컴파일이 막음 부르지도 않은 호출이 세어집니다 |
| NOT_FOUND · BLOCKED · ERROR | false | null | 컴파일이 막음 |
attempted 는 외부를 실제로 불렀는가tokens.social_urls[].attempts 집계의 유일한 근거입니다. 게이트가 사라지면서
이것을 아는 주체가 생성기뿐이 됐습니다.
어기면 부르지 않은 것과 불러서 실패한 것이 같은 횟수로 세어져, 나중에 그 값으로 비용이나 백오프를 계산할 때 조용히 틀립니다.
ExternalFetchError 로 던진다삼키지 않습니다. 어디까지 흡수할지는 호출 경계인 Processor 가 정합니다.
어기면 Processor 는 ExternalErrorCode 로
BLOCKED(대상 쪽 사정)와 ERROR(우리 쪽 사정)를 가릅니다. 일반
Error 로 던지면 전부 ERROR 로 뭉쳐서, 재수집 배치가
영영 열리지 않을 URL 을 계속 두드리게 됩니다.
예외 보조 호출은 흡수합니다(G-10). 우리가 얹은 부가 경로의 실패는 그 URL 전체를 실패로 만들 이유가 없습니다. 판정은 한 문장입니다 — 그 호출이 실패해도 남는 값이 있는가. 없다면 그건 보조가 아니라 본 호출이고 이 규칙이 그대로 적용됩니다.
| 호출 | 실패하면 | 판정 |
|---|---|---|
X trending 제목·요약r.jina.ai | text 는 비지만 publishedAt 이 남는다. trend id 의 snowflake 를 계산할 뿐이라 외부를 안 부른다 | 흡수한다 |
X 프로필fetchProfile | 남는 게 없다. URL 에는 userName 뿐이고 그건 변경 가능해 platformKey 가 못 된다 | 던진다 |
반대로 흡수하면 본 호출 실패가 OK + graph: null
로 기록됩니다. 위 표에서 유일하게 컴파일이 안 막는 조합이라, 링크 0건인데 성공으로 남습니다.
단서 보조 호출이라도 삼키면 안 되는 실패가 있습니다. 판정이 하나 더 붙습니다 — 그 URL 만의 사정인가, 다음 호출에도 똑같이 일어날 성질인가.
| 코드 | 성격 | 판정 |
|---|---|---|
| TARGET_BLOCKED · UPSTREAM_UNAVAILABLE · RATE_LIMITED | 그 대상·그 순간의 사정 | 흡수 |
| CREDIT_EXHAUSTED · QUOTA_EXHAUSTED | 돈·할당량이 떨어졌다 | 던진다 |
| CREDENTIAL_MISSING · CREDENTIAL_REJECTED | 배포 설정이 틀렸다 | 던진다 |
CREDIT_EXHAUSTED 가 특히 그렇습니다. Processor 가 그 코드를 보고
이 이벤트의 남은 유료 URL 을 건너뜁니다(E-4). 삼키면 그 신호가 사라져
돈이 떨어진 채로 유료 호출이 계속 나갑니다. 보조 호출이 본 호출과 같은 크레딧을
쓰는 경우가 있습니다 — X 커뮤니티 개설자 조회가 그렇습니다.
classify() 가 무엇을 보장해야 생성기가 편해지는가.
sourceType 을 'unknown' 으로classify() 의 반환은 판별 유니온입니다. 종류가 있으면 키도 반드시 있고,
키가 없으면 종류는 'unknown' 입니다.
type SocialRoute<T extends string = string> =
| { sourceType: T; sourceKey: string }
| { sourceType: 'unknown'; sourceKey: null };
지켜야 하는 쪽 classify() 입니다. 키를 못 뽑았는데 종류를
그대로 답하면 타입이 거짓말을 합니다. 예를 들어 ?q= 가 비었는데
x_tweet_search 를 답하는 경우입니다.
어기면 타입은 여전히 통과합니다. 대신 생성기가
sourceKey 를 string 으로 믿고 쓰다가 빈 키로 행을 만듭니다.
platform_key 가 빈 문자열인 행은 정체성이 없는데도 저장됩니다.
생성기 쪽 이득 if (!route.sourceKey) 분기를
쓸 수 없게 됩니다. 도달 불가능한 분기에 무엇을 반환해도 틀립니다 —
UNSUPPORTED 면 우리 버그가 정상 관측으로 보이고, throw 면 멀쩡한
URL 이 ERROR 로 남습니다.
⚠️ 대가 — 'unknown' 의 뜻이 넓어집니다. "그 호스트지만 어떤 객체도 아닌
경로" 에 "종류는 알겠는데 키가 없었다" 가 합쳐집니다. 둘 다 UNSUPPORTED 로 끝나
동작은 같지만 로그에서 구분되지 않습니다.
normalizeUrl() 이 null 이면 던진다생성기가 받는 URL 은 라우터가 이미 정규화한 값입니다. 그것을 다시 정규화해서 실패하면 정규화가 자기 출력을 못 읽는다는 뜻입니다.
어기면 입력 문제가 아니라 우리 버그인데
UNSUPPORTED 로 조용히 묻힙니다.
같은 객체를 두 경로로 만들 때 무엇을 맞춰야 하는가.
거의 모든 소셜이 같은 계정을 두 경로로 만듭니다.
| 소셜 | 경로 A (직접) | 경로 B (딸려옴) |
|---|---|---|
| X | x_profile | tweet.author · community.creator |
| TikTok | 프로필 | video.author |
| 프로필 | post.owner | |
| user | post.author | |
| GitHub | owner | repo.owner |
두 경로가 같은 필드 집합을 내야 합니다. 못 내면 둘 중 하나입니다 — ① 덜 주는 쪽에서 추가 호출로 채우거나, ② 보장 안 되는 필드를 양쪽에서 다 뺍니다. 한쪽만 채운 채로 두는 것이 금지입니다.
어기면 도착 순서가 저장 모양을 가릅니다. 기존 행은 갱신되지 않으므로(R-8) 먼저 도착한 쪽의 모양이 영구히 고착됩니다. 같은 계정인데 어떤 토큰에서 보면 필드가 있고 다른 토큰에서 보면 없습니다.
조용합니다 결손 종류에 따라 갈립니다. 날짜가 빠지면
new Date(undefined) 가 Invalid Date 가 되어 mongoose 가 거부하지만,
숫자·문자열이 빠지면 그 필드만 없는 행이 그대로 저장됩니다. 예외도 로그도 없습니다.
확인 두 경로에 같은 대상을 넣고 산출 객체를 통째로 비교하는 테스트를 둡니다. 필드를 하나씩 보면 나중에 추가되는 필드에서 다시 갈립니다.
⚠️ 필드를 추가할 때가 가장 위험합니다. 기존 두 경로가 같아도, 새 필드가 한쪽
응답에만 있으면 그 순간 깨집니다. X 의 계정 outboundUrls(X-8)가 그 후보입니다 —
프로필 응답에는 entities 가 있는데 중첩 author 에서는 생략된다고
SDK 주석이 경고합니다.
X 의 현재 상태 통과합니다. 매핑은 두 경로가 같은
transformUser → toActiveProfile → toAccount 를 지나
산출이 문자열까지 동일하고, 채워짐도 audit 유료 실호출로 확인됐습니다 —
"quoted_tweet 의 키 집합이 최상위와 100% 동일하고 author 도 완전 프로필"
(social-url-structure-audit/scaffold/x.json → liveTests).
다 쓴 뒤 무엇을 어떤 순서로 확인하는가.
생성기 하나를 마칠 때마다 처음부터 끝까지 누르며 훑습니다. 체크 상태는 저장되지 않습니다 — 남아 있으면 앞 소셜의 체크가 다음 소셜로 새어 들어갑니다.
지금 없는 것이 무엇이고, 왜 생성기에서 우회하면 안 되는가.
아래는 아직 안 정해졌거나 알면서 감수하는 것들입니다. 생성기 구현에서 이걸 우회하려 들면 안 됩니다 — 우회하면 그 소셜만 다른 규칙으로 돌게 되고, 나중에 본 결정이 내려질 때 그 하나만 따로 고쳐야 합니다.
| 항목 | 내용 | 추적 |
|---|---|---|
| FetchStatus.SUSPENDED | 아직 enum 에 없습니다. 추가할 때 GenerateResult 의 갈래 3 에 편입시킵니다(R-13). X 구현 전에 추가해야 합니다 | G-5 |
| accounts.unavailable | 채우는 경로가 없습니다. 정지 계정은 키를 못 만들어 행 자체를 안 만들고, 행이 이미 있는 계정이 나중에 정지되는 경우는 재관측 갱신이 없어 반영되지 않습니다. 결과적으로 정지 신호는 tokens.social_urls[].status 에만 남고, "어느 계정이 정지됐나" 는 URL 을 다시 route() 해서 역추적해야 합니다. audit 이 이걸 actionable("런칭 후 계정 정지 = 팀 이탈 강신호")로 판정했으므로, 실제로 쓰려면 H-008 이 먼저 풀려야 합니다 | H-008 X-3 |
search·intent·trend 가 키 공간 공유 | contents 조회가 (platform, platform_key) 뿐이라(subtype 이 빠집니다) 정규화된 검색어와 intent 문구가 같은 문자열이면 한 행으로 합쳐지고 subtype 은 먼저 온 쪽으로 고정됩니다. 합치는 것은 인덱스가 아니라 findContent() 의 조회입니다 — 인덱스는 unique 도 아니라 애초에 막지 않습니다. platform_key 에 접두사를 붙이면 막히지만, 그러면 subtype 이라는 같은 사실이 두 곳에 살게 되어 어긋난 행을 아무도 못 잡습니다. 잃는 것은 두 종류의 구분뿐이고 token_links 는 양쪽 다 붙습니다 | G-13 |
| ContentDataInput | 현재 {[key: string]: never} 라 아무것도 못 담습니다. X 가 첫 정의 주체입니다 | X-6 |
| 재관측 갱신 | 기존 행의 모든 필드가 첫 관측에서 얼어붙습니다 (R-8 의 근거) | H-008 |
| 지표 두 곳이 갈림 | metrics_latest 와 metric_series 는 첫 관측 시점이 달라 갈릴 수 있습니다 | H-007 |
| 재수집 배치 | 없습니다. 일시 실패는 현재로선 영구 미기록입니다 | H-009 |
| 같은 대상 두 URL | 정규화 문자열이 다르면 dedup 되지 않아 fetch 가 2회 나갑니다 | H-011 |
| 정규화 실패 URL | 흔적이 아예 남지 않습니다 — 로그에만 있습니다 | H-012 |
| 링크 중복 | 재처리 부산물과 재관측 이력이 구분되지 않습니다 | H-013 |
| 동시 생성 | contents·venues 는 unique 가 아니라 두 행이 생길 수 있습니다 | H-005 |