Files
document-haness/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json
T

1485 lines
73 KiB
JSON

{
"schemaVersion": 4,
"project": "keycloak-session-store",
"ssot": "final/document.md",
"sourceRepository": {
"path": "/home/donghyeon/workspace/keycloak-pattern",
"revision": "cdac9b8178391311d8eca1ebc6cac15bb62d79af",
"verified": "이 커밋이 A-1 부터 D-4a 까지 실험 전량을 병합한 tip 이고 저장소 HEAD 다. git branch --contains 가 experiment 브랜치 26 개를 모두 낸다 (2026-09-07 확인)"
},
"ssotSha256": "0ae5674723f25dc85d5069529890f07be1a11b768b56901103fa3ea6ac3dcf55",
"sourceRevision": "keycloak-session-lab@2026-09",
"generatedAt": "2026-09-08",
"candidateScope": {
"document": "final/document.md",
"sections": [
"코드보다 먼저 드러난 문제",
"문제를 어렵게 만든 제약",
"검토한 선택지와 막힌 지점",
"선택의 이유와 지킨 경계",
"선택이 코드와 흐름에 반영되는 방식",
"결정이 지켜지는지 확인하는 방법",
"얻은 것, 잃은 것, 적용하지 않을 때",
"결국 지키려던 것은 무엇이었나"
],
"excluded": [
"자료",
"이 기록에 아직 없는 것"
],
"note": "처음부터 한 편으로 쓴 글이라 제2부가 없다. 맨 뒤 두 절은 증거 목록과 그림 제작 메모라 후보 자리가 아니다"
},
"note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸은 사람이 적고 file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py keycloak-session-store",
"contract": {
"decomposition": [
"글감을 찾는 입력은 final/document.md 하나다. 거기에 없는 근거는 먼저 SSOT 에 넣는다.",
"후보 전부는 candidates 에 처분과 함께 남고 PROMOTE 만 topics 로 올라간다.",
"없애고 관련 Case 나 Concept 의 한 절로 넣어도 이해·결정·재사용성이 그대로라면 독립 기록으로 만들지 않는다.",
"Topic 은 독자 질문 하나다. 그 물음에 답하지 않는 글감은 다른 Topic 으로 옮긴다.",
"Concept 은 Case·Decision·Question 을 먼저 고른 뒤 그것을 이해하는 데 필요한 것만 거꾸로 더한다."
],
"readinessValues": [
"READY",
"OPEN",
"NEEDS_EVIDENCE",
"NEEDS_DECISION",
"BLOCKED"
],
"dispositionValues": {
"PROMOTE": "독립 Tech Log 로 쓴다",
"MERGE_INTO": "다른 기록의 한 절로 흡수한다",
"KEEP_IN_SSOT": "분석에는 남기고 독립 기록으로 만들지 않는다 — 정상적인 성공 결과다",
"NEEDS_EVIDENCE": "주장에 아직 검증이 없다",
"NEEDS_DECISION": "방향이 그럴듯하지만 프로젝트가 정하지 않았다",
"BLOCKED": "원본이 불완전하거나 서로 어긋난다"
}
},
"topics": {
"session-custody-across-nodes": {
"title": "세션의 거처 — 두 노드가 같은 답을 내는 이유",
"readerQuestion": "Keycloak 세션은 실제로 어디에 있고, 두 노드가 같은 세션을 쓰는 이유는 무엇인가?",
"kinds": {
"case": [
{
"title": "클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다",
"slug": "session-sharing-is-the-database-not-replication",
"readiness": "READY",
"source": [
"final/document.md#코드보다-먼저-드러난-문제-전제가-무너졌다",
"final/document.md#선택의-이유와-지킨-경계-a1"
],
"code": [
"JGROUPS_PING",
"OFFLINE_USER_SESSION",
"sessions 캐시"
],
"classification": "로그가 가리키는 원인과 실제 경로가 다르다는 것을 노드 B 의 SQL 문장 로깅으로 확정했고, 그 확정이 A-1 의 빗나간 예측까지 설명하며 닫힌다",
"missing-verification": "Infinispan 복제를 명시적으로 켠 구성에서는 재지 않았다. 이 결론은 persistent-user-sessions 가 켜진 26.7.0 기본값에 한정된다",
"relations": [
"concept:persistent-vs-volatile-user-sessions",
"case:cache-temperature-decides-the-outcome",
"reference:state-the-version-and-the-setting-with-the-result"
],
"kind": "case",
"ssot-assets": [
"session-sharing-path",
"a1-transport-vs-discovery"
],
"ssot-evidence": [
"raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt",
"raw/a1-jgroups-transport-block__10-logout-not-propagated.txt"
],
"publication": "미작성"
},
{
"title": "같은 설정이 캐시 온도만으로 세 가지 답을 냈다",
"slug": "cache-temperature-decides-the-outcome",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a7-a7a"
],
"code": [
"CLIENT_SCOPE_CLIENT",
"REVOKED_TOKEN",
"DEFAULT_SCOPE"
],
"classification": "가설(REVOKED_TOKEN)을 문장 로깅으로 반증하고 실제 조회를 확정한 뒤, 캐시 온도 셋을 모두 재현해 표 하나로 닫히지 않는다는 결론까지 갔다",
"missing-verification": "캐시가 식는 시간을 재지 않았다. 냉·중간·온 세 상태를 만들어 확인했을 뿐 그 사이의 전이는 관측하지 않았다",
"relations": [
"concept:persistent-vs-volatile-user-sessions",
"reference:state-the-version-and-the-setting-with-the-result"
],
"kind": "case",
"ssot-assets": [
"cache-temperature-outcomes"
],
"ssot-evidence": [
"raw/a7a-volatile-cause__01-cause-determined.txt",
"raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt"
],
"publication": "미작성"
},
{
"title": "롤링 재시작은 세션을 남기고 캐시만 지웠다",
"slug": "rolling-restart-keeps-sessions-drops-cache",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a8"
],
"code": [
"persistent-user-sessions"
],
"classification": "재시작 전후의 DB 세션 수와 캐시 수를 따로 세어 무엇이 남고 무엇이 사라지는지 갈랐고, 그 설정을 켜는 이유로 닫힌다",
"missing-verification": "표본이 적어 무중단 주장을 처음에 과장했다가 고쳤다. 재시작 중 진행 중이던 요청은 재지 않았다",
"relations": [
"concept:persistent-vs-volatile-user-sessions",
"case:session-sharing-is-the-database-not-replication"
],
"kind": "case",
"ssot-assets": [
"a8-cache-vs-session"
],
"ssot-evidence": [
"raw/a8-rolling-restart__01-restart-availability.txt",
"raw/a8-rolling-restart__02-session-survival.txt"
],
"publication": "미작성"
}
],
"concept": [
{
"title": "persistent-user-sessions 가 세션의 거처를 정한다",
"slug": "persistent-vs-volatile-user-sessions",
"readiness": "READY",
"source": [
"final/document.md#코드보다-먼저-드러난-문제-버전-조건"
],
"basis-version": "Keycloak 26.7.0 · persistent-user-sessions 기본 활성 · 24 이전과 대조",
"classification": "세션이 DB 에 있는지 메모리에 있는지를 이 설정이 정하고, 그것을 모르면 A 층 결과 셋이 왜 뒤집히는지 읽을 수 없다",
"relations": [
"case:session-sharing-is-the-database-not-replication",
"case:rolling-restart-keeps-sessions-drops-cache",
"reference:state-the-version-and-the-setting-with-the-result"
],
"kind": "concept",
"ssot-assets": [
"version-conditional-results"
],
"publication": "미작성"
}
],
"reference": [
{
"title": "버전과 설정을 결과와 함께 적는다",
"slug": "state-the-version-and-the-setting-with-the-result",
"readiness": "READY",
"source": [
"final/document.md#코드보다-먼저-드러난-문제-버전-조건",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건"
],
"classification": "같은 제품의 같은 실험이 설정 하나로 뒤집히는 것을 세 건에서 확인했고, 그 규칙은 다른 제품의 기본값 변경에도 그대로 적용된다",
"scope": "기본값이 메이저 버전 사이에 바뀐 제품을 측정해 결과를 적는 자리",
"exceptions": "측정 대상이 그 설정에 걸리지 않는 경로라면 조건을 달지 않아도 된다. 무엇이 걸리는지를 먼저 확인한다",
"relations": [
"concept:persistent-vs-volatile-user-sessions",
"case:cache-temperature-decides-the-outcome"
],
"kind": "reference",
"publication": "미작성"
}
],
"question": [],
"decision": []
},
"topic": "session-custody-across-nodes"
},
"losing-a-node-or-the-store": {
"title": "노드와 저장소를 잃을 때",
"readerQuestion": "노드나 저장소를 잃으면 무엇이 먼저 깨지고, 복구까지 시간을 쓰는 것은 어느 단계인가?",
"kinds": {
"case": [
{
"title": "200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다",
"slug": "four-logins-that-returned-200-and-vanished",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a2-a3"
],
"code": [
"SET LOCAL synchronous_commit TO OFF",
"wal_writer_delay"
],
"classification": "153 건 중 149 건만 남은 것을 세어 확인하고 원인을 커밋 설정에서 확정했으며, 의도된 설계의 대가를 숫자로 닫았다",
"missing-verification": "synchronous_commit 을 켠 대조군을 돌리지 않았다. 유실이 0 이 되는지와 그때의 지연 비용은 재지 않았다",
"relations": [
"case:two-ways-to-lose-a-node",
"concept:readiness-hides-the-broken-node",
"reference:most-of-an-outage-is-noticing"
],
"kind": "case",
"ssot-assets": [
"a3-commit-to-disk-gap"
],
"ssot-evidence": [
"raw/a3-database-crash__03-loss-measurement.txt",
"raw/a3-database-crash__07-loss-result.txt",
"raw/a3-database-crash__08-wal-settings.txt"
],
"publication": "미작성"
},
{
"title": "노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것",
"slug": "two-ways-to-lose-a-node",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a4"
],
"code": [
"node-monitor-grace-period",
"tolerationSeconds",
"StatefulSet"
],
"classification": "같은 복구 시간을 내는 두 장애의 원인이 다르다는 것을 갈랐고, 축출까지 5분 40초라는 내역으로 닫힌다",
"missing-verification": "저장소를 두 노드에 나눠 배치한 구성에서는 재지 않았다. 4a 의 결과는 이 실험대의 배치에 걸려 있다",
"relations": [
"case:four-logins-that-returned-200-and-vanished",
"concept:readiness-hides-the-broken-node",
"reference:most-of-an-outage-is-noticing"
],
"kind": "case",
"ssot-assets": [
"a4-two-node-losses",
"lab-topology"
],
"ssot-evidence": [
"raw/a4-node-loss__02-worker-node-killed.txt",
"raw/a4-node-loss__03-state-during-loss.txt",
"raw/a4-node-loss__04-eviction-timing.txt"
],
"publication": "미작성"
},
{
"title": "200 밀리초를 넣었더니 응답이 22.2 초가 됐다",
"slug": "200ms-of-delay-became-22-seconds",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a6"
],
"code": [
"agroal 커넥션 풀",
"readiness probe"
],
"classification": "28 배가 어디서 왔는지를 왕복 누적과 풀 경합 두 단계로 분해했고, 느림이 장애로 승격되는 지점까지 관측해 닫았다",
"missing-verification": "지연 값을 200ms 한 점에서만 쟀다. 어느 지연부터 readiness 가 실패하는지는 재지 않았다",
"relations": [
"concept:readiness-hides-the-broken-node",
"case:two-ways-to-lose-a-node"
],
"kind": "case",
"ssot-assets": [
"a6-latency-multiplication"
],
"ssot-evidence": [
"raw/a6-latency-injection__02-delay-injected.txt",
"raw/a6-latency-injection__04-pool-under-load.txt"
],
"publication": "미작성"
}
],
"concept": [
{
"title": "readiness 가 깨진 노드를 시야에서 먼저 치운다",
"slug": "readiness-hides-the-broken-node",
"readiness": "READY",
"source": [
"final/document.md#검토한-선택지와-막힌-지점-관측을-어디에"
],
"basis-version": "k3s · Kubernetes readiness probe · Keycloak 26.7.0 /health",
"classification": "밖에서 재면 장애가 보이지 않는 이유가 이 동작이고, 이것을 모르면 A 층 결과 대부분을 아무 일도 없었던 것으로 읽는다",
"relations": [
"case:200ms-of-delay-became-22-seconds",
"case:two-ways-to-lose-a-node",
"concept:the-up-metric-cannot-see-alive-but-useless"
],
"kind": "concept",
"ssot-assets": [
"observation-points"
],
"publication": "미작성"
}
],
"reference": [
{
"title": "장애 시간의 대부분은 알아채는 데 걸린다",
"slug": "most-of-an-outage-is-noticing",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a4"
],
"classification": "복구 절차가 아니라 감지 지연이 장애 시간을 정한다는 것을 타이머 두 개의 합으로 확인했고, 다른 오케스트레이터에도 같은 축이 있다",
"scope": "노드 상실을 오케스트레이터가 감지해 대체를 만드는 구성",
"exceptions": "진입 경로 자체를 잃은 장애는 감지가 빨라도 복구되지 않는다. 그때는 이중화 지점이 문제다",
"relations": [
"case:two-ways-to-lose-a-node",
"case:four-logins-that-returned-200-and-vanished"
],
"kind": "reference",
"publication": "미작성"
}
],
"question": [],
"decision": []
},
"topic": "losing-a-node-or-the-store"
},
"where-application-state-lives": {
"title": "애플리케이션이 서버에 든 상태의 자리",
"readerQuestion": "로그인 세션과 OAuth 토큰을 서버 어디에 두어야 하고, 저장소를 옮기면 무엇이 따라오지 않는가?",
"kinds": {
"case": [
{
"title": "세션만 Redis 로 옮기자 토큰이 따라오지 않았다",
"slug": "moving-the-session-left-the-tokens-behind",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b1"
],
"code": [
"InMemoryOAuth2AuthorizedClientService",
"AuthenticatedPrincipalOAuth2AuthorizedClientRepository",
"SPRING_SESSION_STORE_TYPE"
],
"classification": "저장소를 붙이기 전에 자동구성이 무엇을 골랐는지 먼저 읽고, 그 조회 키 차이가 다음 실험의 결과를 만든다는 것까지 이어 닫았다",
"missing-verification": "Redis 를 붙인 상태에서 토큰 저장소만 따로 두는 조합은 B-2 에서 다시 쟀다. 여기서는 세션 쪽만 확인했다",
"relations": [
"concept:two-stores-two-lookup-keys",
"case:a-primary-key-without-the-session-id",
"decision:split-the-two-stores-and-design-each"
],
"kind": "case",
"ssot-evidence": [
"raw/b0-bff-redis-deploy__03-beans-analysis.txt",
"raw/b1-redis-session-store__03-redis-contents.txt"
],
"publication": "미작성"
},
{
"title": "기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다",
"slug": "a-primary-key-without-the-session-id",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b2"
],
"code": [
"JdbcOAuth2AuthorizedClientService",
"PRIMARY KEY (client_registration_id, principal_name)"
],
"classification": "저장소를 나눠 옮긴 뒤 네 가지를 확인해 둘은 풀리고 둘은 남는 것을 갈랐고, 남은 둘의 원인이 저장소가 아니라 DDL 한 줄임을 확정했다",
"missing-verification": "세션 id 를 키에 넣은 스키마로 고쳐서 다시 재지 않았다. 덮어쓰기가 사라지는지는 확인하지 않았다",
"relations": [
"concept:two-stores-two-lookup-keys",
"case:moving-the-session-left-the-tokens-behind",
"reference:look-at-the-lookup-key-before-moving-the-store",
"decision:split-the-two-stores-and-design-each"
],
"kind": "case",
"ssot-assets": [
"b2-primary-key-overwrite"
],
"ssot-evidence": [
"raw/b0-bff-redis-deploy__02-autoconfiguration.txt"
],
"publication": "미작성"
},
{
"title": "회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다",
"slug": "the-winner-of-the-rotation-race-also-loses",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b3"
],
"code": [
"revokeRefreshToken=true",
"refreshTokenMaxReuse=0"
],
"classification": "동시 5 건을 실제로 경합시켜 「하나는 성공한다」가 아니라 전부 못 쓰게 되는 것을 관측했고, 원인을 client session 삭제로 확정했다",
"missing-verification": "재시도 설계를 붙여서 다시 재지 않았다. 어떤 재시도가 이 상황을 복구하는지는 확인하지 않았다",
"relations": [
"case:a-primary-key-without-the-session-id",
"decision:split-the-two-stores-and-design-each"
],
"kind": "case",
"ssot-assets": [
"b3-rotation-contention"
],
"ssot-evidence": [
"raw/b0-bff-redis-deploy__01-deploy.txt"
],
"publication": "미작성"
},
{
"title": "볼륨 없는 영속화와 유예 없는 키 회전",
"slug": "persistence-without-a-volume-and-rotation-without-overlap",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b5-b6"
],
"code": [
"appendonly yes",
"NimbusJwtDecoder",
"JWKS kid"
],
"classification": "설정만 보면 되어 있는 것으로 읽히는 두 자리를 각각 실제로 죽여 보고 회전시켜 확인했으며, 설정과 매체를 나눠 봐야 한다는 결론으로 닫힌다",
"missing-verification": "볼륨을 붙인 Redis 로 다시 재지 않았다. 영속화가 실제로 동작하는지는 확인하지 않았다",
"relations": [
"case:moving-the-session-left-the-tokens-behind",
"reference:look-at-the-lookup-key-before-moving-the-store"
],
"kind": "case",
"ssot-assets": [
"b5-b6-storage-and-keys"
],
"ssot-evidence": [
"raw/b5-redis-loss__04-persistence.txt",
"raw/b6-key-rotation__03-old-key-removed.txt"
],
"publication": "미작성"
}
],
"concept": [
{
"title": "세션과 인가된 클라이언트는 조회 키가 다르다",
"slug": "two-stores-two-lookup-keys",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0"
],
"basis-version": "Spring Boot 3 · Spring Security OAuth2 Client · Spring Session Redis",
"classification": "하나는 세션 id 로 다른 하나는 principal 이름으로 찾는다는 것을 알아야 저장소를 옮긴 결과를 읽을 수 있다",
"relations": [
"case:moving-the-session-left-the-tokens-behind",
"case:a-primary-key-without-the-session-id",
"decision:split-the-two-stores-and-design-each"
],
"kind": "concept",
"ssot-assets": [
"bff-store-lookup-keys"
],
"publication": "미작성"
}
],
"reference": [
{
"title": "저장소를 옮기기 전에 조회 키를 본다",
"slug": "look-at-the-lookup-key-before-moving-the-store",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"classification": "무엇으로 찾는지가 무엇을 옮겨야 하는지를 정한다는 기준이고, 세션과 토큰이 아닌 다른 짝에도 그대로 적용된다",
"scope": "서버가 든 상태를 외부 저장소로 옮기는 자리",
"exceptions": "상태가 하나뿐이고 조회 키도 하나면 이 확인이 필요 없다. 다만 자동구성이 무엇을 골랐는지는 그때도 읽는다",
"relations": [
"concept:two-stores-two-lookup-keys",
"case:a-primary-key-without-the-session-id"
],
"kind": "reference",
"publication": "미작성"
}
],
"question": [],
"decision": [
{
"title": "세션과 토큰의 저장소를 나눠 각각 설계한다",
"slug": "split-the-two-stores-and-design-each",
"readiness": "READY",
"decision-status": "ADOPTED",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b2",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"decision-evidence": [
"case:moving-the-session-left-the-tokens-behind",
"case:a-primary-key-without-the-session-id"
],
"grounds": "조회 키가 달라 한쪽을 옮겨도 다른 쪽이 따라오지 않고, 나눠 옮긴 뒤에도 남는 문제의 원인은 저장소가 아니라 스키마였다",
"classification": "한 저장소로 묶는 대안을 실제로 시도해 반쪽만 옮겨지는 것을 확인한 뒤 나누기로 정했고, 남는 비용을 함께 적었다",
"relations": [
"concept:two-stores-two-lookup-keys",
"case:a-primary-key-without-the-session-id"
],
"kind": "decision",
"publication": "미작성"
}
]
},
"topic": "where-application-state-lives"
},
"trust-handed-over-at-the-edge": {
"title": "엣지와 IdP 가 건네는 신뢰",
"readerQuestion": "엣지와 IdP 가 만든 인증 결과는 애플리케이션까지 어떻게 전달되고, 무엇이 그 전달을 끊는가?",
"kinds": {
"case": [
{
"title": "nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다",
"slug": "nginx-does-not-overwrite-a-header-it-never-sets",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b4"
],
"code": [
"proxy_set_header X-Auth-Request-Roles",
"--cookie-refresh"
],
"classification": "위조 헤더를 실제로 보내 통과하는 것을 관측하고 원인을 지우지 않은 설정에서 확정했으며, 클레임 변경이 재인증까지 반영되지 않는 것도 같은 실험에서 닫았다",
"missing-verification": "--cookie-refresh 를 켠 구성에서는 재지 않았다. 클레임 반영 지연이 얼마나 줄어드는지는 확인하지 않았다",
"relations": [
"reference:clear-the-header-before-you-trust-it",
"case:nobody-implemented-backchannel-logout"
],
"kind": "case",
"ssot-assets": [
"b4-header-trust-boundary"
],
"ssot-evidence": [
"raw/b4-edge-authorization__01-header-handling.txt"
],
"publication": "미작성"
},
{
"title": "백채널 로그아웃은 양쪽 다 없었다",
"slug": "nobody-implemented-backchannel-logout",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-c층"
],
"code": [
"backchannelLogoutUrl",
"oidcLogout"
],
"classification": "네 가지를 순서대로 확인해 설정 결손과 기능 부재가 한 경로에 겹쳐 있는 것을 갈랐고, 한쪽만 고쳐서는 되지 않는다는 결론으로 닫힌다",
"missing-verification": "양쪽을 다 구현해서 전파가 되는지 확인하지 않았다. 원인 확정까지가 이 실험의 범위다",
"relations": [
"case:nginx-does-not-overwrite-a-header-it-never-sets",
"reference:clear-the-header-before-you-trust-it"
],
"kind": "case",
"ssot-assets": [
"c2-backchannel-both-sides"
],
"ssot-evidence": [
"raw/b4-edge-authorization__01-header-handling.txt"
],
"publication": "미작성"
},
{
"title": "쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션",
"slug": "orphan-sessions-and-the-ttl-that-finds-them",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a"
],
"code": [
"--cookie-secret",
"--cookie-refresh",
"refresh:disabled"
],
"classification": "secret 교체가 티켓을 못 풀게 만든 상태에서 무엇을 지울지 고르는 방법을 TTL 역산으로 세우고 로그와 1 초 차로 교차검증해 닫았다",
"missing-verification": "--cookie-refresh 를 켠 구성에서는 이 역산이 성립하지 않는다. 그 경우의 정리 방법은 재지 않았다",
"relations": [
"case:nginx-does-not-overwrite-a-header-it-never-sets",
"reference:clear-the-header-before-you-trust-it"
],
"kind": "case",
"ssot-assets": [
"b7-cookie-session-tradeoff"
],
"ssot-evidence": [
"raw/b7a-orphan-session__01-orphan-lifecycle.txt",
"raw/b7-cookie-secret__01-deploy.txt"
],
"publication": "미작성"
}
],
"concept": [],
"question": [],
"decision": [],
"reference": [
{
"title": "믿기 전에 그 헤더를 먼저 지운다",
"slug": "clear-the-header-before-you-trust-it",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b4",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"classification": "프록시가 동명 헤더를 자동으로 덮어쓸 것이라는 전제가 틀렸다는 것을 확인했고, 다른 프록시에서도 같은 확인이 필요하다",
"scope": "엣지가 인증 결과를 헤더로 실어 업스트림에 넘기는 구성",
"exceptions": "업스트림이 헤더가 아니라 서명된 토큰을 검증하면 이 지우기가 필요 없다",
"relations": [
"case:nginx-does-not-overwrite-a-header-it-never-sets",
"case:orphan-sessions-and-the-ttl-that-finds-them"
],
"kind": "reference",
"publication": "미작성"
}
]
},
"topic": "trust-handed-over-at-the-edge"
},
"operations-that-report-success": {
"title": "성공이라고 적으면서 실패하는 운영 절차",
"readerQuestion": "운영 절차가 성공이라고 보고하는데도 의도한 일이 일어나지 않는 자리는 어디이고, 무엇으로 판정하는가?",
"kinds": {
"case": [
{
"title": "새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다",
"slug": "the-certificate-that-took-38-minutes-to-reach-the-wire",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4"
],
"code": [
"certbot-renew.service ExecStartPost",
"/etc/letsencrypt/renewal-hooks/",
"nginx 마스터·워커 PID"
],
"classification": "디스크 기록 시각과 실제 서빙 시각을 따로 재어 공백을 확정하고 원인 셋이 모두 비어 있는 것을 확인했으며, 88 일 잠복한다는 성질까지 닫았다",
"missing-verification": "실제 만료가 임박한 상태를 만들지 않았다. 이 결함이 만료로 드러나는 경로는 재지 않았다",
"relations": [
"case:a-deploy-hook-closed-the-gap-to-two-seconds",
"decision:put-the-reload-in-a-deploy-hook",
"question:does-the-renewal-timer-actually-renew"
],
"kind": "case",
"ssot-assets": [
"renewal-to-serving-gap"
],
"ssot-evidence": [
"raw/d4-certificate-renewal__07-renewal-hook-missing.txt",
"raw/d4-certificate-renewal__09-serial-timeline.txt",
"raw/d4-certificate-renewal__12-certbot-state.txt",
"raw/d4-certificate-renewal__13-verdict.txt"
],
"publication": "미작성"
},
{
"title": "deploy 훅 하나가 그 공백을 1~2초로 줄였다",
"slug": "a-deploy-hook-closed-the-gap-to-two-seconds",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"code": [
"renewal-hooks/deploy/",
"types_hash 경고"
],
"classification": "처방을 실제로 넣고 전후를 같은 방법으로 재어 확인했고, 훅 로그의 error 문구가 실패가 아니라는 것까지 갈라 닫았다",
"missing-verification": "실제 갱신 주기에서 훅이 도는 것은 확인하지 않았다. 강제 갱신으로만 검증했다",
"relations": [
"case:the-certificate-that-took-38-minutes-to-reach-the-wire",
"decision:put-the-reload-in-a-deploy-hook",
"reference:judge-a-reload-by-the-worker-pid-not-the-log"
],
"kind": "case",
"ssot-assets": [
"d4a-hook-effect"
],
"ssot-evidence": [
"raw/d4a-deploy-hook__01-hook-verified.txt",
"raw/d4a-deploy-hook__02-certbot-with-hook.txt",
"raw/d4a-deploy-hook__03-after-state.txt"
],
"publication": "미작성"
},
{
"title": "되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다",
"slug": "the-upgrade-that-would-not-roll-back",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2"
],
"code": [
"liquibase ValidationFailedException",
"databasechangelog",
"StatefulSet 롤링 업데이트"
],
"classification": "롤백 실패를 관측하고 원인을 체크섬으로 확정한 뒤, 스키마가 움직이지 않은 방향은 실제로 성공하는 것까지 확인해 조건을 붙여 닫았다",
"missing-verification": "스키마가 크게 바뀌는 메이저 업그레이드에서는 재지 않았다. 두 패치 버전 사이만 확인했다",
"relations": [
"reference:judge-a-reload-by-the-worker-pid-not-the-log",
"decision:put-the-reload-in-a-deploy-hook"
],
"kind": "case",
"ssot-assets": [
"d2-upgrade-direction"
],
"ssot-evidence": [
"raw/d4-certificate-renewal__01-certificate-state.txt"
],
"publication": "미작성"
}
],
"concept": [],
"reference": [
{
"title": "적용됐는지는 로그 문구가 아니라 상태로 판정한다",
"slug": "judge-a-reload-by-the-worker-pid-not-the-log",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"classification": "같은 절차에서 로그는 성공이라고 적는데 상태는 바뀌지 않은 자리를 두 번 만났고, 판정을 상태로 옮기는 규칙은 다른 재적재에도 적용된다",
"scope": "설정이나 인증서를 다시 읽게 하는 절차. reload · rotate · reconcile",
"exceptions": "절차가 프로세스를 완전히 교체하면 PID 비교가 판정이 되지 않는다. 그때는 적재한 값 자체를 확인한다",
"relations": [
"case:a-deploy-hook-closed-the-gap-to-two-seconds",
"case:the-upgrade-that-would-not-roll-back"
],
"kind": "reference",
"publication": "미작성"
}
],
"question": [
{
"title": "갱신 타이머가 실제 갱신에서도 도는가",
"slug": "does-the-renewal-timer-actually-renew",
"readiness": "OPEN",
"source": [
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-재보지-않은-것",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"known": "강제 갱신에서는 deploy 훅이 1~2 초 만에 reload 를 걸었다. 타이머 자체는 매번 SUCCESS 로 끝나고 있다",
"unknown": "만료 30 일 전 조건이 성립해 타이머가 실제 갱신을 수행할 때도 같은 훅이 도는가",
"next-verification": "만료 30 일 전(약 89 일 뒤)에 타이머가 돈 뒤 워커 PID 와 서빙 인증서의 일련번호를 확인한다",
"decision-criterion": "워커 PID 가 바뀌고 서빙 일련번호가 새 인증서와 같으면 닫는다. 그렇지 않으면 훅이 강제 갱신에서만 도는 것이므로 타이머 유닛 쪽에 훅을 다시 건다",
"relations": [
"case:the-certificate-that-took-38-minutes-to-reach-the-wire",
"case:a-deploy-hook-closed-the-gap-to-two-seconds"
],
"kind": "question",
"publication": "미작성"
}
],
"decision": [
{
"title": "reload 를 사람이 아니라 deploy 훅이 부르게 한다",
"slug": "put-the-reload-in-a-deploy-hook",
"readiness": "READY",
"decision-status": "ADOPTED",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"decision-evidence": [
"case:the-certificate-that-took-38-minutes-to-reach-the-wire",
"case:a-deploy-hook-closed-the-gap-to-two-seconds"
],
"grounds": "훅이 없을 때 갱신에서 서빙까지 2305 초가 걸렸고 그 reload 를 부른 것은 자동화가 아니라 사람이었다. 훅을 넣자 1~2 초가 됐다",
"classification": "사람이 치는 절차로 두는 대안을 실제 공백으로 확인한 뒤 훅으로 정했고, 훅 로그가 error 를 찍는다는 비용을 함께 적었다",
"relations": [
"case:a-deploy-hook-closed-the-gap-to-two-seconds",
"reference:judge-a-reload-by-the-worker-pid-not-the-log",
"question:does-the-renewal-timer-actually-renew"
],
"kind": "decision",
"publication": "미작성"
}
]
},
"topic": "operations-that-report-success"
},
"when-the-measurement-lies": {
"title": "측정이 거짓말하는 자리",
"readerQuestion": "무엇을 재야 잰 것이 되는가 — 주입과 관측 사이에서 측정은 어디서 거짓말하는가?",
"kinds": {
"case": [
{
"title": "주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다",
"slug": "nine-injections-that-silently-did-nothing",
"readiness": "READY",
"source": [
"final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다"
],
"code": [
"conntrack",
"kube-router FORWARD",
"PID 1 SIGKILL",
"flannel VXLAN"
],
"classification": "아홉 건이 각각 다른 이유로 실패한 것을 하나씩 확정하고, 실패한 주입과 영향 없는 주입이 구별되지 않는다는 공통 원인으로 닫았다",
"missing-verification": "아홉 건 모두를 고친 뒤 다시 돌린 것은 아니다. 일부는 다른 주입 방법으로 우회했다",
"relations": [
"reference:verify-the-injection-landed-separately-from-the-result",
"case:seventy-six-failures-that-were-not-the-servers",
"concept:the-up-metric-cannot-see-alive-but-useless"
],
"kind": "case",
"ssot-assets": [
"injection-verification",
"a5-partition-asymmetry"
],
"ssot-evidence": [
"raw/a1-jgroups-transport-block__05-conntrack-problem.txt",
"raw/a6-latency-injection__03-flannel-injection.txt"
],
"publication": "미작성"
},
{
"title": "실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다",
"slug": "seventy-six-failures-that-were-not-the-servers",
"readiness": "READY",
"source": [
"final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는"
],
"code": [
"TCP 연결수 0",
"50µs"
],
"classification": "같은 순간의 대조 폴링과 연결수·소요 시간·재현율 넷으로 귀속을 뒤집었고, 대조군이 없었으면 무엇이 됐을지까지 닫았다",
"missing-verification": "클라이언트 쪽 원인을 특정하지 않았다. 서버 탓이 아니라는 것까지가 이 확인의 범위다",
"relations": [
"reference:verify-the-injection-landed-separately-from-the-result",
"concept:the-up-metric-cannot-see-alive-but-useless",
"reference:never-subtract-values-from-two-clocks"
],
"kind": "case",
"ssot-assets": [
"measurement-control"
],
"ssot-evidence": [
"raw/d4-certificate-renewal__05-control-no-injection.txt",
"raw/d4-certificate-renewal__11-inflight-full.txt"
],
"publication": "미작성"
},
{
"title": "산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다",
"slug": "commands-written-as-prose-do-not-run",
"readiness": "READY",
"source": [
"final/document.md#결정이-지켜지는지-확인하는-방법-재현-가능성",
"final/document.md#검토한-선택지와-막힌-지점-스크립트를-쓰지-않는다"
],
"code": [
"kubectl run --rm -i",
"상주 탐침"
],
"classification": "헤드라인 수치를 만든 명령 넷이 산문이던 것을 셸 표현식으로 바꾸고 실제로 돌려 확인했으며, 그 확인에서 한 건이 깨진 것까지 닫았다",
"missing-verification": "고친 명령을 다른 환경에서 돌려 보지 않았다. 이 실험대에서만 확인했다",
"relations": [
"case:nine-injections-that-silently-did-nothing",
"reference:verify-the-injection-landed-separately-from-the-result"
],
"kind": "case",
"ssot-assets": [
"reproducibility-gap"
],
"ssot-evidence": [
"raw/a6-latency-injection__04-pool-under-load.txt"
],
"publication": "미작성"
}
],
"concept": [
{
"title": "up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다",
"slug": "the-up-metric-cannot-see-alive-but-useless",
"readiness": "READY",
"source": [
"final/document.md#검토한-선택지와-막힌-지점-관측을-어디에",
"final/document.md#결정이-지켜지는지-확인하는-방법-관측-도구는-부분집합"
],
"basis-version": "Prometheus up 지표 · Keycloak 26.7.0 /metrics · 이 실험대의 스크레이프 대상",
"classification": "프로세스가 살아 있고 /metrics 가 응답하기만 하면 1 이 된다는 것을 알아야 503 중에도 1 이던 관측을 읽을 수 있다",
"relations": [
"case:seventy-six-failures-that-were-not-the-servers",
"concept:readiness-hides-the-broken-node",
"reference:verify-the-injection-landed-separately-from-the-result"
],
"kind": "concept",
"publication": "미작성"
}
],
"reference": [
{
"title": "예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다",
"slug": "verify-the-injection-landed-separately-from-the-result",
"readiness": "READY",
"source": [
"final/document.md#결국-지키려던-것은-무엇이었나",
"final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다",
"final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는"
],
"classification": "세 규칙이 각각 어겨졌을 때 무엇이 틀렸는지가 이 실험대의 이력으로 남아 있고, 장애 주입을 하는 다른 실험에도 그대로 적용된다",
"scope": "상태를 일부러 망가뜨리고 그 영향을 재는 모든 실험",
"exceptions": "주입 없이 평시를 관측하는 측정에는 첫 두 규칙이 걸리지 않는다. 대조군 규칙은 그때도 걸린다",
"relations": [
"case:nine-injections-that-silently-did-nothing",
"case:seventy-six-failures-that-were-not-the-servers",
"concept:the-up-metric-cannot-see-alive-but-useless"
],
"kind": "reference",
"publication": "미작성"
},
{
"title": "두 시계에서 온 값을 빼지 않는다",
"slug": "never-subtract-values-from-two-clocks",
"readiness": "READY",
"source": [
"final/document.md#결정이-지켜지는지-확인하는-방법-두-시계"
],
"classification": "보정 없이 뺀 값이 자릿수가 아니라 방향까지 틀리는 것을 실제로 만났고, 시각을 다루는 다른 측정에도 같은 확인이 필요하다",
"scope": "서로 다른 기계에서 온 타임스탬프의 차이를 재는 자리",
"exceptions": "같은 기계의 단조 시계로만 잰 구간에는 걸리지 않는다",
"relations": [
"case:seventy-six-failures-that-were-not-the-servers",
"case:commands-written-as-prose-do-not-run"
],
"kind": "reference",
"publication": "미작성"
}
],
"question": [],
"decision": []
},
"topic": "when-the-measurement-lies"
}
},
"candidates": [
{
"id": "SSOT-session-sharing-is-the-database-not-replication",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#코드보다-먼저-드러난-문제-전제가-무너졌다",
"final/document.md#선택의-이유와-지킨-경계-a1"
],
"summary": "클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:session-sharing-is-the-database-not-replication",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-cache-temperature-decides-the-outcome",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a7-a7a"
],
"summary": "같은 설정이 캐시 온도만으로 세 가지 답을 냈다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:cache-temperature-decides-the-outcome",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-rolling-restart-keeps-sessions-drops-cache",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a8"
],
"summary": "롤링 재시작은 세션을 남기고 캐시만 지웠다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:rolling-restart-keeps-sessions-drops-cache",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-persistent-vs-volatile-user-sessions",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#코드보다-먼저-드러난-문제-버전-조건"
],
"summary": "persistent-user-sessions 가 세션의 거처를 정한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "concept:persistent-vs-volatile-user-sessions",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-state-the-version-and-the-setting-with-the-result",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#코드보다-먼저-드러난-문제-버전-조건",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건"
],
"summary": "버전과 설정을 결과와 함께 적는다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:state-the-version-and-the-setting-with-the-result",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-four-logins-that-returned-200-and-vanished",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a2-a3"
],
"summary": "200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:four-logins-that-returned-200-and-vanished",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-two-ways-to-lose-a-node",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a4"
],
"summary": "노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:two-ways-to-lose-a-node",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-200ms-of-delay-became-22-seconds",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a6"
],
"summary": "200 밀리초를 넣었더니 응답이 22.2 초가 됐다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:200ms-of-delay-became-22-seconds",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-readiness-hides-the-broken-node",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#검토한-선택지와-막힌-지점-관측을-어디에"
],
"summary": "readiness 가 깨진 노드를 시야에서 먼저 치운다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "concept:readiness-hides-the-broken-node",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-most-of-an-outage-is-noticing",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a4"
],
"summary": "장애 시간의 대부분은 알아채는 데 걸린다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:most-of-an-outage-is-noticing",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-moving-the-session-left-the-tokens-behind",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b1"
],
"summary": "세션만 Redis 로 옮기자 토큰이 따라오지 않았다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:moving-the-session-left-the-tokens-behind",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-a-primary-key-without-the-session-id",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b2"
],
"summary": "기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:a-primary-key-without-the-session-id",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-the-winner-of-the-rotation-race-also-loses",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b3"
],
"summary": "회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:the-winner-of-the-rotation-race-also-loses",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-persistence-without-a-volume-and-rotation-without-overlap",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b5-b6"
],
"summary": "볼륨 없는 영속화와 유예 없는 키 회전",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:persistence-without-a-volume-and-rotation-without-overlap",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-two-stores-two-lookup-keys",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0"
],
"summary": "세션과 인가된 클라이언트는 조회 키가 다르다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "concept:two-stores-two-lookup-keys",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-look-at-the-lookup-key-before-moving-the-store",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"summary": "저장소를 옮기기 전에 조회 키를 본다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:look-at-the-lookup-key-before-moving-the-store",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-split-the-two-stores-and-design-each",
"kindCandidate": "DECISION",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b2",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"summary": "세션과 토큰의 저장소를 나눠 각각 설계한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "decision:split-the-two-stores-and-design-each",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-nginx-does-not-overwrite-a-header-it-never-sets",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b4"
],
"summary": "nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:nginx-does-not-overwrite-a-header-it-never-sets",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-nobody-implemented-backchannel-logout",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-c층"
],
"summary": "백채널 로그아웃은 양쪽 다 없었다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:nobody-implemented-backchannel-logout",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-orphan-sessions-and-the-ttl-that-finds-them",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a"
],
"summary": "쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:orphan-sessions-and-the-ttl-that-finds-them",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-clear-the-header-before-you-trust-it",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b4",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"summary": "믿기 전에 그 헤더를 먼저 지운다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:clear-the-header-before-you-trust-it",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-the-certificate-that-took-38-minutes-to-reach-the-wire",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4"
],
"summary": "새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:the-certificate-that-took-38-minutes-to-reach-the-wire",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-a-deploy-hook-closed-the-gap-to-two-seconds",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"summary": "deploy 훅 하나가 그 공백을 1~2초로 줄였다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:a-deploy-hook-closed-the-gap-to-two-seconds",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-the-upgrade-that-would-not-roll-back",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2"
],
"summary": "되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:the-upgrade-that-would-not-roll-back",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-judge-a-reload-by-the-worker-pid-not-the-log",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"summary": "적용됐는지는 로그 문구가 아니라 상태로 판정한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:judge-a-reload-by-the-worker-pid-not-the-log",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-does-the-renewal-timer-actually-renew",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-재보지-않은-것",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"summary": "갱신 타이머가 실제 갱신에서도 도는가",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "question:does-the-renewal-timer-actually-renew",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-put-the-reload-in-a-deploy-hook",
"kindCandidate": "DECISION",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a"
],
"summary": "reload 를 사람이 아니라 deploy 훅이 부르게 한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "decision:put-the-reload-in-a-deploy-hook",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-nine-injections-that-silently-did-nothing",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다"
],
"summary": "주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-seventy-six-failures-that-were-not-the-servers",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는"
],
"summary": "실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:seventy-six-failures-that-were-not-the-servers",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-commands-written-as-prose-do-not-run",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#결정이-지켜지는지-확인하는-방법-재현-가능성",
"final/document.md#검토한-선택지와-막힌-지점-스크립트를-쓰지-않는다"
],
"summary": "산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:commands-written-as-prose-do-not-run",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-the-up-metric-cannot-see-alive-but-useless",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#검토한-선택지와-막힌-지점-관측을-어디에",
"final/document.md#결정이-지켜지는지-확인하는-방법-관측-도구는-부분집합"
],
"summary": "up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "concept:the-up-metric-cannot-see-alive-but-useless",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-verify-the-injection-landed-separately-from-the-result",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#결국-지키려던-것은-무엇이었나",
"final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다",
"final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는"
],
"summary": "예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:verify-the-injection-landed-separately-from-the-result",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-never-subtract-values-from-two-clocks",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#결정이-지켜지는지-확인하는-방법-두-시계"
],
"summary": "두 시계에서 온 값을 빼지 않는다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "reference:never-subtract-values-from-two-clocks",
"reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다"
},
{
"id": "SSOT-a1-transport-vs-discovery",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a1"
],
"summary": "7800 을 끊었더니 세션 공유는 안 깨지고 로그아웃 전파가 깨졌다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:session-sharing-is-the-database-not-replication",
"reason": "SSOT 가 이 결과를 A-0 의 확증으로 적는다. 발견은 DB 를 쓰고 전송은 7800 을 쓴다는 같은 사실의 두 면이다"
},
{
"id": "SSOT-a5-asymmetric-partition",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a5"
],
"summary": "비대칭 분단은 클러스터를 가르지 못하고 전면 장애 경로도 없다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:two-ways-to-lose-a-node",
"reason": "결론이 「전면 장애가 없다」라 독립 기록으로 읽을 이유가 약하다. 여기서 나온 주입 실패 셋은 측정 Topic 의 Case 가 담는다"
},
{
"id": "SSOT-c1-sso-across-apps",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-c층"
],
"summary": "두 앱이 같은 realm 으로 SSO 되는 것을 확인했다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nobody-implemented-backchannel-logout",
"reason": "SSO 가 되는 것 자체는 기대한 동작이고, 이 실험이 남긴 것은 그 옆에서 관측한 로그아웃 미전파다"
},
{
"id": "SSOT-d1-backup-restore",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2"
],
"summary": "백업과 복구 — 본문 RTO 30초와 타임라인 41초가 어긋났다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:seventy-six-failures-that-were-not-the-servers",
"reason": "SSOT 가 이 건을 문서와 증거가 어긋난 사례 표에 넣었고, 그 표가 측정 Topic 의 근거다"
},
{
"id": "SSOT-d3-secret-base64",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d3"
],
"summary": "kubectl get secret 의 base64 는 암호화가 아니라 인코딩이다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "널리 알려진 사실이고 이 실험이 더한 것은 두 노출 경로를 확인한 것까지다. 규칙으로 빼면 다른 프로젝트에 줄 것이 없다"
},
{
"id": "SSOT-lab-topology",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#문제를-어렵게-만든-제약-실험대"
],
"summary": "베어메탈 한 대 위의 VM 두 대와 인증서 SAN 셋",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "측정 환경의 구성이다. 각 Case 의 검증 환경이 필요한 만큼을 이미 담는다"
},
{
"id": "SSOT-guest-host-sudo",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#문제를-어렵게-만든-제약-sudo"
],
"summary": "호스트의 sudo 가 비밀번호를 요구해 조용히 실패했다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "아홉 건 표의 8 번이 이 사건이다. 표 밖으로 빼면 같은 이야기가 둘이 된다"
},
{
"id": "SSOT-observation-three-points",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#검토한-선택지와-막힌-지점-관측을-어디에"
],
"summary": "관측 지점을 외부·지표·데이터베이스 셋으로 늘렸다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "reference:verify-the-injection-landed-separately-from-the-result",
"reason": "주입이 걸렸는지 결과와 따로 확인한다는 규칙이 이 세 지점을 요구한다. 그 규칙의 한 절이다"
},
{
"id": "SSOT-techviz-remake",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#이-기록에-아직-없는-것"
],
"summary": "손그림 28 건을 techviz 로 다시 만들며 lint 가 잡은 두 가지",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 저장소의 그림 제작 절차이지 이 주제의 독자 질문에 답하지 않는다. 후보 범위 밖이다"
}
],
"unlisted": [],
"history": {},
"counts": {
"topics": 6,
"nodes": 33,
"written": 0,
"unwritten": 33,
"unlisted": 0,
"candidates": 42
},
"unassignedSsotAssets": {
"d3-secret-exposure": "D-3 의 base64 관측은 KEEP_IN_SSOT 로 두어 글감이 없다. 그림도 함께 남긴다",
"wrong-predictions": "틀린 예측 다섯을 한 자리에 모은 그림이라 특정 Case 에 붙지 않는다. 주제 전체의 그림이다",
"open-questions-answered": "앞 프로젝트가 남긴 열린 질문 넷에 답한 것을 모은 그림이라 이 프로젝트의 어느 한 글감에 붙지 않는다",
"not-applicable-conditions": "적용되지 않는 조건을 모은 그림이고 그것을 담는 것은 Reference 다. Reference 에는 본문이 없어 그림을 렌더링할 자리가 없다"
}
}