Generator 루브릭

token_links 정책 · 규칙 R-1 ~ R-18  ·  2026-08-07

§1이 페이지 쓰는 법

언제 펴고, 무엇부터 보고, social-generator-rubric.md 와는 어떻게 다른가.

생성기 하나를 쓰기 직전다 쓴 직후 두 번 폅니다.

정본은 social-generator-rubric.md 입니다

이 페이지는 작업 중에 보기 좋게 만든 뷰이고, 판정의 정본은 같은 폴더의 rubric.md 입니다. 규칙이 바뀌면 둘을 같이 고칩니다 — 한쪽만 고치면 다음에 읽는 사람이 어느 쪽이 맞는지 알 방법이 없습니다.

각 규칙이 그렇게 정해졌는지는 decisions.md(G-*)와 recording-holes.md(H-*)에 있습니다. 여기에는 판정 결과만 담습니다.

규칙에는 두 종류의 심각도가 있고, 카드 왼쪽 색이 그것을 나타냅니다.

§2token_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 가 알아서 만듭니다.

URL 마다 객체가 있어야 하나 — 아니요

8종을 만들면서 매번 나오는 질문이라 답을 먼저 적습니다.

token_links 행 하나 = platform_key 로 식별된 객체 하나

역도 성립합니다. 객체가 없으면 링크도 없고, 그것은 정상 결과입니다.

platform_key 있음  →  객체 행 생성  →  token_links 1건
platform_key 없음  →  객체 없음     →  token_links 0건

이건 관례가 아니라 스키마가 강제합니다. contents·accounts·venuesplatform_key 가 전부 required: true 라 없으면 mongoose 가 저장을 거부합니다. 그리고 buildLink() 는 이미 _id 가 확정된 객체만 받으므로, 객체 없이 링크를 만들 경로가 코드에 없습니다.

그래서 기록이 세 단계로 갈립니다.

단계token_linkstokens.social_urls[]언제
정상 수집ok객체를 확정했습니다
객체 없음✅ 그 외 status부르지 못했거나, 불렀는데 대상이 없거나, 키를 못 만들었습니다
완전 소실정규화에 실패했습니다 — 로그에만 남습니다 (H-012)

두 번째 단계를 의도적으로 허용합니다. 예전에는 contents(subtype=unknown) 자리행과 token_links(unresolved) 를 만들어 "이 토큰이 이 URL 을 걸었다" 를 표현했는데, 두 가지가 망가졌습니다. 첫째로 "링크가 있다" 가 "수집됐다" 를 뜻하지 않게 됐습니다. 둘째로 그 자리행이 승격되지 않아, 나중에 제대로 수집해도 unknown 인 채로 남았습니다.

graphnull 인 status 와 그 이유

OK 하나만 graph 를 갖습니다. 나머지는 전부 객체를 만들지 않습니다.

status왜 객체가 없나불렀나재시도로 바뀌나
UNSUPPORTED담당 Generator 가 없거나, 있어도 그 종류를 다루지 않습니다우리가 만들면 바뀝니다
SKIPPED_PAID유료라 부르지 않았습니다설정을 켜면 바뀝니다
SUSPENDED 예정계정이 정지·탈퇴라 platform_key 를 만들 수 없습니다정지는 풀립니다
NOT_FOUND불렀는데 대상이 없습니다거의 안 바뀝니다
BLOCKED대상 쪽 사정입니다 — 비공개 · 차단 · 초대 전용열릴 수 있습니다
ERROR우리 쪽 사정입니다 — 네트워크 · DB · 계약 위반바뀔 수 있습니다

어휘를 이만큼 쪼갠 이유가 마지막 열입니다. 재수집 배치가 무엇을 다시 두드릴지 판단하는 유일한 근거입니다. 뭉개면 영영 열리지 않을 URL 을 계속 두드리게 됩니다 — 프로세서가 TARGET_BLOCKEDERROR 에서 가르는 것과 같은 근거입니다.

응답 원문은 저장하지 않습니다 2026-08-07

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 intent0회intent 텍스트가 값입니다
X trending보조 1회 (실패 허용)snowflake id 에서 생성 시각이 호출 0회로 나옵니다
X 정지 계정1회응답에 id 가 없어 키를 만들 수 없습니다
grok · 예약 루트 · unknown0회키도 못 만들고 채울 필드도 없습니다

§3 · §4묶음이 링크가 되기까지, 그리고 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자가 섞여 나옵니다.

사슬 길이
겹침
생성기가 내는 묶음 — 처리 순서(깊은 것부터)
저장되는 token_links
접힐 때 살아남는 값은 가장 얕은 깊이입니다

Writer 는 사슬을 깊은 것부터 돌면서 링크를 덮어씁니다. 나중에 오는 쪽이 항상 더 얕으니, 마지막에 남는 값이 곧 최솟값입니다.

예전에는 먼저 만난 것을 채택했는데, 정렬이 깊은 것을 먼저 주므로 결과가 정반대였습니다. 자기 인용이면 작성자 계정이 깊이 1 로 박혀서, 토큰이 직접 건 계정인데도 link_depth: 0 조회에서 빠졌습니다.

§5그래프 모양 규칙 — R-1 ~ R-6

어떤 모양이 통과하고, 어떤 모양이 Writer 에서 예외를 맞는가.

먼저 내가 만들려는 모양이 아래에 있는지 찾아보세요. 화살표는 parentRef 이고, 방향은 "내가 인용한 원본" 을 가리킵니다.

규칙 카드

R-1던짐 그래프는 사슬 하나

모든 묶음이 rootRef 에서 parentRef 를 타고 도달 가능해야 합니다. 가지도 고아도 없습니다.

어기면 plan() 이 던지고 그 URL 전체가 실패합니다.

깊이 0 은 "토큰이 직접 걸었다" 는 강한 주장인데, 도달 경로가 없는 묶음에는 그것을 뒷받침할 근거가 없습니다. 예전에는 그런 묶음에 0 을 줘서 홍보한 적 없는 대상이 홍보 명단에 섞였습니다.

확인 묶음이 N 개면 rootRef 에서 parentRef 를 N-1 번 타서 전부 닿아야 합니다.

R-2조용히 틀림 인용이 아니면 묶음을 가르지 않는다

한 URL 에서 나온 계정·베뉴·콘텐츠는 묶음 하나의 세 자리에 담습니다. 묶음이 갈리는 것은 인용·리트윗·포크일 때뿐입니다.

어기면 R-1 에 걸려 던집니다. "프로필 + 고정 트윗" 을 두 묶음으로 내려는 순간이 정확히 그 경우입니다.

확인 새 묶음을 만들 때마다 물으세요 — "이건 앞 묶음이 인용한 원본인가?" 아니면 같은 묶음입니다.

R-3던짐 parentRef 대상은 콘텐츠를 가진 묶음이어야 한다

어기면 '인용 원본이 콘텐츠 있는 묶음이 아니다' 로 던집니다. 허공을 가리킨 경우도 같은 검사에 걸립니다.

아니면 contents.parent_content_id 가 에러 없이 빕니다. 인용은 글이 글을 가리키는 것입니다.

R-4던짐 셋 다 null 인 묶음을 만들지 않는다

어기면 '빈 그룹' 으로 던집니다.

객체를 하나도 못 만들겠으면 그 묶음 자체를 내지 않습니다. 그것이 유일한 묶음이면 graphnull 로 돌려줍니다.

R-5던짐 ref 는 그래프 안에서 유일하다

지역 이름표라 무엇이든 됩니다('main'·'tweet:111'). 겹치면 안 됩니다.

어기면 조회표가 하나를 삼켜 사슬이 짧아지고, '도달할 수 없는 묶음' 으로 던집니다.

주의 자기 인용은 ref 가 겹치는 게 아닙니다. 겹치는 것은 platform_key 이고 그건 정상입니다.

R-6조용히 틀림 parentRef 방향 — "내가 인용한 원본" 을 가리킨다

A.parentRef = B"A 가 B 를 인용했다" 는 뜻입니다. 반대가 아닙니다.

어기면 사슬이 거꾸로 서서 깊이가 뒤집힙니다. 던지지 않습니다 — 모양은 여전히 유효한 사슬이기 때문입니다.

확인 rootRef 묶음의 parentRef인용 원본을 가리키는지 봅니다.

§6객체 내용 규칙 — R-7 ~ R-12

객체를 만들 때 무엇을 반드시 채워야 하는가.

R-7조용히 틀림 모든 객체에 platformKey 가 있어야 한다

계정·베뉴·콘텐츠 전부입니다. 만들 수 없으면 그 객체를 아예 넣지 않습니다.

어기면 정체성 축이 없어 같은 대상이 관측할 때마다 새 행이 됩니다.

멘션도 MentionInput.platformKey 는 필수입니다. 핸들만 담으면 같은 KOL 이 인용 경로와 멘션 경로에서 다른 키로 잡힙니다.

못 만들 때 객체를 빼고 신호는 status 로 보존합니다. 예를 들어 X 의 정지 계정은 id 가 응답에 없어 키를 만들 수 없으므로, 계정 행 대신 SUSPENDED 로 끝냅니다.

R-8조용히 틀림 껍데기를 만들지 않는다 — 첫 생성이 곧 최종이다

만들 거면 계약된 필드를 채우는 것을 보장합니다. 못 채우면 ① 추가 호출로 채우거나 ② 아예 만들지 않습니다.

어기면 영구히 빕니다. resolveAccount·resolveVenue·resolveContent 는 전부 기존 행을 갱신하지 않습니다.

가장 위험한 자리는 fan-out 으로 처음 만들어지는 객체입니다. 인용 원본의 작성자가 그렇습니다 — 응답에 실린 축약 정보뿐인데, 그 빈약한 행이 확정되고 나면 나중에 그 계정을 직접 fetch 해도 갱신되지 않습니다. 즉 어느 토큰이 먼저 오느냐가 데이터 품질을 결정합니다.

① 토큰 A 가 트윗 하나를 건다
그 트윗이 @bob 의 트윗을 인용하고 있습니다. 생성기는 @bob 계정을 fan-out 으로 만듭니다 — 응답 안에 실린 축약 정보만 있습니다.
② @bob 계정 행이 생긴다 — 팔로워 · 가입일 · 바이오가 빈 채로
인용 원본 안의 author 스냅샷에 그 필드들이 없으면 비어 있는 채로 확정됩니다.
③ 토큰 B 가 @bob 의 프로필 URL 을 직접 건다
이번에는 프로필을 통째로 fetch 해서 33개 필드가 다 있습니다.
④ 그런데 아무것도 갱신되지 않는다
resolveAccountplatform_key 로 기존 행을 찾아 그대로 반환합니다. @bob 은 영원히 빈 계정입니다.
R-9조용히 틀림 모든 URL 은 정규화된 값이다

outboundUrls 는 본문에서 뽑은 값이라 생성기가 직접 normalizeUrl() 을 부릅니다 — 라우터를 거치지 않는 유일한 경로입니다. 정규화에 실패한 값은 버립니다.

어기면 outbound_urls 의 쓸모는 "이 콘텐츠가 토큰의 사이트를 가리키는가" 인데, 그 판정은 tokens.social_urls[].url문자열을 맞대는 방식입니다. 한쪽만 정규화돼 있으면 같은 주소인데도 안 맞습니다.

주의 강제하는 코드가 없습니다. 컴파일도 런타임도 안 잡습니다. 이 항목이 루브릭에 있는 이유가 그것입니다.

R-10조용히 틀림 지표와 data필드 단위 명시 매핑

{ ...response.stats } 같은 스프레드는 금지합니다.

어기면 TypeScript 의 초과 속성 검사가 스프레드에는 적용되지 않아 컴파일을 통과하고, mongoose 의 strict 모드가 스키마에 없는 키를 조용히 버립니다. 지표가 통째로 사라져도 에러가 없습니다.

키 이름 지어내지 않고 fetcher 반환 타입의 실제 필드명을 그대로 씁니다. 어느 소셜의 값인지는 같은 문서의 platform 이 말해 줍니다.

R-11조용히 틀림 subtype 은 fetch 후 판정이며 정체성이 아니다

같은 대상을 다시 만나면 값이 달라질 수 있습니다. 메시지가 없던 텔레그램 채널이 차면 shell 에서 channel 로 바뀝니다.

어기면 조회 키에 넣으면 같은 대상이 두 행으로 갈립니다. 정체성은 platform_key 하나뿐입니다.

R-12조용히 틀림 creatorId·venueId·parentContentId 를 비운다

생성기는 저장 전에 돌기 때문에 _id 를 만들 수 없습니다. Writer 가 채웁니다.

§7반환 계약 — R-13 ~ R-15

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 가 있거나, attemptedstatus 와 안 맞으면 타입 오류가 납니다.

⚠️ 딱 하나가 안 막힙니다 — OK 인데 graph: null. tsconfigstrictNullChecks: false 때문에 null 이 모든 타입에 assignable 이라 그대로 통과합니다. 이 하나는 눈으로 확인하는 수밖에 없습니다.

statusattempted graph
OKtrue있음불렀고 대상이 있었다. 이때만 링크가 생긴다
NOT_FOUNDtruenull불렀는데 대상이 없었다
SKIPPED_PAIDfalsenull유료라 부르지 않았다
UNSUPPORTEDfalsenull지원하지 않아 부르지 않았다
SUSPENDEDtruenull불렀더니 계정이 정지·탈퇴였다. 갈래 3 에 편입한다
OKnull함정 · 컴파일 통과 저장이 건너뛰어지고 OK 로 기록됩니다. 링크가 0건인데 성공으로 남습니다. 넷 중 유일하게 안 막히는 조합입니다
OK 아님있음컴파일이 막음
SKIPPED_PAID · UNSUPPORTEDtruenull컴파일이 막음 부르지도 않은 호출이 세어집니다
NOT_FOUND · BLOCKED · ERRORfalsenull컴파일이 막음
R-14조용히 틀림 attempted외부를 실제로 불렀는가

tokens.social_urls[].attempts 집계의 유일한 근거입니다. 게이트가 사라지면서 이것을 아는 주체가 생성기뿐이 됐습니다.

어기면 부르지 않은 것과 불러서 실패한 것이 같은 횟수로 세어져, 나중에 그 값으로 비용이나 백오프를 계산할 때 조용히 틀립니다.

R-15조용히 틀림 호출 실패는 ExternalFetchError 로 던진다

삼키지 않습니다. 어디까지 흡수할지는 호출 경계인 Processor 가 정합니다.

어기면 Processor 는 ExternalErrorCodeBLOCKED(대상 쪽 사정)와 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 커뮤니티 개설자 조회가 그렇습니다.

§7b분류 계약 — R-16 · R-17

classify() 가 무엇을 보장해야 생성기가 편해지는가.

R-16조용히 틀림 키를 못 뽑으면 sourceType'unknown' 으로

classify() 의 반환은 판별 유니온입니다. 종류가 있으면 키도 반드시 있고, 키가 없으면 종류는 'unknown' 입니다.

type SocialRoute<T extends string = string> =
  | { sourceType: T;         sourceKey: string }
  | { sourceType: 'unknown'; sourceKey: null };

지켜야 하는 쪽 classify() 입니다. 키를 못 뽑았는데 종류를 그대로 답하면 타입이 거짓말을 합니다. 예를 들어 ?q= 가 비었는데 x_tweet_search 를 답하는 경우입니다.

어기면 타입은 여전히 통과합니다. 대신 생성기가 sourceKeystring 으로 믿고 쓰다가 빈 키로 행을 만듭니다. platform_key 가 빈 문자열인 행은 정체성이 없는데도 저장됩니다.

생성기 쪽 이득 if (!route.sourceKey) 분기를 쓸 수 없게 됩니다. 도달 불가능한 분기에 무엇을 반환해도 틀립니다 — UNSUPPORTED 면 우리 버그가 정상 관측으로 보이고, throw 면 멀쩡한 URL 이 ERROR 로 남습니다.

⚠️ 대가'unknown' 의 뜻이 넓어집니다. "그 호스트지만 어떤 객체도 아닌 경로" 에 "종류는 알겠는데 키가 없었다" 가 합쳐집니다. 둘 다 UNSUPPORTED 로 끝나 동작은 같지만 로그에서 구분되지 않습니다.

R-17Writer 가 던짐 normalizeUrl()null 이면 던진다

생성기가 받는 URL 은 라우터가 이미 정규화한 값입니다. 그것을 다시 정규화해서 실패하면 정규화가 자기 출력을 못 읽는다는 뜻입니다.

어기면 입력 문제가 아니라 우리 버그인데 UNSUPPORTED 로 조용히 묻힙니다.

§7c경로 일관성 — R-18

같은 객체를 두 경로로 만들 때 무엇을 맞춰야 하는가.

R-18조용히 틀림 같은 객체를 만드는 경로가 둘이면 산출이 같아야 한다

거의 모든 소셜이 같은 계정을 두 경로로 만듭니다.

소셜경로 A (직접)경로 B (딸려옴)
Xx_profiletweet.author · community.creator
TikTok프로필video.author
Instagram프로필post.owner
Reddituserpost.author
GitHubownerrepo.owner

두 경로가 같은 필드 집합을 내야 합니다. 못 내면 둘 중 하나입니다 — ① 덜 주는 쪽에서 추가 호출로 채우거나, ② 보장 안 되는 필드를 양쪽에서 다 뺍니다. 한쪽만 채운 채로 두는 것이 금지입니다.

어기면 도착 순서가 저장 모양을 가릅니다. 기존 행은 갱신되지 않으므로(R-8) 먼저 도착한 쪽의 모양이 영구히 고착됩니다. 같은 계정인데 어떤 토큰에서 보면 필드가 있고 다른 토큰에서 보면 없습니다.

조용합니다 결손 종류에 따라 갈립니다. 날짜가 빠지면 new Date(undefined)Invalid Date 가 되어 mongoose 가 거부하지만, 숫자·문자열이 빠지면 그 필드만 없는 행이 그대로 저장됩니다. 예외도 로그도 없습니다.

확인 두 경로에 같은 대상을 넣고 산출 객체를 통째로 비교하는 테스트를 둡니다. 필드를 하나씩 보면 나중에 추가되는 필드에서 다시 갈립니다.

⚠️ 필드를 추가할 때가 가장 위험합니다. 기존 두 경로가 같아도, 새 필드가 한쪽 응답에만 있으면 그 순간 깨집니다. X 의 계정 outboundUrls(X-8)가 그 후보입니다 — 프로필 응답에는 entities 가 있는데 중첩 author 에서는 생략된다고 SDK 주석이 경고합니다.

X 의 현재 상태 통과합니다. 매핑은 두 경로가 같은 transformUsertoActiveProfiletoAccount 를 지나 산출이 문자열까지 동일하고, 채워짐도 audit 유료 실호출로 확인됐습니다 — "quoted_tweet 의 키 집합이 최상위와 100% 동일하고 author 도 완전 프로필" (social-url-structure-audit/scaffold/x.jsonliveTests).

§8구현 후 자가 점검

다 쓴 뒤 무엇을 어떤 순서로 확인하는가.

생성기 하나를 마칠 때마다 처음부터 끝까지 누르며 훑습니다. 체크 상태는 저장되지 않습니다 — 남아 있으면 앞 소셜의 체크가 다음 소셜로 새어 들어갑니다.

0 / 23

§9미확정 · 알려진 한계

지금 없는 것이 무엇이고, 왜 생성기에서 우회하면 안 되는가.

아래는 아직 안 정해졌거나 알면서 감수하는 것들입니다. 생성기 구현에서 이걸 우회하려 들면 안 됩니다 — 우회하면 그 소셜만 다른 규칙으로 돌게 되고, 나중에 본 결정이 내려질 때 그 하나만 따로 고쳐야 합니다.

항목내용추적
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_latestmetric_series 는 첫 관측 시점이 달라 갈릴 수 있습니다H-007
재수집 배치없습니다. 일시 실패는 현재로선 영구 미기록입니다H-009
같은 대상 두 URL정규화 문자열이 다르면 dedup 되지 않아 fetch 가 2회 나갑니다H-011
정규화 실패 URL흔적이 아예 남지 않습니다 — 로그에만 있습니다H-012
링크 중복재처리 부산물과 재관측 이력이 구분되지 않습니다H-013
동시 생성contents·venues 는 unique 가 아니라 두 행이 생길 수 있습니다H-005