Files
document-haness/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json
T
DongHyeonkaandClaude Opus 5 7661f08d58 feat(b0): B-0 을 주입부터 원상복구까지 끝까지 밟는다 — setup 35편 완주
남아 있던 한 편이다. keycloak-pattern 에 이 실험과 무관한 변경이 39개(미추적
3개 더) 있어 따로 떼어낸 작업 트리에서 파일 넷을 고치고 이미지를 구워 두
노드에 넣었다. 원래 작업 트리는 한 글자도 안 바뀌었다.

자동구성이 고른 것
- 빈 321개 — 이 편이 예측한 숫자와 같다
- authorizedClientService -> InMemoryOAuth2AuthorizedClientService
- SessionRepository 빈이 하나도 없다. Spring Session 이 아예 안 걸렸고
  세션을 톰캣이 자기 메모리의 HttpSession 에 직접 들고 있다
- 타입에 Redis 나 Jdbc 가 든 빈 0개

로그인이 replica 개수에서 갈린다
- 원 가이드는 브라우저로 한 번 해 보라고 적는데 그러면 한 번의 결과만 남는다.
  쿠키 병을 쓰는 curl 로 같은 흐름을 여섯 번씩 돌려 양쪽을 같은 수로 견줬다
- replica 2 — 여섯 번 다 /login?error
- replica 1 — 여섯 번 다 200. 이쪽이 대조군이라, 거기서도 실패했으면 가설이
  아니라 시험 방법을 의심해야 한다
- 토큰 경계는 원래 실행과 한 글자도 다르지 않았다

원상복구를 같은 명령으로 확인했다
- 빈 321 → 437, InMemory → Jdbc, Redis/Jdbc 빈 0 → 58, Redis 세션 키 6
- replica 2 에서 로그인이 다시 되는 것이 복구 판정이다

그리고 ⑤ 의 `git diff --stat` 점검은 작업 트리가 깨끗할 때만 성립한다는 것을
적었다. git worktree 로 떼어내면 그 점검이 다시 살아난다.

관문: check_body PASS · check_prose error 0 · check_evidence 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:16:45 +09:00

4073 lines
271 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"schemaVersion": 4,
"project": "keycloak-session-store",
"ssot": "final/document.md",
"sourceRepository": {
"path": "/home/donghyeon/workspace/keycloak-pattern",
"revision": "9465582b5d1630eb4ae7c4e078021486919bf6b6",
"verified": "반입할 때 적어 둔 source/.source-revision 이 이 커밋이고 저장소에 실재한다 — 「chore: 실행 환경 구성 문서 추가 및 수정」, 2026-09-10. **전에 이 칸은 cdac9b81 이었고 그것은 틀렸다** (2026-09-17 대조) — 그 커밋은 2026-09-04 이고 반입본 306개 가운데 docs/guides/** 28개가 거기에 아예 없다. 가이드는 그 엿새 뒤 6f6ab86 에서 들어왔다. **그런데 반입한 바이트는 이 커밋과도 같지 않다** — 9465582b 와 같은 것은 276개이고 29개가 다르다. 같은 306개를 저장소의 **작업 트리**와 견주면 297개가 같다. HEAD 에서 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 6f6ab86 도 28개가 어긋났다. **맞는 커밋은 없다** — 반입은 커밋이 아니라 **그 시점의 작업 트리**(미커밋 수정이 있던 상태)에서 떠 온 것이다. 지금도 저장소는 그 파일들을 M 으로 낸다. 작업 트리와 남은 차이 8개 가운데 5개가 그 M 목록에 있고(반입 뒤 저장소가 더 고쳤다), deploy/lab/host/nginx-keycloak-lab.conf 는 저장소에서 deploy/lab/edge/ 로 옮겨져 반입본에만 남았다. **이 커밋은 「반입 시점의 HEAD」라는 뜻이지 「반입한 바이트가 이것이다」가 아니다**"
},
"ssotSha256": "02d7dcfb862178b4b0a49453bf8ef0693226d7ab52ca44d695d9b988b471c265",
"sourceRevision": "keycloak-session-lab@2026-09",
"generatedAt": "2026-09-17",
"candidateScope": {
"document": "final/document.md",
"sections": [
"코드보다 먼저 드러난 문제",
"문제를 어렵게 만든 제약",
"검토한 선택지와 막힌 지점",
"선택의 이유와 지킨 경계",
"선택이 코드와 흐름에 반영되는 방식",
"결정이 지켜지는지 확인하는 방법",
"얻은 것, 잃은 것, 적용하지 않을 때",
"결국 지키려던 것은 무엇이었나",
"2026-09-11 추가 측정 — 워크로드 종류가 클러스터에 미치는 영향",
"재현 가이드 26편과, 그것을 따라가다 드러난 결함",
"실험대가 쓴 개념 — 조사한 것",
"A층 재현 절차 — 열 편을 직접 치는 순서",
"B층 재현 절차 — 아홉 편을 직접 치는 순서",
"C층 재현 절차 — 두 편을 직접 치는 순서",
"D층 재현 절차 — 다섯 편을 직접 치는 순서"
],
"excluded": [
"자료",
"이 기록에 아직 없는 것"
],
"note": "처음부터 한 편으로 쓴 글이라 제2부가 없다. 맨 뒤 두 절은 증거 목록과 그림 제작 메모라 후보 자리가 아니다. 2026-09-11 에 SSOT 로 들어온 h2 둘(추가 측정 · 재현 가이드 감사)을 범위에 더했다 — 둘 다 실험을 돌리고 잰 검증 기록이라 후보 자리다. 「실험대가 쓴 개념 — 조사한 것」은 이번 분해의 범위가 아니라 sections 에도 excluded 에도 넣지 않았다. 2026-09-12 에 Studio 의 여섯 번째 종류 SETUP(화면 이름 「환경 구성」)을 화면에서 확인해 스킬과 스크립트에 넣었고, 그 다음 날 A층 열 편의 절차가 SSOT 로 들어와 h2 하나를 범위에 더했다 — `source/docs/guides/experiments/` 의 재현 가이드 26편은 발견이 아니라 「직접 쳐서 다시 만드는 절차」인데 담을 종류가 없어 SSOT 에도 계약에도 없었다. 이 h2 에서 나오는 후보는 종류가 전부 SETUP 이고, 같은 실험의 발견은 이미 A층 Case 들이 담고 있으므로 겹치는 것은 MERGE_INTO 가 아니라 relations 로 잇는다. B·C·D 층 16편은 아직 SSOT 에 없어 이번 범위가 아니다. 그 다음 B층 아홉 편의 절차가 SSOT 로 들어와 h2 하나를 더 범위에 더했다 — A층과 같은 뼈대(기준선 → 주입 → 주입 검증 → 관찰 → 복구)이지만 건드리는 것이 클러스터·네트워크·DB 가 아니라 애플리케이션 소스와 매니페스트이고, 되돌리기가 `git checkout` 뒤 재빌드와 두 노드 재import 까지 간다. 이 h2 에서 나오는 후보는 종류가 전부 SETUP 이고, 같은 실험의 발견은 이미 B층 Case 여섯이 담고 있으므로 겹치는 것은 MERGE_INTO 가 아니라 relations 로 잇는다. C·D 층 일곱 편은 아직 SSOT 에 없어 이번 범위가 아니다. 그 다음 C·D 층 일곱 편의 절차가 SSOT 로 들어와 h2 둘을 더 범위에 더했다 — 뼈대는 앞의 두 층과 같은 「기준선 → 주입 → 주입 검증 → 관찰 → 복구」 이지만 C층이 건드리는 것은 두 앱의 세션이고 D층이 건드리는 것은 운영 절차 자체(백업·판올림·비밀·인증서 갱신)다. 이 h2 둘에서 나오는 후보도 종류가 전부 SETUP 이고, 같은 실험의 발견은 이미 C·D층 Case 다섯이 담고 있으므로 겹치는 것은 MERGE_INTO 가 아니라 relations 로 잇는다. 층마다 주제 하나로 둔 것도 A·B 와 같다 — C 는 두 편뿐이지만 독자 질문이 D 와 다르다. 이로써 `source/docs/guides/experiments/` 의 재현 가이드 26편이 전부 SSOT 에 들어왔다. ── 「실험대가 쓴 개념 — 조사한 것」(1,474행·여덟 층)을 범위에 더했다. 그전까지 이 h2 는 sections 에도 excluded 에도 없어서 범위 검사가 양쪽 어디로도 걸지 못했고, 그래서 어느 항목도 처분을 받은 적이 없다. 범위로 넣은 까닭은 이것이 제2부(모듈 분석 전문)나 제3부(분석 재료)가 아니라 이 실험대에서 직접 읽은 값이기 때문이다 — conntrack 표 311/131072, 유닛 다섯의 Type 과 KillMode, nginx cgroup 의 PID 와 카운터, SCT 타임스탬프 둘, 게스트 두 대의 RSS 와 available 이 전부 이 실험대에서 뽑은 것이라고 「이 조사가 선 근거」가 적는다. 근거로 빼는 쪽도 성립한다 — 이 절은 계약이 선 뒤에 쓰였고 스스로 「각 주제가 서 있는 바닥」이라고 적는다. 그래도 범위로 둔 것은, 빼 두면 이 절에 나중에 붙는 것도 영영 판정되지 않아 지금 고치는 상태가 그대로 다시 생기기 때문이다. 대신 선별 결과를 층마다 대장에 적었다 — 후보 22건이고 PROMOTE 는 0건이다. 여덟 층은 이미 쓰인 기록들이 필요한 만큼씩 흡수해 간 바닥이라, 지금 독립 기록으로 올리면 같은 문장이 두 곳에 생긴다. 0층의 아래쪽(KVM ioctl·VMX·virtio 규격·vhost-user·VFIO)은 SSOT 가 스스로 「이 실험대에서 잰 것이 아니라 문서에서 옮긴 것」이라고 적고 virtualization 프로젝트가 그것을 정의로 담고 있어, 저쪽이 정의이고 이쪽이 측정이라는 앞 단계의 판정 그대로 KEEP_IN_SSOT 로 둔다. excludedAnchorPattern 도 이번에 처음 적었다 — 없으면 「범위 밖에서만 나온 글감」 검사가 통째로 꺼진 채였다. 지금 excluded 인 두 절만 가리키고, 이 패턴으로 걸리는 기존 글감은 없다.",
"excludedAnchorPattern": "#(자료|이-기록에-아직-없는-것)(-|$)"
},
"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": "Keycloak 두 노드가 같은 세션을 읽는 경로",
"readerQuestion": "두 노드가 같은 세션을 쓰는 것은 복제 때문인가?",
"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": "초안",
"file": "session-custody-across-nodes/case/case-session-sharing-is-the-database-not-replication.md",
"status": "게시 전",
"studioId": "",
"assets": [
"session-sharing-path",
"a1-transport-vs-discovery"
],
"assetFiles": [
"session-sharing-path",
"a1-transport-vs-discovery"
],
"evidenceFiles": [
"../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt",
"../../../final/evidence/raw/a1-jgroups-transport-block__10-logout-not-propagated.txt"
]
},
{
"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": "초안",
"file": "session-custody-across-nodes/case/case-cache-temperature-decides-the-outcome.md",
"status": "게시 전",
"studioId": "",
"assets": [
"cache-temperature-outcomes"
],
"assetFiles": [
"cache-temperature-outcomes"
],
"evidenceFiles": [
"../../../final/evidence/raw/a7a-volatile-cause__01-cause-determined.txt",
"../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt"
]
},
{
"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": "초안",
"file": "session-custody-across-nodes/case/case-rolling-restart-keeps-sessions-drops-cache.md",
"status": "게시 전",
"studioId": "",
"assets": [
"a8-cache-vs-session"
],
"assetFiles": [
"a8-cache-vs-session"
],
"evidenceFiles": [
"../../../final/evidence/raw/a8-rolling-restart__01-restart-availability.txt",
"../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt"
]
}
],
"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": "초안",
"file": "session-custody-across-nodes/concept/concept-persistent-vs-volatile-user-sessions.md",
"status": "게시 전",
"studioId": "",
"assets": [
"version-conditional-results"
],
"assetFiles": [
"version-conditional-results"
],
"evidenceFiles": [
"../../../final/evidence/raw/session-replication__01-cross-node-session.txt",
"../../../final/evidence/raw/a7-volatile-comparison__01-switch-to-volatile.txt",
"../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt",
"../../../final/evidence/raw/a7-volatile-comparison__04-a1-rerun-partition.txt",
"../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt",
"../../../final/evidence/raw/a7-volatile-comparison__03-a8-rerun-restart.txt",
"../../../final/evidence/raw/a2-database-loss__03-four-paths.txt",
"../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt"
]
}
],
"reference": [
{
"title": "버전과 설정을 결과와 함께 적는다",
"slug": "state-the-version-and-the-setting-with-the-result",
"readiness": "READY",
"source": [
"final/document.md#코드보다-먼저-드러난-문제-버전-조건",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건"
],
"classification": "같은 제품의 같은 실험이 설정 하나로 뒤집히는 것을 세 건(A-1·A-2·A-8)에서 확인했다. 본 것은 Keycloak 한 제품의 `persistent-user-sessions` 하나뿐이고, 다른 제품의 기본값 변경은 이 프로젝트가 재지 않았다 — 규칙은 「버전과 설정을 결과와 함께 적어라」이지 「다른 제품도 이렇게 뒤집힌다」가 아니다",
"scope": "기본값이 메이저 버전 사이에 바뀐 제품을 측정해 결과를 적는 자리. 이 프로젝트의 근거는 Keycloak 26 사례 하나다",
"exceptions": "측정 대상이 그 설정에 걸리지 않는 경로라면 조건을 달지 않아도 된다. 무엇이 걸리는지를 먼저 확인한다",
"relations": [
"concept:persistent-vs-volatile-user-sessions",
"case:cache-temperature-decides-the-outcome"
],
"kind": "reference",
"publication": "초안",
"file": "session-custody-across-nodes/reference/reference-state-the-version-and-the-setting-with-the-result.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/a7-volatile-comparison__01-switch-to-volatile.txt",
"../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt",
"../../../final/evidence/raw/a7-volatile-comparison__04-a1-rerun-partition.txt",
"../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt",
"../../../final/evidence/raw/a7-volatile-comparison__03-a8-rerun-restart.txt",
"../../../final/evidence/raw/a2-database-loss__03-four-paths.txt",
"../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt"
]
}
],
"question": [],
"decision": [],
"setup": [
{
"title": "세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다",
"kind": "setup",
"slug": "reproduce-a0-session-sharing-path",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-0",
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "**열 편의 첫 편이고 뒤의 아홉이 이 절차의 결과 위에 선다** — A-2 와 A-3 과 A-8 이 전제에 「A-0 을 먼저 한다」를 적는다. 세션 테이블을 비우고 StatefulSet 을 재시작해 출발값을 0 으로 만든 뒤 상주 탐침 파드에서 시험 넷을 차례로 친다 — 교차 노드 사용 · 캐시 계수기 델타 · 엔트리 소유 · PostgreSQL 문장 로깅으로 SQL 포획. **읽는 사람이 그대로 치는 명령이라 Setup 이다**. `kubectl`·`psql`·`curl` 이 40분어치 이어지고 Case 의 평문 한 칸에 담으면 복사가 안 된다. **순서가 결과를 바꾸는 곳이 셋**(observed) — DB 만 지우고 파드를 재시작하지 않으면 캐시 합계 19 와 DB 총계 15 가 어긋나고, 재시작 뒤 파드 IP 를 다시 잡지 않으면 아무 데도 안 닿는 것을 「복제 실패」로 읽으며, refresh token 은 회전하므로 **반대편 노드에 먼저** 써야 시험군이 남는다. **탐침 선택이 곧 측정 설계다** — 첫 판본은 `userinfo` 로 쟀고 `403` 을 복제 실패로 읽을 뻔했는데 발급 노드도 `403` 이었고 원인은 `openid` scope 가 없는 것이었다. **판정은** 반대편 캐시가 전부 `+0` 인 것, 엔트리가 대각선으로 `7 + 5 = 12` 로 DB 총계와 맞는 것, `keycloak-1` 이 날린 SQL 여덟 줄이다. **유효 범위** — 캐시 설정은 파일에서 읽을 수 없고(`cache-ispn.xml` 에는 `<transport/>` 뿐이다) 이 판정은 동작을 측정해서 얻은 것이다. Prometheus 출력을 자르는 `tr`·`grep` 줄과 JWT 에서 sid 를 뽑는 `sed` 줄은 가이드가 미검증으로 표시했다(unknown).",
"relations": [
"case:session-sharing-is-the-database-not-replication",
"concept:persistent-vs-volatile-user-sessions",
"reference:verify-the-injection-landed-separately-from-the-result",
"setup:reproduce-a1-jgroups-transport-block",
"setup:reproduce-a3-database-crash"
],
"publication": "게시됨",
"file": "session-custody-across-nodes/setup/setup-reproduce-a0-session-sharing-path.md",
"status": "게시 전",
"studioId": "4f56ed58-fc82-4ed1-ac87-c356b30c34f7",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다",
"kind": "setup",
"slug": "reproduce-a1-jgroups-transport-block",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-1"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "NetworkPolicy 로 7800 만 빼고 8080·9000 을 허용해 **디스커버리는 살리고 트랜스포트만** 끊는 절차다. **주입을 넣은 것과 걸린 것이 여기서 처음 갈린다** — 정책을 걸어도 25분 내내 `vendor_cluster_size` 가 2 였고, `/proc/net/tcp6` 의 `01`(ESTABLISHED)과 `conntrack -L` 이 까닭을 댄다. **순서가 결과를 바꾸는 곳이 둘**(observed) — 9000 을 안 열면 readiness 가 실패해 kubelet 이 파드를 죽여 엉뚱한 이유로 클러스터가 깨지고, `conntrack -D` 는 `-L` 출력의 src·dst·sport·dport 를 **그대로** 옮겨야 지워진다(`--dport 7800` 만 주면 0건이다). **판정은** `coord = t` 가 두 줄인 것 하나로 끝난다 — 로그를 두 번 긁는 것보다 짧다. **이 절차가 확정하지 못한 것** — conntrack 삭제만으로 분단이 만들어지는지. 해설이 「3분 뒤 분단」이라고 썼다가 정정했고 실제 하락은 파드 재시작 4초 뒤였다. 그래서 분단을 확실히 만드는 단계는 정책이 걸린 채 파드를 지우는 것이다. `ssh kc-lab-2` 로 들어가 `conntrack` 을 따로 치는 두 단계 형태는 이 실험대에서 치지 않았다(unknown).",
"relations": [
"case:session-sharing-is-the-database-not-replication",
"case:nine-injections-that-silently-did-nothing",
"concept:readiness-hides-the-broken-node",
"case:commands-written-as-prose-do-not-run",
"setup:reproduce-a0-session-sharing-path",
"setup:reproduce-a5-asymmetric-partition"
],
"publication": "게시됨",
"file": "session-custody-across-nodes/setup/setup-reproduce-a1-jgroups-transport-block.md",
"status": "게시 전",
"studioId": "95e29500-b535-4246-86db-d569ed814904",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다",
"kind": "setup",
"slug": "reproduce-a7-volatile-comparison",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1",
"persistent-user-sessions = v1"
],
"classification": "A-0·A-1·A-2·A-8 을 **옛 기본값 위에서 그대로 다시 치는** 절차다. 그 넷을 먼저 해 두지 않으면 「뒤집혔다」가 보이지 않는다. **끌 수 있는지를 먼저 확인한다** — `kc.sh build --help-all` 의 기능 목록에 `persistent-user-sessions[:v1]` 이 있어야 하고, 없으면 그 버전에서는 이 절차를 할 수 없으며 그 자체가 답이다. **전환 전에 세션 테이블을 비운다** — volatile 은 옛 행을 지우지 않으므로 빼먹으면 「전환이 안 됐다」로 잘못 읽는다. **빌드 옵션이라 재빌드가 일어나 롤아웃이 오래 걸린다** — `--timeout=500s` 를 준다. **args 문자열만으로는 부족하다** — 로그인 5회 뒤 `offline_user_session` 이 `(0 rows)` 인 것이 전환의 유일한 확실한 증거다. **캐시 엔트리 수로는 두 모드를 구별할 수 없다** — persistent 와 volatile 이 똑같이 `5 / 0` 을 낸다. **원복을 잊으면 이후 실험이 전부 오염된다** — args 와 매니페스트를 둘 다 되돌리고 로그인 뒤 행이 생기는지로 확인한다. **여기서 잰 `① 500 · ② 200` 을 그대로 표로 옮기면 안 된다** — 조건부이고 `setup:reproduce-a7a-volatile-cause` 가 그 셋을 가른다.",
"relations": [
"case:cache-temperature-decides-the-outcome",
"concept:persistent-vs-volatile-user-sessions",
"reference:state-the-version-and-the-setting-with-the-result",
"setup:reproduce-a7a-volatile-cause",
"setup:reproduce-a8-rolling-restart",
"setup:reproduce-a1-jgroups-transport-block"
],
"publication": "게시됨",
"file": "session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md",
"status": "게시 전",
"studioId": "b8d7db33-afdc-4e49-9eab-ed4edbbe398e",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다",
"kind": "setup",
"slug": "reproduce-a7a-volatile-cause",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1",
"persistent-user-sessions = v1"
],
"classification": "**A-7 과 나눠 둔 까닭은 주입과 복구가 따로 서기 때문이다** — 여기는 주입이 셋(문장 로깅 · volatile 전환 · DB 정지)이고 복구도 셋이며, 계기가 `iptables` 가 아니라 **표식과 문장 로그**다. 표식 → 요청 → 표식 을 손으로 넣고 `awk '/A/,/B/'` 로 구간을 잘라 본다. **표식을 셸 함수로 감싸지 않는다** — 원 실행의 `m()` 은 출력을 `/dev/null` 로 버려 표식이 로그에 들어갔는지 확인하지 않고 넘어갔다(observed). **`grep -v JGROUPS_PING` 으로 거르기 전에 소음을 한 번 본다** — 5초마다 폴링한다. **캐시를 식히는 방법은 `rollout restart` 하나뿐이고**, 재현 셋 사이마다 그것을 건너뛰면 세 상태가 하나로 뭉개져 전부 `200/200` 이 된다. 재현 A 는 재시작과 DB 정지 **사이에 아무 요청도 보내지 않고**, B 는 **로그인만 한 번** 하고, C 는 refresh 를 3회 미리 돌린다. **문장 로깅을 끄지 않으면 다음 실험의 측정값이 바뀐다** — A-3 은 초당 14건으로 로그인을 도는데 로그가 폭주해 크래시 타이밍이 달라진다. **이 편의 시각은 UTC 다.** **재지 않은 것** — `CLIENT_SCOPE_CLIENT` 결과의 캐시가 얼마나 오래 더운지.",
"relations": [
"case:cache-temperature-decides-the-outcome",
"concept:persistent-vs-volatile-user-sessions",
"setup:reproduce-a7-volatile-comparison",
"reference:verify-the-injection-landed-separately-from-the-result"
],
"publication": "게시됨",
"file": "session-custody-across-nodes/setup/setup-reproduce-a7a-volatile-cause.md",
"status": "게시 전",
"studioId": "21dce25a-a165-47dc-bb40-2ed9f6f9efea",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다",
"kind": "setup",
"slug": "reproduce-a8-rolling-restart",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-8"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "**파괴적이지 않아서 더 조심하는 절차다** — 재는 것이 「깨졌나」가 아니라 「안 깨졌나」라 측정을 잘못하면 안 깨진 것처럼 보이기 쉽고, 원래 실행이 실제로 그랬다. **함정은 토큰을 파드 안에 담는 단계에 있다** — 첫 재현 절차에 `/tmp/rt` 를 만드는 줄이 없어 빈 문자열이 `refresh_token=` 으로 전송됐는데 `400` 이 아니라 통과한 것처럼 보였고 아무 에러도 안 났다. `wc -c < /tmp/rt` 한 번이 이 시험 전체를 지킨다. **탐침은 StatefulSet 밖에 두고 재시작 내내 유지한다** — 다시 띄우면 `/tmp/rt` 와 `/tmp/sid` 가 같이 사라지므로 새 파드 IP 는 명령줄로 넘긴다. **대조군 refresh 뒤에 `/tmp/rt` 를 다시 채운다** — 토큰이 회전했으므로 안 채우면 뒤의 `400` 이 재시작 탓인지 재사용 탓인지 갈리지 않는다. **재시작 여부는 `RESTARTS` 가 아니라 `AGE` 로 본다** — `rollout restart` 는 파드를 교체하므로 카운터는 0 에서 시작하고, 두 파드의 나이가 `44s`/`66s` 로 다른 것이 한 번에 하나씩 내렸다는 증거다. **가용성 루프를 재시작보다 먼저 띄운다.** **말할 수 있는 문장** — 「무중단이었다」가 아니라 「5초 해상도에서 끊김이 관측되지 않았다」이고 표본은 9개다.",
"relations": [
"case:rolling-restart-keeps-sessions-drops-cache",
"concept:persistent-vs-volatile-user-sessions",
"case:commands-written-as-prose-do-not-run",
"setup:reproduce-a7-volatile-comparison",
"setup:reproduce-a0-session-sharing-path"
],
"publication": "게시됨",
"file": "session-custody-across-nodes/setup/setup-reproduce-a8-rolling-restart.md",
"status": "게시 전",
"studioId": "0b64d23a-f82a-44b4-ad54-e079578977c4",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
}
]
},
"topic": "session-custody-across-nodes"
},
"losing-a-node-or-the-store": {
"title": "PostgreSQL 을 내리고 노드 전원을 뽑았을 때",
"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__02-design-check.txt",
"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": "초안",
"file": "losing-a-node-or-the-store/case/case-four-logins-that-returned-200-and-vanished.md",
"status": "게시 전",
"studioId": "",
"assets": [
"a3-commit-to-disk-gap"
],
"assetFiles": [
"a3-commit-to-disk-gap"
],
"evidenceFiles": [
"../../../final/evidence/raw/a3-database-crash__02-design-check.txt",
"../../../final/evidence/raw/a3-database-crash__03-loss-measurement.txt",
"../../../final/evidence/raw/a3-database-crash__07-loss-result.txt",
"../../../final/evidence/raw/a3-database-crash__08-wal-settings.txt"
]
},
{
"title": "노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것",
"slug": "two-ways-to-lose-a-node",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a4"
],
"code": [
"node-monitor-grace-period",
"tolerationSeconds",
"StatefulSet"
],
"classification": "같은 복구 시간을 내는 두 장애의 원인이 다르다는 것을 갈랐다. 축출까지의 시간은 **두 값을 더한 340초가 계산이지 측정이 아니고**, 실측한 축출은 240~270초다. 두 폴링이 같은 `+0` 을 쓰는지 이 실험이 적어 두지 않아 **모순되는지 아닌지를 이 실험은 말할 수 없다** — 그 판정 불가도 이 기록이 적는 것이다",
"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": "초안",
"file": "losing-a-node-or-the-store/case/case-two-ways-to-lose-a-node.md",
"status": "게시 전",
"studioId": "",
"assets": [
"lab-topology",
"a4-two-node-losses"
],
"assetFiles": [
"lab-topology",
"a4-two-node-losses"
],
"evidenceFiles": [
"../../../final/evidence/raw/a4-node-loss__02-worker-node-killed.txt",
"../../../final/evidence/raw/a4-node-loss__03-state-during-loss.txt",
"../../../final/evidence/raw/a4-node-loss__04-eviction-timing.txt"
]
},
{
"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": "초안",
"file": "losing-a-node-or-the-store/case/case-200ms-of-delay-became-22-seconds.md",
"status": "게시 전",
"studioId": "",
"assets": [
"a6-latency-multiplication"
],
"assetFiles": [
"a6-latency-multiplication"
],
"evidenceFiles": [
"../../../final/evidence/raw/a6-latency-injection__02-delay-injected.txt",
"../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt"
]
},
{
"title": "StatefulSet 과 Deployment 가 같은 답을 냈고, 유령 행은 남은 코디네이터가 지웠다",
"slug": "ghost-rows-are-cleaned-by-the-surviving-coordinator",
"readiness": "READY",
"source": [
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-무엇을-쟀나",
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-관측",
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-결론"
],
"code": [
"JGROUPS_PING",
"kubectl delete pod",
"--grace-period=0",
"maxSurge: 0",
"maxUnavailable: 1",
"ISPN100001",
"keycloak-0-60375",
"keycloak-85469cb4d-cfzkt-24175"
],
"classification": "워크로드 종류 둘과 종료 방식 둘, 네 조합에서 JGROUPS_PING 을 매번 조회해 유령 행이 남지 않는 것을 확정했고, 코디네이터 로그의 뷰 변경 시각과 행 소멸 시각이 같은 초에 찍히는 것으로 정리 주체까지 닫았다. experiment-plan.md 에 미해결로 남아 있던 항목이 「자동」으로 닫힌다. 덤으로 StatefulSet 의 근거가 클러스터 동작이 아니라 로그와 표를 접두사로 대조하기 위한 성질 둘로 좁혀졌다",
"missing-verification": "정상 종료와 SIGKILL 만 쟀다. 노드 상실(A-4 형태)에서 코디네이터 자신이 죽는 경우는 재지 않았고, 그때는 정리 주체가 사라지므로 결과가 다를 수 있다. Deployment 도 성립한다는 것은 이 측정에서 나온 추론이고 프로젝트가 워크로드 종류를 바꾸기로 정한 기록은 없다",
"relations": [
"case:two-ways-to-lose-a-node",
"case:rolling-restart-keeps-sessions-drops-cache",
"case:session-sharing-is-the-database-not-replication"
],
"kind": "case",
"publication": "초안",
"file": "losing-a-node-or-the-store/case/case-ghost-rows-are-cleaned-by-the-surviving-coordinator.md",
"status": "게시 전",
"studioId": "",
"assets": [
"ghost-row-cleanup-order"
],
"assetFiles": [
"ghost-row-cleanup-order"
],
"evidenceFiles": []
}
],
"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": "초안",
"file": "losing-a-node-or-the-store/concept/concept-readiness-hides-the-broken-node.md",
"status": "게시 전",
"studioId": "",
"assets": [
"observation-points"
],
"assetFiles": [
"observation-points"
],
"evidenceFiles": [
"../../../final/evidence/raw/a1-jgroups-transport-block__11-service-impact.txt",
"../../../final/evidence/raw/a2-database-loss__05-recovery.txt",
"../../../final/evidence/raw/a4-node-loss__03-state-during-loss.txt",
"../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt",
"../../../final/evidence/raw/a6-latency-injection__05-recovery.txt"
]
}
],
"reference": [
{
"title": "장애 시간의 대부분은 알아채는 데 걸린다",
"slug": "most-of-an-outage-is-noticing",
"readiness": "READY",
"source": [
"final/document.md#선택의-이유와-지킨-경계-a4"
],
"classification": "복구 절차가 아니라 감지 지연이 장애 시간을 정한다는 것을 A-4 에서 봤다. **타이머 두 개의 합으로 확인한 것이 아니다** — `tolerationSeconds` 300 은 읽었지만 `node-monitor-grace-period` 40 은 조회하지 않았고(evidence 0건, 쿠버네티스 기본값을 인용했다), **두 값을 더한 340초는 계산이지 측정이 아니다.** 실측한 축출은 240~270초이고, 두 폴링이 같은 `+0` 을 쓰는지 적어 두지 않아 **모순되는지 아닌지를 이 실험은 말할 수 없다.** 기다리는 시간을 잡는 데는 그 계산으로 충분하지만 결과로 적을 때는 잰 쪽을 적는다. 다른 오케스트레이터는 이 프로젝트가 보지 않았다",
"scope": "노드 상실을 오케스트레이터가 감지해 대체를 만드는 구성. 이 프로젝트가 본 것은 k3s 한 벌이다",
"exceptions": "진입 경로 자체를 잃은 장애는 감지가 빨라도 복구되지 않는다. 그때는 이중화 지점이 문제다",
"relations": [
"case:two-ways-to-lose-a-node",
"case:four-logins-that-returned-200-and-vanished"
],
"kind": "reference",
"publication": "초안",
"file": "losing-a-node-or-the-store/reference/reference-most-of-an-outage-is-noticing.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/a4-node-loss__02-worker-node-killed.txt",
"../../../final/evidence/raw/a4-node-loss__03-state-during-loss.txt",
"../../../final/evidence/raw/a4-node-loss__04-eviction-timing.txt",
"../../../final/evidence/raw/a4-node-loss__05-recovery.txt",
"../../../final/evidence/raw/a4-node-loss__08-control-plane-recovery.txt"
]
}
],
"question": [],
"decision": [],
"setup": [
{
"title": "PostgreSQL 을 정상 종료시키고 네 경로를 잰다",
"kind": "setup",
"slug": "reproduce-a2-database-loss",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-2"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "`scale deployment/postgres --replicas=0` 으로 DB 를 **정상 종료**시키고 네 경로를 같은 명령으로 주입 전후에 잰다 — 캐시를 가진 노드의 refresh · 없는 노드의 refresh · 새 로그인 · 이미 발급된 토큰의 관리 API. **정문이 실제로 `503` 이 되므로 정지 구간을 1분 남짓으로 짧게 잡는다.** **계측 도구가 A-1 에서 바뀐다** — `--rm` 임시 파드는 토큰을 단계 사이로 못 넘기므로 `sleep 7200` 짜리 상주 탐침을 띄우고 `exec` 로 이어간다. **순서가 결과를 바꾸는 곳이 둘**(observed) — access token 수명이 60초라 「토큰 발급 → 정지 → 시험」을 그 안에 끝내야 `401` 이 만료인지 DB 탓인지 갈리고, `-o /dev/null` 을 빼면 본문과 상태코드가 섞여 `HTTP 000000{...}401` 이 나온다(원래 실행이 그렇게 했고 그 측정은 버렸다). **판정은** Ready 파드 0개 · `ready : []` · 정문 `503` 이고, 헬스 네 항목 중 `database connections` 만 DOWN 이다. **복구 중에 Keycloak 을 재시작하지 않는다** — 그러면 「사람 개입이 필요한가」가 사라진다. 실제로 `restarts=0` 인 채 DB Ready 이후 약 15초에 돌아왔다.",
"relations": [
"case:four-logins-that-returned-200-and-vanished",
"concept:the-up-metric-cannot-see-alive-but-useless",
"concept:readiness-hides-the-broken-node",
"setup:reproduce-a0-session-sharing-path",
"setup:reproduce-a3-database-crash"
],
"publication": "게시됨",
"file": "losing-a-node-or-the-store/setup/setup-reproduce-a2-database-loss.md",
"status": "게시 전",
"studioId": "bf169fef-f900-4247-88f8-427742ae3fe9",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다",
"kind": "setup",
"slug": "reproduce-a3-database-crash",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-3"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "**절차의 절반이 「죽이는 데 실패하는 두 가지 방법」이다.** `--grace-period=0 --force` 와 컨테이너 안 `kill -9 1` 을 순서대로 밟아 보고 세 번째로 백엔드 프로세스를 죽인다. 건너뛰고 세 번째만 하면 왜 그것이 유일한 방법인지 모른다. **주입 성공 신호를 미리 정한다** — `database system was not properly shut down` 과 `redo starts`/`redo done` 이 없으면 결과를 해석하지 않는다. **줄의 존재가 아니라 시각을 본다** — `ready to accept connections` 는 아까 뜰 때 찍힌 줄일 수 있다. **손으로 칠 물건이 아닌 것이 하나 있다** — 400회 로그인 루프는 편집기로 `/tmp/a3-login-loop.sh` 를 써서 `cat >` 로 파드에 밀어 넣고 터미널 하나를 통째로 쓴다. 파드 안에서 `( ... ) &` 로 띄우면 `exec` 세션이 끝날 때 같이 죽어 0건을 모은다(observed). **차집합을 낼 때 `LC_ALL=C sort` 를 빼면 안 된다** — sid 가 base64url 이라 로케일이 다르면 멀쩡한 sid 가 없는 것으로 잡힌다. **가정한 값은 재기 전에 잰다** — `wal_writer_delay` 200ms 는 주입 전에 `pg_settings` 로 읽어 둔다. **말할 수 있는 범위** — 153/149/4 의 「4」가 아니라 「0 이 아니고 WAL 플러시 주기와 같은 자릿수」까지다.",
"relations": [
"case:four-logins-that-returned-200-and-vanished",
"case:nine-injections-that-silently-did-nothing",
"case:commands-written-as-prose-do-not-run",
"reference:verify-the-injection-landed-separately-from-the-result",
"setup:reproduce-a2-database-loss"
],
"publication": "게시됨",
"file": "losing-a-node-or-the-store/setup/setup-reproduce-a3-database-crash.md",
"status": "게시 전",
"studioId": "91ce17ad-758d-4a63-be5b-489786609557",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "기계 전원을 뽑고 쿠버네티스가 알아채는 시각을 잰다",
"kind": "setup",
"slug": "reproduce-a4-node-loss",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-4"
],
"pinned-versions": [
"Keycloak = 26.7.0"
],
"classification": "**명령을 치는 곳이 세 군데인 것이 이 절차의 내용이다** — 터미널 A 는 VM 호스트에서 `virsh`, B 는 `kc-lab-1` 에서 `kubectl`(4b 에서는 이 터미널이 죽는다), C 는 밖에서 `curl`. 워커를 죽이는 4a 와 k3s 서버를 죽이는 4b 로 나뉘고 **어느 노드를 죽이느냐가 전부**다. **`virsh shutdown` 을 쓰면 안 된다** — ACPI 종료라 쿠버네티스가 정상 이탈로 처리해 이 절차의 발견 둘이 통째로 안 나온다. `virsh destroy` 가 전원 차단이다. **믿을 수 있는 것은 하이퍼바이저뿐이다** — 쿠버네티스가 40초 동안 `Ready` 라고 말하는 것은 결과이지 검증이 아니다. **`--max-time 8` 을 모든 외부 확인에 준다** — 4b 에서 그것이 없으면 curl 이 몇 분씩 매달리고, 타임아웃이 곧 결과다(처음 40초의 `000`). **기다리는 시간이 절차의 일부다** — 40초 + `tolerationSeconds=300` 이라 축출까지 약 5분 40초이고 그것만 7분을 본다. **`delete pod --grace-period=0 --force` 는 치지 않는다** — 노드가 살아 있으면 같은 이름의 파드 둘이 생긴다. `virsh start` 가 빠르고 안전하다. **4a 확인표를 통과하기 전에 4b 로 넘어가지 않는다.** **이 절차가 답을 못 남긴 곳** — 처음 40초가 `000` 인 까닭을 nginx 로그로 확인하려던 절이 증거 파일에 제목만 있고 비어 있다.",
"relations": [
"case:two-ways-to-lose-a-node",
"reference:most-of-an-outage-is-noticing",
"concept:the-up-metric-cannot-see-alive-but-useless",
"concept:readiness-hides-the-broken-node"
],
"publication": "게시됨",
"file": "losing-a-node-or-the-store/setup/setup-reproduce-a4-node-loss.md",
"status": "게시 전",
"studioId": "d845adc8-be2c-4471-aa1d-7e4db864c471",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "한 방향만 끊어 보고 raw PREROUTING 까지 내려간다",
"kind": "setup",
"slug": "reproduce-a5-asymmetric-partition",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-5"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "주입이 네 번이고 앞의 둘은 **일부러 실패한다** — `filter FORWARD` 최상단은 kube-router 가 자기 체인을 재삽입하며 밀어내고, `raw PREROUTING` 으로 옮겨도 연결 방향을 잘못 짚으면 0 패킷이다. 셋 다 화면에는 「아무 일도 없었다」로 보이므로 겪어 보지 않으면 다음에도 속는다. **카운터가 유일한 판정 기준이다** — 규칙이 목록에 보이는 것은 검증이 아니고 `pkts` 가 0 이면 아무것도 측정하지 않은 것이다. **규칙을 넣기 전에 `conntrack -L | grep 7800` 으로 방향을 본다** — `dport=7800` 인 쪽이 서버이고, A-1 때와 방향이 반대였다. **57800 도 같이 막는다** — FD_SOCK2 는 `bind_port + 50000` 이라 7800 만 막으면 장애 감지가 살아 분단이 어중간해진다. **단방향으로는 안 갈라진다** — JGroups 가 열린 방향으로 다시 붙고 `suspected = 0` 이 그 증거다. 양방향으로 막아야 `coord = t` 가 둘이 된다. **로그 시각은 UTC 다** — KST 에서 9시간을 빼서 맞춰 보지 않으면 주입 전후를 정반대로 가른다. **일회용 `--rm -it` 파드는 붙는 경주가 되므로** 상주 탐침을 쓴다(observed — `container is in CONTAINER_EXITED state`).",
"relations": [
"case:two-ways-to-lose-a-node",
"case:nine-injections-that-silently-did-nothing",
"reference:verify-the-injection-landed-separately-from-the-result",
"concept:readiness-hides-the-broken-node",
"setup:reproduce-a1-jgroups-transport-block"
],
"publication": "게시됨",
"file": "losing-a-node-or-the-store/setup/setup-reproduce-a5-asymmetric-partition.md",
"status": "게시 전",
"studioId": "b90d719f-39fb-4bab-a263-0e32eedb2b36",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "flannel.1 에 200ms 를 넣고 커넥션 풀이 고갈되는 것을 본다",
"kind": "setup",
"slug": "reproduce-a6-latency-injection",
"readiness": "READY",
"source": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-6"
],
"pinned-versions": [
"Keycloak = 26.7.0",
"curlimages/curl = 8.11.1"
],
"classification": "**대조군이 같은 클러스터 안에 있는 설계라 배치를 먼저 확인해야 절차가 성립한다** — postgres 와 `keycloak-0` 이 같은 노드이고 `keycloak-1` 만 VXLAN 을 건너야 한다. 둘 다 같은 노드면 시험군이 없고 둘 다 다른 노드면 대조군이 없다. 주입은 세 번이고 앞의 둘은 **일부러 실패한다** — `eth0` 은 이 게스트에 없고(`enp1s0` 이다), `enp1s0` 에서는 VXLAN 캡슐화 때문에 파드 IP 가 헤더에 없다. **성공한 주입은 `flannel.1` 에 `prio` + `netem` + `u32 filter` 세 줄을 한 줄씩 치는 것이다** — `netem` 을 root 에 바로 붙이면 모든 트래픽이 느려진다. **`Sent 0 pkt` 의 뜻이 A-5 와 다르다** — 부하 전이면 정상이고 부하 후면 필터가 틀린 것이다. **부하는 상주 파드 안 파일로 모은다** — `kubectl run --rm -i` 로 동시 20건을 띄우면 stdout 이 새어 20줄 중 일부만 도착한다(observed). **시간 값은 `sort -g` 로 정렬한다** — 사전순이면 `10.5` 가 `3.4` 앞에 와 최대값을 잘못 읽는다. **커넥션 풀 지표는 부하 직후에 읽는다** — `awaiting_count`·`active_count` 는 순간값이라 끝나면 0 이다. **대조군도 변한다** — `70 ms` 에서 `41 ms` 로 41% 빨라졌고 자릿수로 판정한다.",
"relations": [
"case:200ms-of-delay-became-22-seconds",
"case:nine-injections-that-silently-did-nothing",
"case:commands-written-as-prose-do-not-run",
"reference:verify-the-injection-landed-separately-from-the-result"
],
"publication": "게시됨",
"file": "losing-a-node-or-the-store/setup/setup-reproduce-a6-latency-injection.md",
"status": "게시 전",
"studioId": "cf2e783c-424b-4167-aa27-3bd6b5f46ee2",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
}
]
},
"topic": "losing-a-node-or-the-store"
},
"where-application-state-lives": {
"title": "세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기",
"readerQuestion": "저장소를 옮겨도 안 고쳐지는 것은 무엇인가?",
"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": "초안",
"file": "where-application-state-lives/case/case-moving-the-session-left-the-tokens-behind.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt",
"../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt"
]
},
{
"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/b2-multi-instance-session__02-schema.txt",
"raw/b2-multi-instance-session__04-overwrite-test.txt"
],
"publication": "초안",
"file": "where-application-state-lives/case/case-a-primary-key-without-the-session-id.md",
"status": "게시 전",
"studioId": "",
"assets": [
"b2-primary-key-overwrite"
],
"assetFiles": [
"b2-primary-key-overwrite"
],
"evidenceFiles": [
"../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt",
"../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt"
]
},
{
"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/b3-refresh-contention__01-concurrent-refresh.txt",
"raw/b3-refresh-contention__03-client-session-removed.txt"
],
"publication": "초안",
"file": "where-application-state-lives/case/case-the-winner-of-the-rotation-race-also-loses.md",
"status": "게시 전",
"studioId": "",
"assets": [
"b3-rotation-contention"
],
"assetFiles": [
"b3-rotation-contention"
],
"evidenceFiles": [
"../../../final/evidence/raw/b3-refresh-contention__01-concurrent-refresh.txt",
"../../../final/evidence/raw/b3-refresh-contention__02-session-impact.txt",
"../../../final/evidence/raw/b3-refresh-contention__03-client-session-removed.txt",
"../../../final/evidence/raw/b3-refresh-contention__04-policy-comparison.txt"
]
},
{
"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": "초안",
"file": "where-application-state-lives/case/case-persistence-without-a-volume-and-rotation-without-overlap.md",
"status": "게시 전",
"studioId": "",
"assets": [
"b5-b6-storage-and-keys"
],
"assetFiles": [
"b5-b6-storage-and-keys"
],
"evidenceFiles": [
"../../../final/evidence/raw/b5-redis-loss__04-persistence.txt",
"../../../final/evidence/raw/b6-key-rotation__03-old-key-removed.txt"
]
}
],
"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": "초안",
"file": "where-application-state-lives/concept/concept-two-stores-two-lookup-keys.md",
"status": "게시 전",
"studioId": "",
"assets": [
"bff-store-lookup-keys"
],
"assetFiles": [
"bff-store-lookup-keys"
],
"evidenceFiles": [
"../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt",
"../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt",
"../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt",
"../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt",
"../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt"
]
}
],
"reference": [
{
"title": "저장소를 옮기기 전에 조회 키를 본다",
"slug": "look-at-the-lookup-key-before-moving-the-store",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b0",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"classification": "무엇으로 찾는지가 무엇을 옮겨야 하는지를 정한다는 기준이다. 확인한 짝은 세션(세션 id)과 인가된 클라이언트(principal 이름) 하나뿐이고, 다른 짝은 재지 않았다 — 옮기기 전에 같은 확인을 하라는 규칙이다",
"scope": "서버가 든 상태를 외부 저장소로 옮기는 자리",
"exceptions": "상태가 하나뿐이고 조회 키도 하나면 이 확인이 필요 없다. 다만 자동구성이 무엇을 골랐는지는 그때도 읽는다",
"relations": [
"concept:two-stores-two-lookup-keys",
"case:a-primary-key-without-the-session-id"
],
"kind": "reference",
"publication": "초안",
"file": "where-application-state-lives/reference/reference-look-at-the-lookup-key-before-moving-the-store.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt",
"../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt",
"../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt",
"../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt",
"../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt",
"../../../final/evidence/raw/b2-multi-instance-session__05-logout-cleanup.txt"
]
}
],
"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": "초안",
"file": "where-application-state-lives/decision/decision-split-the-two-stores-and-design-each.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt",
"../../../final/evidence/raw/b2-multi-instance-session__01-jdbc-store-deploy.txt",
"../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt",
"../../../final/evidence/raw/b2-multi-instance-session__03-plaintext-tokens.txt",
"../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt",
"../../../final/evidence/raw/b2-multi-instance-session__05-logout-cleanup.txt"
]
}
],
"setup": [
{
"title": "아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다",
"kind": "setup",
"slug": "reproduce-b0-default-session-store",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-0",
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서"
],
"pinned-versions": [
"keycloak-pattern-bff = lab"
],
"classification": "**아홉 편의 첫 편이고 뒤의 여덟이 이 절차가 만든 상태 위에 선다** — B-1·B-2·B-4·B-5·B-7 이 전제에 「B-0 이 끝나 있다」를 적는다. **주입이 둘인데 첫째가 소스를 B-0 시점으로 되돌리는 일이다** — 어느 브랜치에도 B-0 시점의 파일이 없어서 `pom.xml`·`SecurityConfig`·`application.yml`·`bff-redis.yaml` 넷을 편집기로 열어 B-1·B-2 가 넣은 것을 손으로 뺀다. 그대로 배포하면 B-2 의 결과를 재게 된다. **Redis 는 배포만 하고 연결하지 않는다** — 먼저 붙이면 잴 것이 없어진다. **읽는 사람이 그대로 치는 명령이라 Setup 이다** — `vim`·`docker build`·`k3s ctr images import`·`kcadm`·`kubectl` 이 40~60분어치 이어지고 Case 의 평문 한 칸에 담으면 복사가 안 된다. **순서가 결과를 바꾸는 곳이 셋**(observed) — 이미지를 한 노드에만 import 하면 나머지 replica 가 `ErrImageNeverPull` 이고, `docker build` 기본 출력은 마지막 몇 줄뿐이라 `--progress=plain` 과 파일 없이는 `processDuplicateKeys` 가 안 보이며, brower 로 로그인하기 전에 쿠키를 안 지우면 앞선 실패의 세션이 결과를 섞는다. **판정은** 전체 빈 수 `321` 과 저장소 관련 빈 다섯 줄, 그리고 `--- Redis / Spring Session 이 구성되었는가 ---` 칸의 「★ 없음」이다. `/actuator/beans` 는 117KB 라 프록시에서 `Bad Gateway` 이므로 파드 안에서 받고, `200` 인데 `<!DOCTYPE html` 이면 Keycloak 로그인 페이지를 받은 것이라 `head -c 200` 으로 내용을 본다. **유효 범위** — 스티키 세션을 켜면 replica 2 에서 로그인이 되는지는 재지 않았다. 빈을 세고 이름·타입을 뽑는 `grep`·`sed` 줄과 `jq` 판본은 가이드가 미검증으로 표시했다(unknown).",
"relations": [
"case:moving-the-session-left-the-tokens-behind",
"concept:two-stores-two-lookup-keys",
"reference:look-at-the-lookup-key-before-moving-the-store",
"setup:reproduce-b1-redis-session-store",
"setup:reproduce-b2-jdbc-token-store"
],
"publication": "게시됨",
"file": "where-application-state-lives/setup/setup-reproduce-b0-default-session-store.md",
"status": "게시 전",
"studioId": "4ef91d43-1f1d-481a-8b6f-5e015d068578",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다",
"kind": "setup",
"slug": "reproduce-b1-redis-session-store",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-1"
],
"pinned-versions": [
"keycloak-pattern-bff = lab",
"Redis = 7.4.x"
],
"classification": "의존성 **둘**(`spring-session-data-redis` · `spring-boot-starter-data-redis`)을 함께 넣고 다시 빌드해 두 노드에 밀어 넣는 절차다. **하나만 넣으면 오류 없이 in-memory 로 남는다** — 그래서 찍어서 확인하는 단계가 따로 있다. **첫 배포를 일부러 고장 난 채로 한다** — `enableServiceLinks` 를 빼고 올려 쿠버네티스가 Service 이름마다 넣는 `REDIS_PORT=tcp://10.43.57.116:6379` 로 파드가 `Failed to bind properties` 에서 죽는 것을 한 번 보고, 그 뒤에 `enableServiceLinks: false` 로 고친다. 건너뛰고 처방부터 시작해도 결과는 같다고 가이드가 적는다. **순서가 결과를 바꾸는 곳이 둘**(observed) — 주입 전에 `printenv | grep -i redis` 를 안 보면 그 오류를 「Redis 가 죽어서」로 읽고(`redis-cli ping` 은 `PONG` 이다), 테스트에 `spring.session.store-type=none` 을 안 주면 빌드가 테스트 단계에서 죽는데 메시지가 Redis 연결 오류라 배포 문제로 읽힌다. **판정은** 빈 수 `321 → 402 (+81)` 과 새로 생긴 세션 저장소 빈 여섯, 그리고 **안 바뀐** authorized client 셋의 before·after 다. 키를 잡을 때 `grep -v expires` 를 빼면 만료 추적 키를 잡아 다음 명령이 빈 결과를 내고, `tr -d '\\r'` 을 빼면 키가 안 맞는데 오류는 안 난다. **유효 범위** — `--scan` 출력에서 키를 눈으로 골라 치는 두 단계 형태와 `jq` 판본은 가이드에 없다(unknown).",
"relations": [
"case:moving-the-session-left-the-tokens-behind",
"case:nine-injections-that-silently-did-nothing",
"concept:two-stores-two-lookup-keys",
"setup:reproduce-b0-default-session-store",
"setup:reproduce-b2-jdbc-token-store"
],
"publication": "게시됨",
"file": "where-application-state-lives/setup/setup-reproduce-b1-redis-session-store.md",
"status": "게시 전",
"studioId": "1e5d05fa-88f4-4ef5-a402-4a520ae4a52d",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다",
"kind": "setup",
"slug": "reproduce-b2-jdbc-token-store",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-2"
],
"pinned-versions": [
"keycloak-pattern-bff = lab",
"Redis = 7.4.x"
],
"classification": "토큰을 `JdbcOAuth2AuthorizedClientService` 로 옮긴 뒤 **주입 전에 기본키를 눈으로 읽는** 절차다. `\\d oauth2_authorized_client` 의 맨 아래 `PRIMARY KEY, btree (client_registration_id, principal_name)` 한 줄이 이 편의 답이라 그 줄을 보기 전에는 다음으로 넘어가지 않는다. **스키마 초기화가 조용히 실패하는 것을 먼저 만난다** — 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입이고 `spring.sql.init.continue-on-error: true` 가 그 실패를 삼켜 파드는 정상으로 보인다. DDL 은 터미널에 붙여 넣지 않고 `/tmp/oauth2-pg.sql` 로 만들어 `exec -i` 로 넘기며, **`-i` 를 빼면 아무 일도 안 일어나고 오류도 안 난다**(observed). **주입은 「두 브라우저」가 아니라 세션을 지우고 같은 사용자로 다시 로그인시키는 것이다** — 조회 키가 같아 등가이고, 해설 문서가 처음에 「두 브라우저에서」라고 적었다가 측정하지 않은 것을 적었다고 정정했다. **판정은 셋을 한꺼번에 본다** — 행 수 `(1 row)` 가 그대로인 것, `at_md5` 가 `675af228…` → `e19a63fc…` 로 바뀐 것, `issued_at` 이 삭제 시각 뒤인 것. 하나만 보면 INSERT 인지 UPDATE 인지 갈리지 않는다. **`FLUSHALL` 을 치지 않는다** — 같은 Redis 에 `_oauth2_proxy-` 키가 있어 B-7 이 오염된다. **유효 범위** — 진짜 두 브라우저로 같은 결과가 나오는지, RP-initiated logout 을 열었을 때 세션 수가 줄어드는지는 재지 않았다(unknown).",
"relations": [
"case:a-primary-key-without-the-session-id",
"case:nine-injections-that-silently-did-nothing",
"reference:look-at-the-lookup-key-before-moving-the-store",
"decision:split-the-two-stores-and-design-each",
"setup:reproduce-b1-redis-session-store",
"setup:reproduce-b3-refresh-contention"
],
"publication": "게시됨",
"file": "where-application-state-lives/setup/setup-reproduce-b2-jdbc-token-store.md",
"status": "게시 전",
"studioId": "df2ee798-7f31-4570-a355-11f96c0eea84",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다",
"kind": "setup",
"slug": "reproduce-b3-refresh-contention",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-3"
],
"pinned-versions": [
"curlimages/curl = 8.11.1"
],
"classification": "**BFF 를 거치지 않고 토큰 엔드포인트를 직접 치는 유일한 편이다** — Keycloak 쪽 동작만 갈라 보려는 것이고, direct grant 를 쓰므로 브라우저도 필요 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없어(`exit 127`) `sleep 7200` 짜리 상주 탐침 파드를 띄우고 `exec` 로 이어간다 — `--rm` 임시 파드는 발급받은 토큰을 다음 단계로 못 넘긴다. **주입은 realm 설정 하나다** — `revokeRefreshToken` 이 회전 스위치이고 이름이 「회전」이 아니라 「취소」다. `refreshTokenMaxReuse` 는 그것이 켜져야 의미가 있다. **realm 전체에 걸리므로 같은 realm 을 쓰는 BFF 로그인도 영향을 받고, 끝나면 반드시 되돌린다.** **순서가 결과를 바꾸는 곳이 셋**(observed) — `&` 와 `wait` 을 빼면 다섯이 순차로 돌아 전부 `200` 이 나오는데 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」이고, 갱신마다 `RT` 를 다시 담지 않으면 `400` 이 경쟁 탓인지 옛 토큰 탓인지 갈리지 않으며, 켜기 전에 발급한 토큰으로 재면 검증 시점의 정책이 섞인다. **판정은 성공 개수가 아니라 오류 문구가 두 종류인 것과, 이긴 요청이 받은 토큰을 다시 썼을 때의 `400` 과, 그 sid 의 `client_sessions` 가 `0`(대조군은 `1`)인 것이다.** `revoked_token` 은 `0` 이라 블랙리스트가 아니라 client session 삭제가 원인이다. **유효 범위** — BFF 를 거쳐도 같은 경쟁이 나는지, 동시성을 5보다 늘리면 어떻게 되는지는 재지 않았고, `maxReuse ≥ N-1` 은 구성 A·C 에서 따라 나온 추론이다(unknown).",
"relations": [
"case:the-winner-of-the-rotation-race-also-loses",
"case:a-primary-key-without-the-session-id",
"reference:verify-the-injection-landed-separately-from-the-result",
"setup:reproduce-b2-jdbc-token-store"
],
"publication": "게시됨",
"file": "where-application-state-lives/setup/setup-reproduce-b3-refresh-contention.md",
"status": "게시 전",
"studioId": "405c4206-9b59-491f-aed1-8b97cfd9f584",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다",
"kind": "setup",
"slug": "reproduce-b5-redis-loss",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-5"
],
"pinned-versions": [
"Redis = 7.4.x",
"netty-transport = 4.1.135.Final"
],
"classification": "주입이 둘이고 **둘째가 지금 실험대에서 그때를 재현하려고 방향을 뒤집은 단계다** — 매니페스트에 B-5 의 결론이 이미 반영되어 PVC 와 `--appendonly yes` 가 들어 있어서, 볼륨을 떼고 AOF 만 켠 채 파드를 지운다. 원래 실행은 반대 순서였다(unknown). **`delete pod` 이 아니라 `scale --replicas=0` 을 쓴다** — 없는 상태가 유지되어야 관찰할 시간이 생긴다. **영속화를 말하기 전에 `/data` 가 볼륨인지부터 본다** — `spec.volumes` · `volumeMounts` 의 `mountPath` · PVC 가 `Bound` 인 것 셋이 전부 성립해야 하고, 하나라도 빠지면 `appendonly yes` 는 장식이다. Redis 는 시킨 대로 `appendonlydir` 을 만들고 매 쓰기를 기록하므로 **디렉터리가 생긴 것을 「영속화가 켜졌다」로 읽으면 틀린다.** **`--max-time` 을 모든 외부 확인에 준다** — 주입 뒤 그 요청은 응답이 안 오고, 없으면 터미널이 붙잡힌 것을 「내 터미널이 이상함」으로 읽는다. **판정은** 세 경로의 `200 · 000 · 503`, `readiness` 가 `200` 이고 `{\"status\":\"UP\"}` 인 것, 엔드포인트에 두 IP 가 그대로 ready 인 것이다. **BFF 를 재시작하고 싶은 충동을 참는다** — 재시작하면 「스스로 회복하는가」를 영영 못 재고, 실제로는 `0,0 회 재시작` 으로 돌아왔다. **측정 실패를 값으로 읽지 않는다** — `03-health-groups.txt` 의 `/actuator/health HTTP server` 는 상태 코드가 아니다. **유효 범위** — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지, readiness 그룹에 `redis` 를 넣으면 A-2 와 같은 모양이 되는지는 재지 않았다.",
"relations": [
"case:persistence-without-a-volume-and-rotation-without-overlap",
"concept:readiness-hides-the-broken-node",
"concept:the-up-metric-cannot-see-alive-but-useless",
"setup:reproduce-a2-database-loss",
"setup:reproduce-b1-redis-session-store"
],
"publication": "게시됨",
"file": "where-application-state-lives/setup/setup-reproduce-b5-redis-loss.md",
"status": "게시 전",
"studioId": "af9645a0-8ca8-481d-9624-69fce6449b7c",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다",
"kind": "setup",
"slug": "reproduce-b6-key-rotation",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6"
],
"pinned-versions": [
"curl = 8.5.0"
],
"classification": "**되돌릴 수 없는 절차다** — 지우는 것은 키 공급자이고 개인키가 함께 사라지며, 같은 이름으로 다시 만들어도 `kid` 가 다른 새 키 쌍이 된다. 그래서 관찰 절로 넘어가기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 잃으면 곤란한 세션이 있는가, 주입 검증의 `old 200` 을 실제로 봤는가. **회전은 「바꾸기」가 아니라 「우선순위가 더 높은 공급자를 추가하기」다** — `config.*` 값이 대괄호로 감싼 배열이어야 하고 `priority` 를 낮게 주면 새 키가 만들어져도 발급에 안 쓰여 `kid` 가 안 바뀐다. **`-q` 필터가 오류도 종료코드도 없이 빈 결과를 준다** — 「키 공급자가 하나도 없구나」로 읽으면 이 절차 전체가 무너지므로 `--fields id,name,providerId` 로 전체를 받는다. **지울 대상을 바꿔 지우면 실험이 뒤집힌다** — 남길 것이 `rsa-rotated`, 지울 것이 `rsa-generated` 이고 `\"id\"` 는 `\"name\"` 보다 위에 있다. **판정은** RS256 키 수가 `1 → 2 → 1` 로 움직이는 것과 겹침 구간의 `옛 200 · 새 200`, 제거 뒤의 `옛 401 · 새 200` 이다. **`kid` 를 세는 단위가 다르다** — JWKS 에는 암호화용 키도 실리므로 `grep kid | wc -l` 은 서명 키를 과다 계산한다. **401 을 만나면 만료(60초)부터 가른다.** **유효 범위** — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초에서 따라 나온 추론이고, 추가와 제거를 30분 벌려 재지는 않았다(unknown).",
"relations": [
"case:persistence-without-a-volume-and-rotation-without-overlap",
"case:nine-injections-that-silently-did-nothing",
"reference:verify-the-injection-landed-separately-from-the-result",
"setup:reproduce-b7-cookie-secret-rotation"
],
"publication": "게시됨",
"file": "where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md",
"status": "게시 전",
"studioId": "7447ccbf-1800-43a4-a9d2-8ac774965c4b",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
}
]
},
"topic": "where-application-state-lives"
},
"trust-handed-over-at-the-edge": {
"title": "위조 신원 헤더와 로그아웃 전파",
"readerQuestion": "엣지가 만든 인증 결과를 앱은 어디까지 믿어도 되는가?",
"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": "초안",
"file": "trust-handed-over-at-the-edge/case/case-nginx-does-not-overwrite-a-header-it-never-sets.md",
"status": "게시 전",
"studioId": "",
"assets": [
"b4-header-trust-boundary"
],
"assetFiles": [
"b4-header-trust-boundary"
],
"evidenceFiles": [
"../../../final/evidence/raw/b4-edge-authorization__01-header-handling.txt",
"../../../final/evidence/raw/followup__03-b4-role-propagation.txt"
]
},
{
"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/c2-backchannel-logout__01-current-state.txt",
"raw/c2-backchannel-logout__03-logout-attempt.txt",
"raw/c2-backchannel-logout__04-reachability.txt"
],
"publication": "초안",
"file": "trust-handed-over-at-the-edge/case/case-nobody-implemented-backchannel-logout.md",
"status": "게시 전",
"studioId": "",
"assets": [
"c2-backchannel-both-sides"
],
"assetFiles": [
"c2-backchannel-both-sides"
],
"evidenceFiles": [
"../../../final/evidence/raw/c2-backchannel-logout__01-current-state.txt",
"../../../final/evidence/raw/c2-backchannel-logout__03-logout-attempt.txt",
"../../../final/evidence/raw/c2-backchannel-logout__04-reachability.txt"
]
},
{
"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": "초안",
"file": "trust-handed-over-at-the-edge/case/case-orphan-sessions-and-the-ttl-that-finds-them.md",
"status": "게시 전",
"studioId": "",
"assets": [
"b7-cookie-session-tradeoff"
],
"assetFiles": [
"b7-cookie-session-tradeoff"
],
"evidenceFiles": [
"../../../final/evidence/raw/b7a-orphan-session__01-orphan-lifecycle.txt",
"../../../final/evidence/raw/b7-cookie-secret__01-deploy.txt"
]
}
],
"concept": [],
"question": [],
"decision": [],
"reference": [
{
"title": "믿기 전에 그 헤더를 먼저 지운다",
"slug": "clear-the-header-before-you-trust-it",
"readiness": "READY",
"source": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b4",
"final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답"
],
"classification": "프록시가 동명 헤더를 자동으로 덮어쓸 것이라는 전제가 틀렸다는 것을 확인했다. 본 것은 host nginx 의 설정 한 번이고, 같은 체인에 있는 k3s Traefik 의 헤더 처리조차 재지 않았다 — 그래서 규칙은 「다른 프록시도 이렇다」가 아니라 「믿기 전에 그 프록시에서 확인하라」다",
"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": "초안",
"file": "trust-handed-over-at-the-edge/reference/reference-clear-the-header-before-you-trust-it.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/b4-edge-authorization__01-header-handling.txt",
"../../../final/evidence/raw/followup__03-b4-role-propagation.txt"
]
}
],
"setup": [
{
"title": "신원 헤더를 위조해 보내고 어디까지 도착하는지 본다",
"kind": "setup",
"slug": "reproduce-b4-forged-identity-headers",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4"
],
"pinned-versions": [
"curl = 8.5.0"
],
"classification": "**앞부분은 클러스터 상태를 하나도 안 바꾸고 `curl` 한 줄로 끝난다 — 그 사실 자체가 이 절차의 무게다.** 1~3절만 하고 멈춰도 결론 대부분이 나오고, 4절부터 Grafana 의 Ingress 를 빌리고 IdP 의 사용자 속성을 바꾼다. **무엇을 재는 경로인지 먼저 못박는다** — 위조 헤더를 보내는 `app1.hyeonworks.com/api/echo` 는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. **헤더가 도착한 것과 인가가 뚫린 것은 다른 사건이고**, 같은 헤더를 `/api/me` 에 보내면 `401` 이라 그 대조를 같이 친다. **대조군을 안 보면 판정이 성립하지 않는다** — 주입 전에 `x-auth-request-*` 칸이 비어 있는 것을 봐야 뒤에 나온 값을 「내가 보낸 것」이라고 말할 수 있다. **`tr ',' '\\n' | grep` 을 쓰면 안 된다** — 값 배열이 `[\"admin\",\"editor\"]` 처럼 쉼표를 품어 두 줄로 잘리고 「하나만 도착했다」로 읽게 된다. `grep -o '…\\[[^]]*\\]'` 로 대괄호째 뽑는다. **크기 훑기의 `000` 은 오류가 아니라 측정 결과다** — `400`(Tomcat)과 `000`(nginx 가 연결을 끊음)이 다른 값이다. **두 기계의 로그를 나란히 놓기 전에 시계를 맞춘다** — 보정 전에는 12회 요청이 변경 시각보다 앞서 보여 결론이 통째로 뒤집힌다(약 107초). **호스트 sudo 는 비밀번호를 요구하므로 빈 출력을 「설정이 없다」로 읽지 않는다.** **유효 범위** — `proxy_set_header X-Auth-Request-* \"\"` 처방은 이 실험대가 적용한 적이 없어 **위조가 막히는지는 측정으로 없다**(unknown).",
"relations": [
"case:nginx-does-not-overwrite-a-header-it-never-sets",
"reference:clear-the-header-before-you-trust-it",
"reference:never-subtract-values-from-two-clocks",
"setup:reproduce-b7-cookie-secret-rotation"
],
"publication": "게시됨",
"file": "trust-handed-over-at-the-edge/setup/setup-reproduce-b4-forged-identity-headers.md",
"status": "게시 전",
"studioId": "9bb0b051-c158-497f-a744-b24769c93783",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다",
"kind": "setup",
"slug": "reproduce-b7-cookie-secret-rotation",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7"
],
"pinned-versions": [
"Redis = 7.4.x",
"curl = 8.5.0"
],
"classification": "**남의 도메인을 빌리는 절차다** — 인증서가 `auth`·`app1`·`app2` 만 덮어 네 번째 이름을 못 만들므로 Grafana 의 Ingress 를 내리고 `app2` 를 빌린 뒤 반드시 돌려준다. **백업 파일이 비어 있는데 원본부터 지우는 사고를 `wc -l` 과 `grep -c` 가 막는다.** **바꾸기 전에 「겹칠 수 있는가」부터 묻는다** — `--help | grep cookie-secret` 이 `--cookie-secret string` 한 줄만 내고 복수형도 `--old-cookie-secret` 도 없다. 그 한 줄이 이 편의 답이고 나머지는 「그래서 무슨 일이 나는가」다. **patch 의 `env/1` 은 매니페스트 순서에 달린 값이라 그대로 믿지 않는다** — 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다. **회전만으로는 아무 일도 안 난다** — 누군가 옛 쿠키를 들고 와야 벌어지므로 여기서 「실험 실패」로 읽지 않는다. **로그인 화면이 안 뜨는 것을 「무중단」으로 읽으면 정확히 뒤집어 읽는 것이다** — 쿠키는 죽었고 SSO 가 재인증을 가렸으며, 그 증거가 로그의 `Initiating login` 과 `AuthSuccess` 다. **두 줄이 다른 말을 한다** — `stored_session.go:94` 는 「지우겠다」이고 `:97` 이 「그 지우기가 실패했다」이며 94 만 보면 「정리됐구나」로 읽는다. `--prefix` 없이는 replica 둘의 로그를 못 가른다. **회전 시각을 UTC 로 적어 둔다** — B-7a 의 정리 규칙이 이 값을 기준으로 고아를 고른다. **유효 범위** — IdP SSO 가 없거나 만료된 경우에 전원이 로그인 화면을 보는지는 재지 않았다(unknown).",
"relations": [
"case:orphan-sessions-and-the-ttl-that-finds-them",
"case:nginx-does-not-overwrite-a-header-it-never-sets",
"setup:reproduce-b7a-orphan-session",
"setup:reproduce-b6-key-rotation",
"setup:reproduce-b0-default-session-store"
],
"publication": "게시됨",
"file": "trust-handed-over-at-the-edge/setup/setup-reproduce-b7-cookie-secret-rotation.md",
"status": "게시 전",
"studioId": "d53181b9-bb28-4703-97ed-98adfb5b18dc",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "TTL 로 고아 세션을 골라내 지운다",
"kind": "setup",
"slug": "reproduce-b7a-orphan-session",
"readiness": "READY",
"source": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7a"
],
"pinned-versions": [
"Redis = 7.4.x"
],
"classification": "B-7 이 「지우지 못했다」로 멈춘 자리에서 시작해 **못 지우는 주체가 프록시인지 Redis 인지 가르는** 절차다. 회전을 두 번 치고, 두 번째 회전이 「일회성인가 누적인가」를 가른다 — 1차를 살아남은 세션이 2차에서 고아가 된다. **`refresh:disabled` 를 맨 먼저 확인한다. 이 한 단어가 정리 규칙 전체의 전제다** — `--cookie-refresh` 가 켜져 있으면 오래 안 쓴 산 세션이 고아로 오판되어 지워지므로 이 절차를 쓰면 안 되고, 그때는 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이 정직하다. **구분 신호가 TTL 하나뿐이라는 것을 열을 하나씩 지워 보인다** — 이름 접두사도 `type` 도 같고 `strlen` 은 바이트 단위로 `3510` 으로 같으며 값은 암호화되어 뜻을 못 읽는다. **판정 전에 규칙 자체를 검증한다** — 역산한 생성시각 `11:30:26` 과 로그의 `AuthSuccess 11:30:27` 이 1초 차이고, 그 1초는 TTL 이 초 단위 정수라서 생기는 반올림이다. **`del` 을 바로 붙이지 않는다** — 같은 루프를 `echo` 로 먼저 돌려 「산것」이 지금 로그인해 있는 사람 수와 맞는지 본다. **`NOW` 를 루프 밖에서 한 번만 잡는다** — 안에서 잡으면 키마다 기준 시각이 달라진다. **`date` 는 전부 `-u` 로 친다** — 프록시 로그가 UTC 라 섞이면 9시간이 틀어진다. **`FLUSHDB` 를 쓰지 않는다** — 같은 Redis 에 BFF 세션이 있다. **유효 범위** — 고아를 1시간 두고 실제로 만료되는 것을 끝까지 지켜보지는 않았고, 역산으로 고아를 고르는 루프 자체는 가이드가 미검증으로 표시했다(unknown).",
"relations": [
"case:orphan-sessions-and-the-ttl-that-finds-them",
"case:commands-written-as-prose-do-not-run",
"reference:never-subtract-values-from-two-clocks",
"setup:reproduce-b7-cookie-secret-rotation"
],
"publication": "게시됨",
"file": "trust-handed-over-at-the-edge/setup/setup-reproduce-b7a-orphan-session.md",
"status": "게시 전",
"studioId": "8d6b8a7f-e08e-4c9f-9772-a14a9d569260",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다",
"kind": "setup",
"slug": "reproduce-c1-multi-app-sso",
"readiness": "READY",
"source": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1",
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서"
],
"pinned-versions": [
"keycloak-pattern-bff = lab",
"Redis = 7.4.x"
],
"classification": "**두 편의 첫 편이고 C-2 가 이 절차가 만든 상태 위에 선다** — C-2 의 전제가 「C-1 이 끝나 있다」이고, 빌린 이름도 C-2 를 이어서 할 생각이면 아직 돌려주지 않는다. **앱이 둘 필요한 첫 편이다** — B-2 의 app1(BFF)과 B-7 의 app2(oauth2-proxy)가 둘 다 떠 있어야 하고, `app2.hyeonworks.com` 은 Grafana 에서 빌린 이름이라 끝나면 Ingress 를 돌려준다. **브라우저가 필요하다** — SSO 는 `auth.hyeonworks.com` 에 붙은 쿠키가 만드는 현상이라 `curl` 로는 「로그인 화면이 안 떴다」를 못 본다. 다른 브라우저나 시크릿 창에서 열면 로그인 화면이 뜨는 것이 정상이다. **읽는 사람이 그대로 치는 명령이라 Setup 이다** — `psql`·`redis-cli`·`kcadm`·`kubectl`·브라우저가 약 20분어치 이어지고 Keycloak 재시작에만 1~2분이 든다. **주입 전 절차가 한 번 꺾인다** — 세션을 지우려다 `logout-all` 이 오류 없이 안 먹는 것을 먼저 보고(캐시가 답한다) DB 직접 삭제와 `rollout restart` 로 지운 뒤, **세는 법을 고친다.** `offline_user_session` 에는 모든 realm 의 세션이 들어 있고 `kcadm` 을 치는 순간 `master` 에 admin 세션이 생기므로 realm 을 조인하지 않은 숫자는 뜻이 없다. **순서가 결과를 바꾸는 곳이 셋**(observed) — 이 단계를 건너뛰면 관찰 절의 「남은 세션 1」을 「로그아웃이 안 먹었다」로 정반대로 읽고, 세션 id 를 지목하는 `kcadm delete sessions/<id>` 는 오류도 없이 안 먹으므로 `users/<id>/logout` 을 써야 하며, `flushall` 은 BFF 세션까지 지우므로 깨끗한 상태를 만들 때만 친다. **판정은** 같은 `user_session_id` 에 `client_sessions` 가 1 → 2 로 는 것, IdP 세션을 끊은 뒤 남은 세션 1의 realm 이 `master` 인 것, 그리고 Redis 키 두 줄이 **글자 하나까지 같은 것**이다. **화면으로는 판정하지 않는다** — 스크린샷 두 장은 md5 `2c703176…` 로 동일한 파일이고, 화면이 같아 보인다는 것 자체가 이 편의 결론이라 구별은 터미널 출력이 한다. **버전은 이 편이 직접 잰 값이 아니다**(inferred) — C-1 출력에는 판 번호가 한 번도 안 찍혔고, SSOT C층 머리말이 「같은 실험대의 B층 출력을 본다」고 적는다. **유효 범위** — 앱 세션이 실제로 언제 끊기는지는 **기다려서 확인하지 않았다.** `Session not active` 가 나온다는 것은 추론이고, 수명 두 값을 읽는 `get realms … --fields` 와 realm·클라이언트 이름을 조인하는 쿼리들은 가이드가 미검증으로 표시했다(unknown).",
"relations": [
"case:nobody-implemented-backchannel-logout",
"case:session-sharing-is-the-database-not-replication",
"concept:two-stores-two-lookup-keys",
"setup:reproduce-c2-backchannel-logout",
"setup:reproduce-b2-jdbc-token-store",
"setup:reproduce-b7-cookie-secret-rotation"
],
"publication": "게시됨",
"file": "trust-handed-over-at-the-edge/setup/setup-reproduce-c1-multi-app-sso.md",
"status": "게시 전",
"studioId": "3421185f-5f3c-4263-9455-6243306e9fc9",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다",
"kind": "setup",
"slug": "reproduce-c2-backchannel-logout",
"readiness": "READY",
"source": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2"
],
"pinned-versions": [
"curlimages/curl = 8.11.1",
"keycloak-pattern-bff = lab"
],
"classification": "**의도적으로 한쪽만 고치는 절차다** — 후보 셋(보낼 주소 · 받을 엔드포인트 · 네트워크) 중 첫째만 넣고 여전히 안 퍼지는 것을 보이려는 것이고, 「설정했으니 되겠지」로 넘어가는 실패를 **일부러 재현한다.** **주입 검증이 한 겹 더 앞으로 온다** — A·B층에서는 「주입이 걸렸는가」였는데 여기서는 **끊을 세션이 있는가**이고, 원래 실행이 정확히 거기서 헛돌았다. 로그아웃 전 `keycloak-patterns` 세션이 이미 0 이었고 그래서 나온 「앱 세션이 안 지워졌다」는 끊을 것이 없었다는 뜻이었다. 주입은 정상으로 돌았고 출력도 그럴듯했고 결론도 원하던 방향이었는데, 전제 하나가 틀려 있었다. **읽는 사람이 그대로 치는 명령이라 Setup 이다** — `kcadm`·`grep -rn`·`curl`·임시 파드가 약 20분어치 이어진다. **주입 전에 백업이 먼저다** — `attributes=` 는 통째로 교체하므로 기존 속성이 같이 날아갈 수 있고, 되돌리기가 그 백업 파일에 달려 있다. **순서가 결과를 바꾸는 곳이 셋**(observed) — 점 표기 `-s \"attributes.backchannel.logout.url=…\"` 는 `exit 1` 로 죽고(속성 이름 자체에 점이 있다) `kubectl exec` 가 오류 본문을 잘라 「왜」는 안 보이므로 JSON 으로 통째로 줘야 하며, `$CID` 가 빈 채로 다음 명령을 치면 엉뚱한 클라이언트를 고치고, 후보 경로의 `302` 를 「있다」로 읽으면 판정이 뒤집힌다 — `302` 는 핸들러가 없어 인증 요구로 떨어진 것이라 「없다」의 증거다. **판정은** 로그아웃 뒤 `keycloak-patterns` 세션이 0 인데 Redis 키 이름은 개수도 글자도 그대로인 것과, 임시 파드에서 본 `Address: 100.83.212.4` 와 `HTTP 200` 이다. **로그 0줄로는 아무것도 단정하지 않는다** — `DEBUG` 레벨이면 안 찍히는 것과 구별되지 않아 「Keycloak 이 안 보냈다」의 근거로 쓰지 않는다. **증거 한 곳이 1:1 이 아니다**(observed·출처 주의) — 설정이 들어간 것을 확인한 두 줄은 `02-configure-idp.txt` 에 없고 그 파일은 점 표기 실패로 끝나므로, 따라 하는 사람은 그 값을 지금 직접 재 둔다. **버전은 한 줄만 이 편의 것이다** — `curlimages/curl:8.11.1` 은 이 편이 띄운 임시 파드의 출력이고(observed) 나머지는 B층 값이다(inferred). **유효 범위** — 이 실험대의 `HTTP 200` 은 tailnet 과 split DNS 덕에 공개 이름이 되돌아오는 구성 때문이고 운영에서 같은 값이 나온다고 볼 근거가 없다. 받을 엔드포인트를 실제로 구현한 뒤 전파가 되는지, 부분 실패 시의 재시도 정책, `DEBUG` 로그는 재지 않았다. 임시 파드를 띄우는 `kubectl run c2probe …` 한 줄과 설정 JSON 을 파일로 넣는 형태는 가이드에 없다(unknown).",
"relations": [
"case:nobody-implemented-backchannel-logout",
"case:orphan-sessions-and-the-ttl-that-finds-them",
"reference:verify-the-injection-landed-separately-from-the-result",
"setup:reproduce-c1-multi-app-sso",
"setup:reproduce-b7-cookie-secret-rotation"
],
"publication": "게시됨",
"file": "trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md",
"status": "게시 전",
"studioId": "5296a106-4c42-437d-b721-33a5e53a045c",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
}
]
},
"topic": "trust-handed-over-at-the-edge"
},
"operations-that-report-success": {
"title": "운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신",
"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": "초안",
"file": "operations-that-report-success/case/case-the-certificate-that-took-38-minutes-to-reach-the-wire.md",
"status": "게시 전",
"studioId": "",
"assets": [
"renewal-to-serving-gap"
],
"assetFiles": [
"renewal-to-serving-gap"
],
"evidenceFiles": [
"../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt",
"../../../final/evidence/raw/d4-certificate-renewal__09-serial-timeline.txt",
"../../../final/evidence/raw/d4-certificate-renewal__12-certbot-state.txt",
"../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt"
]
},
{
"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": "초안",
"file": "operations-that-report-success/case/case-a-deploy-hook-closed-the-gap-to-two-seconds.md",
"status": "게시 전",
"studioId": "",
"assets": [
"d4a-hook-effect"
],
"assetFiles": [
"d4a-hook-effect"
],
"evidenceFiles": [
"../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt",
"../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt",
"../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt"
]
},
{
"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/d2-version-upgrade__02-rollback-attempt.txt",
"raw/d2-version-upgrade__03-roll-forward.txt"
],
"publication": "초안",
"file": "operations-that-report-success/case/case-the-upgrade-that-would-not-roll-back.md",
"status": "게시 전",
"studioId": "",
"assets": [
"d2-upgrade-direction"
],
"assetFiles": [
"d2-upgrade-direction"
],
"evidenceFiles": [
"../../../final/evidence/raw/d2-version-upgrade__02-rollback-attempt.txt",
"../../../final/evidence/raw/d2-version-upgrade__03-roll-forward.txt"
]
}
],
"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": "D-4 안에서 「로그는 성공인데 상태는 안 바뀌었다」를 두 번 만났다 — certbot 타이머가 매번 `SUCCESS` 인데 옛 인증서가 나갔고, nginx 워커 PID 는 기동 직후의 첫 fork 그대로였다. D-4a 는 **극성이 반대**다(훅 로그는 `error` 를 찍는데 상태는 바뀌었다). 그래서 규칙은 「로그가 틀린다」가 아니라 「로그는 양쪽으로 다 틀리니 상태로 판정하라」다. 검증한 데몬은 nginx 하나뿐이다",
"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": "초안",
"file": "operations-that-report-success/reference/reference-judge-a-reload-by-the-worker-pid-not-the-log.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt",
"../../../final/evidence/raw/d4-certificate-renewal__09-serial-timeline.txt",
"../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt",
"../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt",
"../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt",
"../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt"
]
}
],
"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 일 전(약 59 일 뒤 (증거의 `VALID: 89 days` 는 만료까지다))에 타이머가 돈 뒤 워커 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": "초안",
"file": "operations-that-report-success/question/question-does-the-renewal-timer-actually-renew.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt",
"../../../final/evidence/raw/d4-certificate-renewal__12-certbot-state.txt",
"../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt",
"../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt",
"../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt"
]
}
],
"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": "사람이 치는 절차로 두는 대안을 실제 공백(2305초)으로 확인한 뒤 훅으로 정했다. 훅 로그에 `error` 가 찍히는 것은 **훅을 고른 비용이 아니다** — `types_hash` 경고는 nginx 설정 자체의 것이라 사람이 reload 해도 똑같이 뜬다. 비용으로 적을 것은 「성공한 훅을 로그로 감시하면 실패로 오독한다」쪽이다",
"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": "초안",
"file": "operations-that-report-success/decision/decision-put-the-reload-in-a-deploy-hook.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt",
"../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt",
"../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt",
"../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt"
]
}
],
"setup": [
{
"title": "스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다",
"kind": "setup",
"slug": "reproduce-d1-backup-restore",
"readiness": "READY",
"source": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1",
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서"
],
"pinned-versions": [
"Keycloak = 26.7.0"
],
"classification": "**다섯 편의 첫 편이고 D-2 가 이 절차가 남긴 덤프 위에 선다** — D-2 의 전제가 「D-1 이 끝나 있고 덤프가 손에 있다」이고, 그래서 복구 확인표가 덤프를 지우지 말라고 적는다. **되돌리는 수단이 방금 뜬 덤프 파일 하나뿐이라 검증을 먼저 한다** — `DROP SCHEMA public CASCADE` 는 realm·client·user·세션을 전부 지우고, 가이드는 덤프 검증 넷(크기·`dump complete`·테이블 수 `101`·`COPY` 블록에 붙은 세션 행)을 통과하기 전에는 주입 절로 넘어가지 않는다. **읽는 사람이 그대로 치는 명령이라 Setup 이다** — `pg_dump`·`psql`·`curl`·`kubectl` 이 약 20분어치 이어지고 파괴 구간만 1분 안쪽이다. **이 편은 스크립트를 일부러 안 쓴다** — `DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에 멈췄을 때 무엇이 실행됐는지 모르므로, 파괴를 손으로 치고 눈으로 확인하고 복구도 손으로 친다. **복구가 조용히 실패하는 곳이 하나 있고 그것이 이 편의 함정이다**(observed) — `kubectl exec` 에 `-i` 를 빼면 파드 안의 `psql` 이 빈 입력을 받고 정상 종료하는데 오류도 종료 코드도 안 나고 시각 두 줄은 「1초 만에 끝났다」로 찍혀 **복구한 것과 구별되지 않는다.** **순서가 결과를 바꾸는 곳이 둘** — 파괴 전에 같은 명령으로 대조값을 안 잡으면 「완전 일치」를 판정할 상대가 없고, 개수 쿼리에는 `offline_flag='0'` 필터가 있어 나열 쿼리와 수가 다르므로 **같은 쿼리끼리** 견준다. **판정은** 파괴 뒤 남은 테이블 `0` 과 `relation \"realm\" does not exist`, 그런데도 정문과 app1 이 `HTTP 200` 인 것, 세 경로가 `certs` 200 · `.well-known` 500 · 토큰 발급 400 으로 갈리는 것, 복구 뒤 대조 한 줄이 문자 단위로 같은 것과 `RTO = 41초` 다. **정문의 200 을 「파괴가 실패했다」로 읽지 않는다** — 테이블이 0개인 것을 바로 앞에서 봤고, realm 캐시가 DB 와 대조하지 않고 답한다. 헬스체크는 커넥션만 보므로 빈 데이터베이스를 통과시키고, 이 사고에서 정직한 지표는 토큰 발급 하나다. **버전은 이 편이 직접 잰 값이 아니다**(inferred) — D-1 절에는 판 번호가 한 줄도 없고, 바로 이어 돈 D-2 가 시작 태그를 `quay.io/keycloak/keycloak:26.7.0` 으로 적는다. **유효 범위** — 백업 자동화·보존 주기·복구 리허설의 정기 실행은 확인하지 않았고 손으로 한 번 뜨고 한 번 되돌린 것만 참이다. **덤프를 다른 기계로 옮기는 두 줄은 이 실험대가 치지 않았다** — 덤프는 DB 와 같은 기계에 남았다(unknown). 남은 테이블을 세는 `pg_tables` 쿼리와 토큰 발급 `curl` 한 줄도 미검증이다.",
"relations": [
"case:the-upgrade-that-would-not-roll-back",
"case:nine-injections-that-silently-did-nothing",
"concept:the-up-metric-cannot-see-alive-but-useless",
"setup:reproduce-d2-version-upgrade",
"setup:reproduce-a2-database-loss",
"setup:reproduce-a3-database-crash"
],
"publication": "게시됨",
"file": "operations-that-report-success/setup/setup-reproduce-d1-backup-restore.md",
"status": "게시 전",
"studioId": "7dc48b91-e31b-455c-9a9d-c766f95ff491",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다",
"kind": "setup",
"slug": "reproduce-d2-version-upgrade",
"readiness": "READY",
"source": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2"
],
"pinned-versions": [
"Keycloak (시작·복귀 태그) = 26.7.0",
"Keycloak (정방향) = 26.7.3",
"Keycloak (역방향 대조) = 26.0",
"Infinispan (26.7.3 에 실린 판) = 16.0.14"
],
"classification": "**태그를 세 번 바꾸고 마지막 것이 파드를 `CrashLoopBackOff` 로 만드는 절차다**(정방향 → 롤백 → 선택적으로 실패하는 방향). 전 구간 약 20분이고, **이 실험은 백업 없이 시작하지 않는다** — 스키마가 움직이는 방향으로 가면 태그로는 못 돌아온다. **주입 전에 안 재면 다시 못 재는 값이 하나 있다** — 업그레이드 전의 `databasechangelog` 행 수이고, 올린 뒤에는 「롤백해도 되는가」를 판정할 근거가 사라진다. **판정 기준이 버전 번호가 아니라 행 수의 변화다** — 26.7.0 ↔ 26.7.3 은 `210 → 210` 으로 롤백이 **되고**, 26.7.0 → 26.0 은 체크섬 불일치로 **안 된다.** **읽는 사람이 그대로 치는 명령이라 Setup 이다** — `set image`·`rollout status`·`psql`·폴링 루프가 이어지고 터미널 둘을 여는 편이 낫다. **실측이 두 실행에서 나온다**(observed) — 처음 실행(15:0015:10, 역방향 26.0)과 후속 실행(15:2215:26, 26.7.3 정방향과 롤백)이며 어느 쪽인지 매번 적는다. **해설이 한 번 정정됐다** — 처음에는 「롤백은 안 된다」고 단정했다가 후속 실험에서 조건을 붙였다. **순서가 결과를 바꾸는 곳이 셋**(observed) — 레지스트리 태그 목록을 안 보면 「26.7.0 보다 새 이미지가 없다」고 적게 되고(실제로는 셋이 있었다), 가용성 대조군을 먼저 안 띄우면 주입 중의 `000` 을 귀속할 수 없으며, `Running` 인데 `0/1` 인 상태를 「떴다」로 읽으면 실패를 못 본다. **판정은** 업그레이드 후 `마이그레이션 후: 210 (전: 210)`, 정방향 폴링 `200 87회 / 비200 0`, 롤백 폴링 `43회 / 비200 1` 과 그 1이 `000` 인 것, 그리고 실패한 기동 뒤에도 `210` 인 것을 함께 본다. **`000` 은 서버 오류가 아니다** — `--max-time 3` 을 넘긴 것이고, 끊긴 것과 느린 것의 구별이 그 옵션을 알고 있어야 선다. **실패한 기동이 스키마를 못 건드린 것이 두 경우를 가른다** — Liquibase 가 검증 단계에서 멈추면 이미지만 되돌리면 되고, 이미 적용한 뒤였으면 D-1 의 덤프 복구까지 가야 한다. **유효 범위** — 「행 수가 늘면 태그로 못 돌아온다」는 역방향(26.0)에서 관측한 실패를 근거로 한 추론이며(inferred) 실제로 행 수가 늘어난 뒤 되돌려 본 적은 없다. 26.7.x 사이에는 스키마 변경이 없어 이 실험대에서는 재현하지 못했고, 마이그레이션 도중에 죽는 경우와 대규모 마이그레이션 소요 시간도 재지 않았다. `kubectl rollout undo` 와 `databasechangelog` 마지막 다섯 줄을 뽑는 쿼리는 이 실험이 쓰지 않았다(unknown).",
"relations": [
"case:the-upgrade-that-would-not-roll-back",
"case:rolling-restart-keeps-sessions-drops-cache",
"concept:readiness-hides-the-broken-node",
"setup:reproduce-d1-backup-restore",
"setup:reproduce-a8-rolling-restart"
],
"publication": "게시됨",
"file": "operations-that-report-success/setup/setup-reproduce-d2-version-upgrade.md",
"status": "게시 전",
"studioId": "e53c5947-e1df-400a-ad79-e9d55b1da452",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다",
"kind": "setup",
"slug": "reproduce-d3-secret-exposure",
"readiness": "READY",
"source": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3"
],
"pinned-versions": [
"k3s 저장소 암호화 = Disabled",
"판 번호 = SSOT D-3 절에 한 줄도 없다"
],
"classification": "**파괴적인 단계가 없는 유일한 편이다** — 만드는 것은 카나리아 Secret 하나뿐이고 복구 절에서 지운다. 전 구간 약 15분. **카나리아를 쓰는 까닭이 절차의 일부다** — 관찰 절에서 저장 파일 안을 `grep` 해야 하는데 진짜 비밀번호를 인자로 쓰면 그 값이 셸 히스토리와 `ps` 에 남으므로, 찾아도 피해가 없는 값을 하나 심는다. 실험 대상이 값이 아니라 **경로**라 결론은 같다. **읽는 사람이 그대로 치는 명령이라 Setup 이다** — `kubectl`·`sudo k3s`·`sudo grep`·`auth can-i` 가 네 경로를 하나씩 열고, 게스트의 `sudo` 는 무암호라 호스트와 다르다. **비밀을 화면에 띄우는 편이라 규칙 셋을 먼저 정한다** — 남의 진짜 비밀은 길이(`wc -c`)와 키 이름까지만 보고, 값을 찍어야 하는 곳은 카나리아를 쓰며, 실린 값들은 이름에 `change-me` 가 들어간 실험대 전용 문자열이다. **순서가 결과를 바꾸는 곳이 둘**(observed) — 카나리아를 심기 전에 `describe` 화면을 안 봐 두면 심은 뒤의 화면이 **같은 화면**이라는 것이 안 보이고, `-wal` 이 10MB 인 것을 안 봐 두면 방금 만든 값이 아직 본체에 없을 수 있다는 것을 놓친다. **판정은** `describe` 가 `CANARY: 25 bytes` 만 주는데 같은 값이 `base64 -d` 한 줄로 평문이 되는 것, `Encryption Status: Disabled, no configuration file found`, 저장 파일에서 평문 일치 **`2`**, 파드 안 `env` 두 줄, 그리고 `default SA: no` 다. **`0` 을 「없다」로 읽지 않는다** — 같은 파일에 같은 명령을 걸었는데 키에 따라 `2` 와 `0` 이 나왔고, `0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다. `2` 가 나온 순간 판정은 이미 났다. **`grep -c` 를 쓰는 것도 이 편의 규칙이다** — 바이너리에서도 숫자가 나오고 값 자체를 화면에 안 띄운다. **판 번호가 이 절에 한 줄도 없다** — SSOT D층 머리말에는 B층 같은 버전 표가 없고, 같은 시각에 D-2 가 태그를 바꾸고 있었으므로 다른 편의 값을 끌어오지 않았다. 적어 둔 것은 이 절차가 성립한 구성인 「저장소 암호화 꺼짐」 하나다. **유효 범위** — k3s `--secrets-encryption` 을 켠 뒤의 상태는 시험하지 않았고, `0` 이 나온 키의 원인도 가리지 않았으며, 볼륨 마운트·SealedSecret·외부 KMS 도 전부 안 했다. 카나리아로 저장 파일을 찾는 두 줄, `-wal`·`strings` 로 다시 보는 두 줄, `exec deploy/bff` 형태, `/proc/1/environ` 을 읽는 줄, `auth can-i --list`, 삭제 뒤 다시 `grep` 하는 두 줄은 전부 미검증이고 **이 실험대는 카나리아 대신 실제 값으로 쟀다**(unknown).",
"relations": [
"case:persistence-without-a-volume-and-rotation-without-overlap",
"concept:the-up-metric-cannot-see-alive-but-useless",
"setup:reproduce-d1-backup-restore",
"setup:reproduce-b6-key-rotation",
"setup:reproduce-b7-cookie-secret-rotation"
],
"publication": "게시됨",
"file": "operations-that-report-success/setup/setup-reproduce-d3-secret-exposure.md",
"status": "게시 전",
"studioId": "186443e8-a32a-4a94-8609-845a4247d120",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "인증서를 강제로 갱신하고 밖에서 보이는 일련번호가 언제 바뀌는지 잰다",
"kind": "setup",
"slug": "reproduce-d4-certificate-renewal",
"readiness": "READY",
"source": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4"
],
"pinned-versions": [
"certbot = 5.7.0"
],
"classification": "**클러스터가 아니라 호스트를 보는 유일한 편이다** — `kubectl` 은 한 번도 안 쓰고, 관찰은 개발 머신에서 한다. 밖에서 본 것이 이 실험의 답이고 **그 기계의 시계가 이 실험대에서 유일하게 정확하다.** **되돌릴 수 없는 한 줄이 있다** — `certbot renew --force-renewal` 은 진짜 인증서를 발급하고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 한 장 깎으므로, 먼저 `--dry-run` 으로 절차만 확인하고 강제 갱신은 전체에서 한 번만 쓰며 그 한 번을 헛되게 쓰지 않도록 **대조군을 먼저 잡는다.** **읽는 사람이 그대로 치는 명령이라 Setup 이다** — 주입 전에 재는 칸만 여덟이고 `openssl`·`systemctl`·`ps`·감시 스크립트 셋이 이어진다. **호스트에서 비대화 `sudo` 는 반드시 실패한다**(observed) — `sudo -n -l` 이 `a password is required` 를 내므로 네 단계는 자동화할 수 없고 `ssh -t` 로 사람이 비밀번호를 친다. 그래서 이 실험은 처음에 강제 갱신을 못 하고 그 항목을 미측정으로 남겼다. **빈 출력을 「비었다」로 읽지 않는다** — 훅 디렉터리는 root 전용이라 `sudo` 없이 보면 `Permission denied` 이고, B-7 에서 같은 실수를 한 적이 있다. **판정 기준을 주입 전에 세운다** — 「reload 됐는가」는 로그 문구가 아니라 **nginx 워커 PID** 로 판정하고, 마스터가 유지되고 워커만 바뀌면 reload, 둘 다 바뀌면 재시작이다. 주입 전 워커는 `586` 이고 `lstart` 가 22.4시간 전이라 그동안 reload 가 한 번도 없었다. **시계를 주입 전에 잰다** — 두 기계가 **106초** 어긋나 있고 주입 후에는 그때의 왜곡을 되짚을 수 없다. 이 실험은 그것을 나중에 하는 바람에 공백을 `2199초` 로 적었다가 `2305초` 로 정정했다. **판정은** 갱신 성공(`Congratulations, all renewals succeeded`)과 디스크의 새 일련번호 `6c7cb6df…` 옆에서 밖의 일련번호가 161표본 동안 `0520BB…` 그대로인 것, 워커 PID 가 안 바뀐 것, 그리고 경로 셋(`ExecStartPost` · `renewal-hooks/` 셋 · nginx 플러그인)이 전부 비어 있는 것을 함께 본다. **`notBefore` 로 발급 시각을 계산하지 않는다** — Let's Encrypt 는 정확히 한 시간 백데이트하므로 기준은 SCT 다. **대조군이 오보를 막았다**(observed) — in-flight 감시의 실패 76건은 같은 순간 폴링 49건이 전부 200 이고 연결수 0, 소요 50µs, 재현 0/100 이라 서버 탓이 아니었다. **유효 범위** — 이 편이 잰 reload 는 **사람이 건 것이다.** 훅이 거는 자동 reload 는 D-4a 에서 따로 쟀다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, in-flight 아티팩트 76건의 원인, 타이머가 스스로 갱신하는 경로(만료 30일 전에야 조건이 성립한다)는 재지 않았다. `--dry-run` 의 `Running deploy-hook command` 줄, `-checkend 2592000` 감시 한 줄, `systemctl reload nginx` 형태는 미검증이다(unknown).",
"relations": [
"case:the-certificate-that-took-38-minutes-to-reach-the-wire",
"case:seventy-six-failures-that-were-not-the-servers",
"reference:judge-a-reload-by-the-worker-pid-not-the-log",
"reference:never-subtract-values-from-two-clocks",
"question:does-the-renewal-timer-actually-renew",
"setup:reproduce-d4a-deploy-hook"
],
"publication": "게시됨",
"file": "operations-that-report-success/setup/setup-reproduce-d4-certificate-renewal.md",
"status": "게시 전",
"studioId": "9349a3fe-5234-48ae-af9f-029ffc0d2296",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
},
{
"title": "deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다",
"kind": "setup",
"slug": "reproduce-d4a-deploy-hook",
"readiness": "READY",
"source": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a"
],
"pinned-versions": [
"certbot = 5.7.0"
],
"classification": "**D-4 가 적어 두고 검증하지 않은 처방을 실제로 넣는 절차라 따로 선다** — D-4 는 「deploy 훅을 넣으면 자동 반영되는가」를 **미측정**으로 남겼고, 처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰지 않으려고 편을 나눴다. **D-4 가 끝나 있어야 성립한다** — 「reload 판정은 워커 PID 로 한다」는 기준과 두 기계 시계의 왜곡을 **미리** 재 둔 값, 둘이 없으면 이 편은 못 읽는다. 출발점의 워커 `28829` 자체가 D-4 에서 사람이 친 `nginx -s reload` 의 결과이고, 이 편이 재는 것은 **훅이 거는 reload** 다. **인증서를 한 장 더 쓴다** — `--force-renewal` 을 또 한 번 치므로 주당 한도를 두 장 쓴 셈이 되고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다. **되돌리기는 한 줄인데 되돌리지 않는 편이 낫다** — 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아가고, 그 결함은 약 59일 뒤 (증거의 `VALID: 89 days` 는 만료까지다) 인증서 만료로 나타난다. **읽는 사람이 그대로 치는 명령이라 Setup 이다** — 바꾸는 것은 파일 하나 두 줄인데 `ssh -t`·`sudo install`·`certbot`·`ps`·`openssl` 이 이어지고 주입은 전부 호스트 쪽이라 사람이 비밀번호를 친다. **`deploy/` 여야 한다** — `post/` 는 갱신이 없는 날에도 하루 두 번 nginx 를 reload 하고, `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘길 때만 돈다. **`nginx -t &&` 를 앞에 두는 것도 절차의 일부다** — 설정이 깨진 채 reload 를 보내면 마스터가 새 워커를 못 띄우고, `restart` 를 걸면 `Restart=on-failure` · `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 때문에 10초 안에 5번 실패하고 systemd 가 포기한다. **판정을 문구로 하지 않는다** — certbot 이 찍는 `Hook 'deploy-hook' ran with error output:` 은 훅이 stderr 에 무엇이라도 쓰면 붙는 문구이고 종료 코드를 말하지 않는다. 여기서 stderr 로 나간 것은 nginx 의 `types_hash` 경고 하나이고 내용은 전부 성공이다. **판정은** 마스터 `585` 가 그대로고 워커가 `28829 → 37252` 로 바뀐 것, `etimes 74` 와 `lstart` 가 방금을 가리키는 것, 새 일련번호 `06F3E0EF…` 와 SAN 세 이름이다. `etimes` 를 같이 보는 까닭은 PID 가 재사용될 수 있기 때문이다. **시계 보정이 결과를 정한다** — 106초를 빼면 SCT `12:27:49.05` 뒤 1~2초에 훅과 새 워커가 놓이고, 보정하지 않고 그냥 빼면 107초가 나와 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나와 성립하지 않는다. 독립 시계인 SCT 가 보정을 자기 검증한다. **`notBefore` 는 발급 시각이 아니다** — Let's Encrypt 가 정확히 한 시간 백데이트하고, 한 시간을 더한 값도 SCT 보다 일관되게 약 89초 늦다. **유효 범위** — 타이머가 스스로 갱신하는 경로는 만료 30일 전(약 59일 뒤 (증거의 `VALID: 89 days` 는 만료까지다))에야 조건이 성립해 확인하지 않았고, 그때 볼 두 줄만 적어 두었다. 훅이 진짜로 실패했을 때의 출력, `/tmp` 에 남긴 훅 로그가 `PrivateTmp=true` 때문에 안 보이는지, `notBefore`+1시간과 SCT 사이 약 89초 차이의 원인은 재지 않았다. 훅 파일을 편집기로 만드는 형태와 설치·갱신을 한 줄씩 치는 형태도 이 형태로는 실행하지 않았다 — **이 실험대는 `printf … > /tmp/reload-nginx.sh` 로 만들고 설치와 강제 갱신을 한 줄로 쳤다**(observed·unknown).",
"relations": [
"case:a-deploy-hook-closed-the-gap-to-two-seconds",
"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",
"reference:never-subtract-values-from-two-clocks",
"question:does-the-renewal-timer-actually-renew",
"setup:reproduce-d4-certificate-renewal"
],
"publication": "게시됨",
"file": "operations-that-report-success/setup/setup-reproduce-d4a-deploy-hook.md",
"status": "게시 전",
"studioId": "4f32b469-185a-4dea-8eba-599864a3b476",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
}
]
},
"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": "초안",
"file": "when-the-measurement-lies/case/case-nine-injections-that-silently-did-nothing.md",
"status": "게시 전",
"studioId": "",
"assets": [
"injection-verification",
"a5-partition-asymmetry"
],
"assetFiles": [
"injection-verification",
"a5-partition-asymmetry"
],
"evidenceFiles": [
"../../../final/evidence/raw/a1-jgroups-transport-block__05-conntrack-problem.txt",
"../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt"
]
},
{
"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": "초안",
"file": "when-the-measurement-lies/case/case-seventy-six-failures-that-were-not-the-servers.md",
"status": "게시 전",
"studioId": "",
"assets": [
"measurement-control"
],
"assetFiles": [
"measurement-control"
],
"evidenceFiles": [
"../../../final/evidence/raw/d4-certificate-renewal__05-control-no-injection.txt",
"../../../final/evidence/raw/d4-certificate-renewal__11-inflight-full.txt"
]
},
{
"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",
"raw/followup__05-command-reproducibility.txt"
],
"publication": "초안",
"file": "when-the-measurement-lies/case/case-commands-written-as-prose-do-not-run.md",
"status": "게시 전",
"studioId": "",
"assets": [
"reproducibility-gap"
],
"assetFiles": [
"reproducibility-gap"
],
"evidenceFiles": [
"../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt",
"../../../final/evidence/raw/followup__05-command-reproducibility.txt"
]
},
{
"title": "가이드 26편을 순서대로 따라가니 첫 명령부터 막혔다",
"slug": "the-guides-broke-at-the-first-command",
"readiness": "READY",
"source": [
"final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함"
],
"code": [
"sudo kubectl",
"~/.kube/config",
"kubectl apply -f deploy/...",
"-l app=bff"
],
"classification": "가이드 26편을 실제로 쳐 가며 감사해 재현을 막는 결함을 계열로 확정했고 — sudo kubectl 905건, 저장소 클론 단계 누락(05·06), 그 단계에 아직 없는 리소스 조회(05) — 개별 명령은 전부 실제로 돌았던 것이라는 사실까지 확인해 「틀린 것은 명령이 아니라 그 명령이 놓인 위치」라는 공통 원인으로 닫았다",
"missing-verification": "SSOT 는 감사에서 무엇이 막혔는지까지만 적는다. 고친 가이드를 처음부터 다시 따라가 끝까지 도는지는 적혀 있지 않다. 905건 중 게스트에서 도는 것이 0건이라는 계수도 이 감사 한 번의 결과다",
"relations": [
"case:commands-written-as-prose-do-not-run",
"case:nine-injections-that-silently-did-nothing",
"reference:verify-the-injection-landed-separately-from-the-result"
],
"kind": "case",
"publication": "초안",
"file": "when-the-measurement-lies/case/case-the-guides-broke-at-the-first-command.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": []
}
],
"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": "초안",
"file": "when-the-measurement-lies/concept/concept-the-up-metric-cannot-see-alive-but-useless.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/a2-database-loss__04-health-and-service.txt",
"../../../final/evidence/raw/a2-database-loss__05-recovery.txt"
]
}
],
"reference": [
{
"title": "예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다",
"slug": "verify-the-injection-landed-separately-from-the-result",
"readiness": "READY",
"source": [
"final/document.md#결국-지키려던-것은-무엇이었나",
"final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다",
"final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는"
],
"classification": "세 규칙 가운데 **「대조군 없이 귀속하지 않는다」만 어긴 사례가 남아 있고 그것도 둘이다** — A-6 에서 −41% 인 대조군을 「영향 없음」이라 적은 것과 A-8 에서 표본 9개로 무중단을 주장한 것이고, 둘 다 나중에 고쳤다. **D-4 의 in-flight 76건은 위반이 아니라 이 규칙이 작동해 오보를 막은 사례다.** 「예측을 먼저 적는다」는 위반 사례가 0건이고, 「주입 확인을 결과와 따로」의 근거인 아홉 번은 규칙을 세우기 전의 실패다. SSOT 가 말하는 적용 범위는 이 실험대 이후의 실험까지다",
"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": "초안",
"file": "when-the-measurement-lies/reference/reference-verify-the-injection-landed-separately-from-the-result.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/a1-jgroups-transport-block__07-cluster-size.txt",
"../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt",
"../../../final/evidence/raw/a8-rolling-restart__01-restart-availability.txt",
"../../../final/evidence/raw/d4-certificate-renewal__05-control-no-injection.txt",
"../../../final/evidence/raw/d4-certificate-renewal__08-inflight-artifact.txt"
]
},
{
"title": "두 시계에서 온 값을 빼지 않는다",
"slug": "never-subtract-values-from-two-clocks",
"readiness": "READY",
"source": [
"final/document.md#결정이-지켜지는지-확인하는-방법-두-시계"
],
"classification": "보정 없이 뺀 값이 자릿수가 아니라 방향까지 틀리는 것을 D-4·D-4a 한 쌍에서 만났다. 근거는 그 사례 하나이고, 규칙은 「다른 측정도 틀린다」가 아니라 「빼기 전에 두 시계가 같은지 확인하라」다",
"scope": "서로 다른 기계에서 온 타임스탬프의 차이를 재는 자리",
"exceptions": "같은 기계의 단조 시계로만 잰 구간에는 걸리지 않는다",
"relations": [
"case:seventy-six-failures-that-were-not-the-servers",
"case:commands-written-as-prose-do-not-run"
],
"kind": "reference",
"publication": "초안",
"file": "when-the-measurement-lies/reference/reference-never-subtract-values-from-two-clocks.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt",
"../../../final/evidence/raw/followup__03-b4-role-propagation.txt"
]
}
],
"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",
"final/document.md#코드보다-먼저-드러난-문제-그런데-첫-실험에서-전제가-무너졌다"
],
"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#코드보다-먼저-드러난-문제-버전-조건",
"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#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건",
"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#결정이-지켜지는지-확인하는-방법-대조군-없이는",
"final/document.md#선택의-이유와-지킨-경계-a층-keycloak-자체가-깨질-때"
],
"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": "이 저장소의 그림 제작 절차이지 이 주제의 독자 질문에 답하지 않는다. 후보 범위 밖이다"
},
{
"id": "SSOT-ghost-rows-are-cleaned-by-the-surviving-coordinator",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-무엇을-쟀나",
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-관측",
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-결론"
],
"summary": "워크로드 종류 둘 × 종료 방식 둘에서 JGROUPS_PING 에 유령 행이 남지 않았고, 정리 주체는 남은 코디네이터였다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:ghost-rows-are-cleaned-by-the-surviving-coordinator",
"reason": "하나의 물음(유령 행은 어떻게 정리되는가)에 네 조합의 관측과 코디네이터 로그로 답하고, experiment-plan.md 의 미해결 항목 하나를 「자동」으로 닫는다. 다른 Case 의 한 절로 넣으면 그 Case 의 결론이 달라진다"
},
{
"id": "SSOT-statefulset-does-not-reuse-the-membership-row",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-관측",
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-결론"
],
"summary": "StatefulSet 이라도 같은 행을 덮어쓰지 않는다 — address 는 매번 새로 발급되고 name 의 접미사도 바뀐다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:ghost-rows-are-cleaned-by-the-surviving-coordinator",
"reason": "같은 측정의 관측표 두 행이다. 같은 질문에서 나와 같은 결론(워크로드 종류가 클러스터 동작을 바꾸지 않는다)에 닿으므로 그 Case 의 표로 들어간다"
},
{
"id": "SSOT-workload-kind-for-a-keycloak-cluster",
"kindCandidate": "DECISION",
"sourceRefs": [
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-결론"
],
"summary": "StatefulSet 의 근거가 사람이 읽기 위한 성질 둘로 좁혀졌고, 세션을 DB 에 두고 롤링 정책을 명시하면 Deployment 도 성립한다",
"disposition": "NEEDS_DECISION",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "SSOT 는 「Deployment 도 성립한다」를 inferred 로 적을 뿐 이 프로젝트가 워크로드 종류를 바꾸기로 정한 기록이 없다. 정해진 뒤에 다시 판정한다"
},
{
"id": "SSOT-who-cleans-when-the-coordinator-itself-dies",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-결론"
],
"summary": "코디네이터 자신이 죽으면 유령 행을 누가 정리하는가 — 정상 종료와 SIGKILL 만 쟀다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "SSOT 가 unknown 으로 적고 다음 검증도 종료 기준도 적지 않았다. Open Question 이 요구하는 칸을 지어내지 않고 Case 의 missing-verification 에 남긴다. 재고 나서 다시 판정한다"
},
{
"id": "SSOT-the-guides-broke-at-the-first-command",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함"
],
"summary": "가이드 26편을 순서대로 따라가며 감사하니 재현을 막는 결함이 계열로 나왔고, 틀린 것은 명령이 아니라 명령이 놓인 위치였다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "감사라는 하나의 검증에 규모가 붙은 관측(905건·05·06편)과 공통 원인이 있고 결론이 닫힌다. 측정 장치가 산문이던 Case 와는 물음도 진단도 다르다"
},
{
"id": "SSOT-905-sudo-kubectl-in-the-guides",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함"
],
"summary": "실험 가이드 전편이 sudo kubectl 을 쓰는데 kubeconfig 는 lab host 의 ~/.kube/config 에 있어 첫 명령부터 막힌다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "결함 표의 첫 행이다. 표의 행 하나가 될 것을 기록 하나로 만들지 않는다"
},
{
"id": "SSOT-follow-your-own-guide-in-order-once",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함"
],
"summary": "가이드를 쓴 뒤 처음부터 순서대로 한 번 따라간다 — 개별 명령이 맞아도 놓인 위치가 틀린다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "그 Case 의 결론을 선언문으로 바꾼 것이다. 적용 조건과 예외를 SSOT 에서 댈 수 없어 Reference 로 서지 않는다"
},
{
"id": "SSOT-guides-are-baseline-inject-verify-observe-recover",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함"
],
"summary": "가이드 26편은 기준선 → 주입 → 주입 검증 → 관찰 → 복구 구조이고 주입 검증이 핵심인 편이 많다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "재현 문서의 구성이지 Case 를 읽기 전에 처음부터 설명해야 하는 메커니즘이 아니다. 주입 검증이 왜 핵심인지는 이미 case:nine-injections-that-silently-did-nothing 이 담는다"
},
{
"id": "SSOT-reproduce-a0-session-sharing-path",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-0"
],
"summary": "세션 공유 경로를 가르는 시험 넷을 순서대로 치고 문장 로깅으로 SQL 을 포획한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a0-session-sharing-path",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a1-jgroups-transport-block",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-1"
],
"summary": "NetworkPolicy 로 7800 을 빼고 conntrack 을 지운 뒤 파드를 재시작해 분단을 만든다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a1-jgroups-transport-block",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a2-database-loss",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-2"
],
"summary": "DB 를 정상 종료시키고 네 경로와 헬스·엔드포인트·up 을 같은 명령으로 견준다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a2-database-loss",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a3-database-crash",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-3"
],
"summary": "죽이는 데 실패하는 두 방법을 먼저 밟고 백엔드를 죽인 뒤 클라이언트 sid 와 DB 를 견준다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a3-database-crash",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a4-node-loss",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-4"
],
"summary": "virsh destroy 로 워커와 컨트롤 플레인을 차례로 끄고 인지·축출·재배치 실패를 시각으로 잰다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a4-node-loss",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a5-asymmetric-partition",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-5"
],
"summary": "실패하는 주입 둘을 먼저 밟고 수신측 raw PREROUTING 에 넣은 뒤 양방향으로 갈라 놓는다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a5-asymmetric-partition",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a6-latency-injection",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-6"
],
"summary": "인터페이스 이름과 캡슐화에 두 번 막힌 뒤 flannel.1 에 지연을 걸고 동시 20건으로 큐잉을 만든다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a6-latency-injection",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a7-volatile-comparison",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7"
],
"summary": "args 한 줄로 volatile 로 바꾸고 A-0·A-8·A-1·A-2 를 같은 명령으로 다시 친 뒤 원복한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a7-volatile-comparison",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a7a-volatile-cause",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a"
],
"summary": "표식과 문장 로깅으로 실패한 SQL 을 확정하고 재시작을 끼워 캐시 온도 셋을 따로 재현한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a7a-volatile-cause",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-a8-rolling-restart",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-8"
],
"summary": "재시작 전 토큰을 파드 안에 담고 롤링 재시작을 건 뒤 refresh·DB 행·캐시를 함께 본다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a8-rolling-restart",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 A층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-a-layer-guide-file-map",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서"
],
"summary": "열 편과 근거 가이드 파일·줄 수를 짝지은 대응표",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "어느 절이 어느 가이드에서 왔는지를 대는 색인이다. 열 Setup 의 source 가 같은 것을 가리키므로 따로 기록으로 만들면 두 곳이 같은 말을 한다"
},
{
"id": "SSOT-a-layer-observed-and-unknown-marks",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서"
],
"summary": "가이드의 실측·형태·미검증 표시를 (observed)·(unknown) 으로 옮기는 규약",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 문서가 인용을 표시하는 규약이지 다음 프로젝트에 줄 규칙이 아니다. 적용 범위와 예외를 SSOT 에서 댈 수 없어 Reference 로 서지 않는다"
},
{
"id": "SSOT-a-layer-two-forms-for-each-command",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서"
],
"summary": "가이드가 규범을 어긴 곳마다 이 실험대가 친 형태와 따라 할 형태를 나란히 적고, 안 친 쪽은 unknown 으로 표시한다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "열 Setup 이 그대로 따르는 편집 규약이라 본문에서 매번 보인다. 규칙 하나로 빼면 적용 조건이 이 문서 밖으로 안 나간다"
},
{
"id": "SSOT-a-layer-preamble-sudo-kubectl-mismatch",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서"
],
"summary": "열 편 중 아홉의 전제 한 줄이 sudo kubectl 을 시키는데 같은 폴더의 README 는 붙이지 말라고 적는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "905건을 센 그 Case 의 첫 행과 같은 고장이다. A층 전문이 더한 것은 본문 명령 블록에는 한 번도 안 쓰이고 전제 한 줄만 옛 형태로 남았다는 확인까지다"
},
{
"id": "SSOT-a3-two-clean-zero-loss-results-that-were-not-kills",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서",
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-3"
],
"summary": "A-3 은 세 번 죽여 두 번 실패했고, 실패한 두 번이 모두 「유실 0건」이라는 깨끗한 결과를 냈다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "「주입했다고 걸린 것이 아니다」의 사례 둘이고 그 Case 의 표가 이미 같은 두 행을 담는다. 절차 쪽은 setup:reproduce-a3-database-crash 가 받는다"
},
{
"id": "SSOT-a5-three-blocked-injections",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-5"
],
"summary": "A-5 에서 iptables 가 세 번 막혔다 — kube-router 의 체인 재삽입, 방향을 잘못 짚은 raw 규칙, 그리고 성공 뒤의 역방향 재연결",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "아홉 번 표의 4번과 5번이 그것이다. 새 기록으로 만들면 같은 표를 두 번 쓴다"
},
{
"id": "SSOT-scripts-printed-success-while-nothing-landed",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-6",
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a"
],
"summary": "A-6 의 스크립트는 Cannot find device 네 줄 사이에 「적용완료」를 찍었고, A-7a 의 표식 함수는 출력을 /dev/null 로 버렸다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:commands-written-as-prose-do-not-run",
"reason": "성공 메시지가 커널이 아니라 스크립트에서 나온 같은 고장 둘이고, 「스크립트를 쓰지 않는다」를 근거로 대는 그 Case 가 이미 이 절을 가리킨다"
},
{
"id": "SSOT-jgroups-connection-direction-is-not-fixed",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-5"
],
"summary": "JGroups 의 TCP 연결 방향은 고정이 아니라 먼저 JOIN 을 건 쪽에 따라 정해지고 파드가 재시작될 때마다 바뀔 수 있다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a5-asymmetric-partition",
"reason": "이 사실이 쓰이는 곳은 「규칙을 어느 노드에 어느 목적지로 넣나」 하나뿐이고, A-5 절차가 매번 conntrack -L 로 보라고 적는다. 메커니즘만 떼면 읽을 사람이 없다"
},
{
"id": "SSOT-why-the-entry-point-returned-000-for-40-seconds",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-4"
],
"summary": "노드를 뽑은 뒤 처음 40초 동안 정문이 503 이 아니라 000 이었던 까닭을 nginx 로그로 확인하지 못했다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "SSOT 가 upstream 에 두 노드가 다 들어 있다는 구조로 설명하지만, 그것을 잡았어야 할 증거 파일의 마지막 절이 제목만 있고 비어 있다. 답을 낼 명령은 A-4 절에 unknown 으로 적혀 있고 아직 안 쳤다"
},
{
"id": "SSOT-how-long-the-client-scope-cache-stays-warm",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a"
],
"summary": "CLIENT_SCOPE_CLIENT 조회 결과의 캐시가 얼마나 오래 더운지를 재지 않았다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "만료 시간을 모르므로 재현 C 를 한참 뒤에 다시 재면 또 다른 답이 나올 수 있다. 캐시 온도가 결과를 가른다는 판정의 유효 기간이 여기에 달려 있는데 아직 측정이 없다"
},
{
"id": "SSOT-reproduce-b0-default-session-store",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-0"
],
"summary": "소스 넷을 B-0 시점으로 되돌려 다시 빌드하고 두 노드에 import 한 뒤 자동구성이 고른 빈을 찍는다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b0-default-session-store",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b1-redis-session-store",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-1"
],
"summary": "의존성 둘을 넣고 일부러 고장 난 채 한 번 배포한 뒤 enableServiceLinks 로 고치고 빈 목록을 견준다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b1-redis-session-store",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b2-jdbc-token-store",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-2"
],
"summary": "PostgreSQL 전용 DDL 을 파일로 태우고 기본키를 읽은 뒤 덮어쓰기와 로그아웃 정리를 같은 명령으로 잰다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b2-jdbc-token-store",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b3-refresh-contention",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-3"
],
"summary": "상주 탐침 파드에서 회전을 켜고 같은 refresh token 다섯 개를 동시에 던져 client session 을 센다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b3-refresh-contention",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b4-forged-identity-headers",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4"
],
"summary": "대조군을 먼저 잡고 동명 헤더·쉼표·크기·위조 신원을 차례로 보내며 JWT 경로와 견준다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b4-forged-identity-headers",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b5-redis-loss",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-5"
],
"summary": "Redis 를 0대로 내려 세 경로와 health 그룹을 재고, 볼륨을 떼고 AOF 만 켠 채 파드를 지운다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b5-redis-loss",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b6-key-rotation",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6"
],
"summary": "우선순위가 높은 RSA 공급자를 더해 겹침을 확인한 뒤 옛 공급자를 지우고 두 토큰을 다시 친다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b6-key-rotation",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b7-cookie-secret-rotation",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7"
],
"summary": "Grafana Ingress 를 빌려 oauth2-proxy 를 띄우고 secret 참조를 A→B 로 바꾼 뒤 로그 두 줄을 가른다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b7-cookie-secret-rotation",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-b7a-orphan-session",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7a"
],
"summary": "refresh:disabled 를 확인하고 두 번 회전시킨 뒤 TTL 역산으로 고아를 골라 지운다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b7a-orphan-session",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 B층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-b-layer-shared-preconditions",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서"
],
"summary": "B층 아홉 편의 공통 전제 — 애플리케이션 소스와 매니페스트를 고쳐 다시 빌드하고 두 노드에 각각 import 한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b0-default-session-store",
"reason": "레지스트리가 없어 `imagePullPolicy: Never` 이고 되돌리기가 `git checkout` 뒤 재빌드까지 가는 것은 아홉 편에 공통이지만, 읽는 사람이 그 순서를 처음 치는 자리는 B-0 하나다. 떼어 놓으면 명령이 없는 전제 목록만 남는다"
},
{
"id": "SSOT-b-layer-marker-convention",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서"
],
"summary": "가이드가 출력에 붙인 표시 셋 — 실측 · 형태 · 미검증",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": "",
"reason": "A층과 같은 셋이고 SSOT 가 이미 그 뜻을 표로 적어 두었다. 아홉 편이 각자 그 규약을 다시 쓰므로 독립 기록으로 떼면 같은 말이 열 번째로 늘어난다"
},
{
"id": "SSOT-b-layer-premise-says-sudo-kubectl",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서"
],
"summary": "아홉 편 중 여덟의 전제가 `sudo kubectl` 인데 같은 폴더 README 는 반대로 적고, 본문 명령 블록은 `sudo kubectl` 을 한 번도 쓰지 않는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "A층에서 이미 같은 결함을 관측해 Case 로 담았고, B층은 그 결함이 한 층 더 남아 있다는 같은 사실의 두 번째 표본이다. 표본이 하나 늘었다고 기록이 하나 늘 이유는 없다"
},
{
"id": "SSOT-b-layer-long-unverified-grep-lines",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서"
],
"summary": "`jq` 가 없어 JSON 을 `grep`·`sed` 로 읽고, 그 줄들이 길고 전부 미검증으로 표시되어 있다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:commands-written-as-prose-do-not-run",
"reason": "「산문으로 적힌 측정 장치」와 같은 결함이고 그 Case 가 이미 판정 기준을 갖고 있다. B층 아홉 편은 같은 기준을 적용받는 대상이지 새 결론이 아니다"
},
{
"id": "SSOT-b1-service-links-injects-redis-port",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-1"
],
"summary": "쿠버네티스가 Service 이름마다 넣는 환경변수가 `REDIS_PORT` 를 URL 로 덮어써 파드가 설정 바인딩에서 죽는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-b1-redis-session-store",
"reason": "가이드가 이 함정을 **일부러 한 번 겪게** 해 두었고 쓰이는 곳이 「첫 배포를 어떻게 하나」 하나뿐이다. 메커니즘만 떼면 읽을 사람이 없다"
},
{
"id": "SSOT-b4-header-size-cliff",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4"
],
"summary": "헤더가 8KB 를 넘으면 Tomcat 이 `400`, 16KB 이상이면 nginx 가 연결을 끊어 `000` 이 된다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nginx-does-not-overwrite-a-header-it-never-sets",
"reason": "같은 실험의 관측이고 그 Case 가 B-4 의 발견을 담는 자리다. 없애고 그 Case 의 한 절로 넣어도 이해·결정·재사용성이 그대로다"
},
{
"id": "SSOT-b4-strip-header-prescription-unmeasured",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4"
],
"summary": "`proxy_set_header X-Auth-Request-* \"\"` 로 먼저 지우면 위조가 막힌다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": "",
"reason": "이 실험대는 그 수정을 적용한 적이 없고 적용 뒤 다시 재는 절도 미검증이다. **위조가 막히는지는 이 SSOT 어디에도 측정으로 없다** — 규칙으로 올리려면 적용하고 같은 명령을 다시 쳐야 한다"
},
{
"id": "SSOT-b6-overlap-must-cover-the-longest-token",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6"
],
"summary": "키 겹침 구간은 옛 키로 서명된 것 중 가장 오래 사는 것의 수명만큼 — 이 실험대에서는 최소 30분",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": "",
"reason": "access token 60초와 refresh token 1800초라는 설정에서 따라 나온 추론이고, 겹침을 실제로 30분 유지하며 그 사이 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다"
},
{
"id": "SSOT-b5-no-metrics-for-the-b-layer",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-5"
],
"summary": "Prometheus 가 긁는 대상에 Redis·PostgreSQL·BFF 가 없어 B층 네 지표가 전부 시계열 0개다",
"disposition": "NEEDS_DECISION",
"dispositionReview": "CONFIRMED",
"target": "",
"reason": "「관측은 실험 설계에 포함되어야 한다」까지가 SSOT 가 적은 것이고, exporter 를 붙일지 이 프로젝트가 정하지 않았다. 다음 검증과 종료 조건이 없으면 Open Question 의 칸을 못 채운다"
},
{
"id": "SSOT-b7a-back-calculation-breaks-under-cookie-refresh",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7a"
],
"summary": "TTL 역산 규칙은 `refresh:disabled` 에서만 유효하고 `--cookie-refresh` 를 켜면 산 세션이 고아로 오판된다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": "",
"reason": "규칙의 적용 범위이지 따로 읽을 규칙이 아니다. B-7a 절차의 첫 단계가 그 전제를 확인하는 일이라 거기서 읽혀야 뜻이 있고, 켠 뒤 어떻게 어긋나는지는 재지도 않았다"
},
{
"id": "SSOT-reproduce-c1-multi-app-sso",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1"
],
"summary": "두 앱에 차례로 들어가 SSO 상태를 만든 뒤 IdP 세션만 끊고 앱 세션이 남는지 본다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-c1-multi-app-sso",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-c2-backchannel-logout",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2"
],
"summary": "클라이언트 속성을 백업하고 보낼 주소만 넣은 뒤 로그아웃이 퍼지는지 세 후보로 가른다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-c2-backchannel-logout",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-c-layer-shared-preconditions",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서"
],
"summary": "C층 두 편의 공통 전제 — 앱이 둘 필요하고, app2 는 Grafana 에서 빌린 이름이며, 브라우저가 있어야 한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-c1-multi-app-sso",
"reason": "두 편에 공통이지만 읽는 사람이 그 준비를 처음 하는 곳은 C-1 하나다. 떼어 놓으면 명령이 없는 전제 목록만 남고, 빌린 이름을 언제 돌려주는가도 두 편의 순서에 달려 있어 절차 안에서만 뜻이 선다"
},
{
"id": "SSOT-c-layer-marker-convention",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서"
],
"summary": "가이드가 출력에 붙인 표시 셋 — 실측 · 형태 · 미검증",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "A·B층과 같은 셋이고 SSOT 가 이미 그 뜻을 표로 적어 두었다. 두 편이 각자 그 규약을 다시 쓰므로 독립 기록으로 떼면 같은 말이 세 번째로 늘어난다"
},
{
"id": "SSOT-c-layer-premise-says-sudo-kubectl",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서"
],
"summary": "두 편의 전제가 모두 `sudo kubectl` 인데 같은 폴더 README 는 반대로 적고, 본문 명령 블록은 `sudo kubectl` 을 한 번도 쓰지 않는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "A층과 B층에서 이미 같은 결함을 관측해 Case 로 담았고, C층은 그 결함이 한 층 더 남아 있다는 같은 사실의 세 번째 표본이다. 표본이 하나 늘었다고 기록이 하나 늘 이유는 없다"
},
{
"id": "SSOT-c1-user-session-and-client-session-are-two-layers",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1"
],
"summary": "Keycloak 은 세션을 user session(사람 하나)과 client session(그 사람이 쓰는 앱 하나)으로 나눠 두고, A-3 은 앞엣것을 B-3 은 뒤엣것을 지웠다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-c1-multi-app-sso",
"reason": "이 구조가 쓰이는 곳은 「주입이 걸렸는가」를 `client_sessions 1 → 2` 로 판정하는 한 곳뿐이고, C-1 절차가 그 자리에서 표로 갈라 적는다. 메커니즘만 떼면 읽을 사람이 없다"
},
{
"id": "SSOT-c1-counting-sessions-without-the-realm-means-nothing",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1"
],
"summary": "`offline_user_session` 에는 모든 realm 의 세션이 들어 있어 realm 을 조인하지 않은 세션 수는 결론을 정반대로 읽게 한다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 실험대의 테이블 모양에 달린 세는 법이지 다음 프로젝트에 줄 규칙이 아니다. C-1 절차가 「세는 법을 여기서 고친다」를 한 단계로 두고 있어 거기서 읽혀야 뜻이 있다"
},
{
"id": "SSOT-c1-two-screenshots-are-the-same-file",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1"
],
"summary": "두 시점의 브라우저 증거가 md5 `2c703176…` 로 동일한 파일이라 시점을 구별하는 증거가 되지 못한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-c1-multi-app-sso",
"reason": "화면이 같아 보인다는 것 자체가 이 편의 결론이라 증거로는 못 쓴다는 주의이고, 그 주의가 필요한 곳은 판정을 터미널 출력으로 옮기는 C-1 절차 안이다. 조작이 아니므로 결함 Case 로도 서지 않는다"
},
{
"id": "SSOT-c1-when-does-the-app-session-actually-break",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1"
],
"summary": "IdP 세션을 지운 뒤 앱 세션이 실제로 언제 끊기는지를 기다려서 확인하지 않았다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "`Session not active` 가 나온다는 것은 세 수명에서 따라 나온 추론이고, 수명 두 값을 읽는 줄도 가이드가 미검증으로 표시했다. 기다려서 재기 전에는 Open Question 의 다음 검증과 종료 조건을 채울 수 없다"
},
{
"id": "SSOT-c2-nobody-implemented-either-side",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2"
],
"summary": "보낼 주소도 받을 엔드포인트도 없었고, 한쪽만 고치면 여전히 안 퍼진다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nobody-implemented-backchannel-logout",
"reason": "그 Case 가 이 발견을 담는 곳이고 네 가지를 순서대로 확인해 닫는 구조까지 같다. 재현 절차 쪽은 setup:reproduce-c2-backchannel-logout 이 받는다"
},
{
"id": "SSOT-c2-evidence-file-does-not-match-the-printed-output",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2"
],
"summary": "설정이 들어간 것을 확인한 두 줄이 `02-configure-idp.txt` 에 없다 — 그 파일은 점 표기 실패로 끝난다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-c2-backchannel-logout",
"reason": "증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 곳이고, 처방이 「따라 하는 사람은 지금 직접 재 둔다」라 쓰이는 곳이 C-2 의 주입 검증 절 하나다. 떼면 처방 없는 지적만 남는다"
},
{
"id": "SSOT-c2-zero-log-lines-prove-nothing",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2"
],
"summary": "로그의 `backchannel` 0줄은 「Keycloak 이 안 보냈다」가 아니라 「기본 로그 레벨에서는 안 보인다」이다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "`DEBUG` 를 켜서 다시 재지 않았다. 「0줄로는 단정하지 않는다」까지가 SSOT 가 적은 것이고, 무엇을 켜면 보이는지를 재기 전에는 적용 조건과 예외를 댈 수 없다"
},
{
"id": "SSOT-c2-hairpin-200-is-a-property-of-this-lab",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2"
],
"summary": "클러스터 안에서 공개 이름이 되돌아온 `HTTP 200` 은 tailnet 과 split DNS 구성 덕이고 운영에서는 안 되는 경우가 흔하다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 판정의 유효 범위이지 따로 읽을 규칙이 아니다. 도달성을 재는 단계 바로 옆에서 읽혀야 「200 이 나왔으니 운영도 된다」로 넘어가는 것을 막고, 운영에서 어떻게 갈리는지는 재지도 않았다"
},
{
"id": "SSOT-reproduce-d1-backup-restore",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1"
],
"summary": "덤프를 뜨고 검증 넷을 통과한 뒤 스키마를 통째로 지우고 같은 명령으로 복구를 대조한다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d1-backup-restore",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-d2-version-upgrade",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2"
],
"summary": "`databasechangelog` 행 수를 먼저 세고 태그를 정방향·롤백·역방향으로 세 번 바꾼다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d2-version-upgrade",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-d3-secret-exposure",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3"
],
"summary": "카나리아 Secret 을 심고 API·노드 디스크·파드 안·RBAC 네 경로를 하나씩 연다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d3-secret-exposure",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-d4-certificate-renewal",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4"
],
"summary": "감시 셋과 시계 왜곡을 먼저 잡고 강제 갱신을 한 번 쳐서 디스크와 네트워크가 갈리는 것을 잰다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d4-certificate-renewal",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-reproduce-d4a-deploy-hook",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a"
],
"summary": "`deploy/` 에 두 줄짜리 훅을 설치하고 다시 강제 갱신해 워커 PID 와 SCT 로 1~2초를 잰다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d4a-deploy-hook",
"reason": "SSOT 가 이 편을 명령·예상 출력·되돌리기까지 갖춘 한 줄기 절차로 담고 있고, 읽는 사람이 그대로 쳐야 성립한다. 발견은 C·D층 Case 가 이미 담으므로 겹치지 않는다"
},
{
"id": "SSOT-d-layer-marker-convention",
"kindCandidate": "SETUP",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서"
],
"summary": "가이드가 출력에 붙인 표시 셋 — 실측 · 형태 · 미검증",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "A·B·C층과 같은 셋이고 SSOT 가 이미 그 뜻을 표로 적어 두었다. 다섯 편이 각자 그 규약을 다시 쓰므로 독립 기록으로 떼면 같은 말이 네 번째로 늘어난다"
},
{
"id": "SSOT-d-layer-premise-says-sudo-kubectl",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서"
],
"summary": "다섯 편의 전제도 `sudo kubectl` 인데 같은 폴더 README 는 반대로 적고, D-1 만 「kubeconfig 를 홈에 복사해 뒀다면 빼도 된다」는 괄호를 스스로 달았다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-guides-broke-at-the-first-command",
"reason": "A·B·C층에서 이미 같은 결함을 관측해 Case 로 담았고, D층은 네 번째 표본이다. D-1 이 괄호를 단 것도 같은 결함이 각 편에서 다르게 새어 나온 모양이지 새 결론이 아니다"
},
{
"id": "SSOT-d1-restore-without-i-is-indistinguishable-from-success",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서",
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1"
],
"summary": "`kubectl exec` 에 `-i` 를 빼면 `psql` 이 빈 입력을 받고 정상 종료해 오류도 종료 코드도 없이 「1초 만에 복구됐다」로 찍힌다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "조용히 실패하고 아무 일도 없는 것처럼 보이는 같은 고장이고, D층에서는 그것이 주입이 아니라 복구 쪽에서 온다는 것만 다르다. 그 Case 의 표가 이미 같은 판정 기준을 갖고 있다"
},
{
"id": "SSOT-d1-empty-database-still-answers-200",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1"
],
"summary": "데이터베이스를 통째로 비웠는데 파드는 `1/1 Running` 이고 정문이 `200` 이며 로그는 `WARN` 이다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "concept:the-up-metric-cannot-see-alive-but-useless",
"reason": "살아 있지만 쓸모없는 상태를 프로브가 못 보는 같은 성질이고, 헬스체크가 커넥션만 보기 때문에 빈 데이터베이스를 통과시킨다는 설명이 그 Concept 의 몫이다. 「토큰 발급만 정직한 지표다」도 같은 문장의 뒷면이다"
},
{
"id": "SSOT-d1-the-dump-sits-in-the-same-failure-domain",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1"
],
"summary": "덤프가 DB 와 같은 기계에 있으면 백업이 아니다 — 진짜 요건은 다른 기계가 아니라 다른 장애 도메인이다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 실험대는 덤프를 옮기는 마지막 단계를 하지 않았고 그 두 줄도 미검증이다. 덤프는 DB 와 같은 기계에 남았으므로, 규칙으로 올리려면 옮긴 뒤 그 파일로 실제로 복구해 봐야 한다"
},
{
"id": "SSOT-d2-the-conclusion-was-corrected-by-a-second-run",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2"
],
"summary": "「롤백은 안 된다」고 단정했다가 후속 실행에서 「행 수가 안 바뀌면 된다」로 정정했다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-upgrade-that-would-not-roll-back",
"reason": "그 Case 가 이미 「그 판정은 조건부였다」로 닫고 정정 자체를 담고 있다. 재현 절차 쪽은 setup:reproduce-d2-version-upgrade 가 받는다"
},
{
"id": "SSOT-d2-schema-growth-blocks-a-tag-rollback",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2"
],
"summary": "`databasechangelog` 행 수가 늘면 이미지 태그를 되돌리는 것만으로는 못 돌아온다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "역방향(26.0)에서 관측한 체크섬 실패를 근거로 한 추론이며(inferred) 실제로 행 수가 늘어난 뒤 되돌려 본 적이 없다. 26.7.x 사이에는 스키마 변경이 없어 이 실험대에서는 재현하지 못했다"
},
{
"id": "SSOT-d3-node-disk-holds-the-plaintext",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3"
],
"summary": "k3s 의 저장 파일 `state.db` 안에서 비밀번호 평문이 두 번 잡히고 저장소 암호화는 켠 적이 없다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d3-secret-exposure",
"reason": "앞선 분해가 D-3 의 base64 관측을 KEEP_IN_SSOT 로 두어 이 발견을 담을 Case 가 없고, 노드 디스크 경로는 그 관측의 두 번째 겹이라 새 기록을 세우면 같은 주장이 두 곳에서 갈린다. 절차 안에서는 ②의 판정 출력으로 그대로 읽힌다"
},
{
"id": "SSOT-d3-a-grep-that-returns-zero-is-not-absence",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3"
],
"summary": "같은 파일에 같은 명령을 걸었는데 키에 따라 `2` 와 `0` 이 나왔고, `0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "`0` 이 나온 원인을 이 실험이 가리지 않았고 `-wal`·`strings` 로 다시 보는 두 줄도 미검증이다. 왜 안 나왔는지를 재기 전에는 규칙의 적용 조건과 예외를 댈 수 없다"
},
{
"id": "SSOT-d3-deleting-a-secret-does-not-erase-the-file",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3"
],
"summary": "Secret 을 지워도 저장 파일에서 바로 사라지지는 않는다 — 그래서 유출에 필요한 것은 삭제가 아니라 회전이다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 실험은 삭제 후를 재지 않았고 그 두 줄을 미검증으로 표시했다. 어느 쪽이 나오든 관찰 절의 결론은 안 바뀌지만, 「지워도 남는다」를 독립된 관측으로 적으려면 지운 뒤 실제로 세야 한다"
},
{
"id": "SSOT-d4-non-interactive-sudo-cannot-be-automated",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4",
"final/document.md#문제를-어렵게-만든-제약-게스트와-호스트의-sudo-가-다르다"
],
"summary": "호스트에서 `sudo -n` 은 반드시 `a password is required` 로 실패하고, 그 빈 출력을 「설정이 없다」로 읽으면 진단이 통째로 틀린다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d4-certificate-renewal",
"reason": "이 사실이 쓰이는 곳은 「어느 네 줄을 사람이 직접 치는가」 하나뿐이고, D-4 절차가 그것을 표로 갈라 적는다. 이 실험이 처음에 강제 갱신을 미측정으로 남긴 까닭도 같은 표 안에서만 뜻이 선다"
},
{
"id": "SSOT-d4-nginx-error-log-truncates-at-2048-bytes",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4"
],
"summary": "nginx 에러 로그는 한 항목이 `NGX_MAX_ERROR_STR` 2048바이트에서 잘리고 저널 포맷을 바꿔도 안 늘어난다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d4-certificate-renewal",
"reason": "막히는 증상 하나의 원인과 우회(access 로그를 본다)이고 쓰이는 곳이 그 한 곳이다. 떼면 어디서 만나는 문제인지가 빠진 상식 한 줄이 된다"
},
{
"id": "SSOT-d4-crt-sh-indexes-a-subset-of-the-truth",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4"
],
"summary": "서빙 중인 인증서에는 SCT 가 둘 박혀 있는데 crt.sh 조회는 `[]` 를 돌려준다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "concept:the-up-metric-cannot-see-alive-but-useless",
"reason": "관측 도구가 진실의 부분집합만 본다는 같은 함정이고 SSOT 자신이 A-2 의 `up` 지표와 같은 종류라고 적는다. 그 Concept 이 이미 그 성질을 설명하는 곳이다"
},
{
"id": "SSOT-d4-2199-was-corrected-to-2305",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4",
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a"
],
"summary": "디스크 mtime(호스트 시계)과 일련번호 관측(개발 머신 시계)을 그대로 빼서 2199초로 적었다가 106초를 보정해 2305초로 고쳤다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "reference:never-subtract-values-from-two-clocks",
"reason": "그 Reference 가 담는 규칙의 실제 사례이고, 같은 106초가 D-4 에서는 결론을 안 바꾸고 D-4a 에서는 뒤집는다는 것이 그 규칙의 적용 조건이다. 사례를 따로 세우면 규칙과 예시가 두 곳으로 갈린다"
},
{
"id": "SSOT-d4a-ran-with-error-output-is-not-a-failure",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a"
],
"summary": "certbot 의 `Hook 'deploy-hook' ran with error output:` 은 훅이 stderr 에 무엇이라도 쓰면 붙는 문구이고 종료 코드를 말하지 않는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "reference:judge-a-reload-by-the-worker-pid-not-the-log",
"reason": "「적용됐는지는 로그 문구가 아니라 상태로 판정한다」가 그 Reference 이고 이 문구가 그 규칙이 막는 오독의 사례다. 로그에서 `error` 를 긁는 감시가 성공한 훅을 실패로 읽는다는 것까지 같은 규칙 안에 든다"
},
{
"id": "SSOT-d4a-notbefore-is-not-the-issue-time",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a"
],
"summary": "Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트하고, 한 시간을 더한 값도 SCT 보다 일관되게 약 89초 늦다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-d4a-deploy-hook",
"reason": "이 사실이 쓰이는 곳은 「발급 시각의 기준을 무엇으로 잡는가」 하나뿐이고, 약 89초 차이의 원인은 이 실험이 규명하지 않았다. 메커니즘만 떼면 설명할 수 없는 상수 하나가 남는다"
},
{
"id": "SSOT-d4a-timer-path-is-still-unmeasured",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a"
],
"summary": "훅은 `--force-renewal` 로만 검증했고 타이머가 스스로 갱신하는 경로는 만료 30일 전에야 조건이 성립한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "question:does-the-renewal-timer-actually-renew",
"reason": "같은 미지수를 이미 Open Question 으로 담고 있고, D-4a 가 더한 것은 그날이 오면 볼 두 줄까지다. 질문을 하나 더 세우면 같은 종료 조건이 두 곳에 적힌다"
},
{
"id": "SSOT-concepts-layer-to-topic-map",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-여덟-층이-받치는-것"
],
"summary": "여덟 층과 계약의 주제 여섯을 짝지은 대응표, 그리고 실험 문서에서 이름만 쓰이고 설명된 적이 없는 용어가 24개라는 계수",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"reason": "분해 계약이 이미 선 뒤에 그 계약을 설명하려고 쓴 자리이고, 24개는 조사 범위를 센 값이다. 「구현 클래스 51개를 전부 읽었다」와 같은 꼴이라 독립 기록으로 읽을 사람이 없다"
},
{
"id": "SSOT-concepts-l0-guest-is-a-host-process",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-0층-가상화"
],
"summary": "게스트는 호스트에서 qemu-system 프로세스 하나이고 virsh destroy 는 ACPI 신호 없이 그 프로세스를 끊는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:two-ways-to-lose-a-node",
"reason": "A-4 에서 노드를 뽑은 뒤 45초 동안 아무 일도 없었던 까닭이 이것이다. 노드를 잃는 두 경로를 담은 그 Case 의 한 절로 들어간다. 같은 층의 인터페이스 이름(enp1s0)과 virtio 시드 디스크는 이미 nine-injections Case 와 a6 Setup 이 흡수했다"
},
{
"id": "SSOT-concepts-l0-memory-reads-differently-in-three-places",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-0층-가상화"
],
"summary": "호스트 RSS · 게스트 available · kubectl top 이 같은 메모리를 다르게 내고, 상한을 바꾸려면 껐다 켜야 하며 게스트에는 swap 을 두지 않았다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"reason": "이 실험대의 자원 배치 사실이라 독립 기록이 되지 않는다. 같은 층의 정의는 virtualization 프로젝트가 concept:memory-pressure-reclaim-swap-oom 과 question:qemu-resident-memory-distribution 으로 담고 있어 두 프로젝트가 같은 것을 두 번 올리지 않는다"
},
{
"id": "SSOT-concepts-l0-what-is-under-this-layer",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-0층-가상화"
],
"summary": "KVM 의 ioctl 층과 struct kvm_run, VMX root 와 non-root, virtio 의 virtqueue 와 전송 계층, vhost-user, VFIO 의 IOMMU 그룹과 DMA 리매핑",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"reason": "SSOT 가 스스로 「이 실험대에서 잰 것이 아니라 커널·Intel·QEMU 문서를 읽은 결과」라고 적고 패스스루는 쓴 적조차 없다고 적는다. 이 자리의 정의는 virtualization 프로젝트의 SSOT 가 정본이고 concept:kvm-vcpu-to-physical-cpu · guest-packet-path-to-physical-nic · guest-block-io-path-to-virtqueue 가 같은 것을 담는다. 저쪽이 정의이고 이쪽이 측정이라 이쪽에서 글감으로 올리지 않는다"
},
{
"id": "SSOT-concepts-l1-systemd-unit-semantics",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-1층-리눅스와-systemd"
],
"summary": "Type= 이 「떴다」를 판정하고 KillMode·KillSignal 이 멈추는 방식을 정하며, systemctl cat 에 적힌 것과 systemctl show 가 내는 적용값이 다르다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "reference:judge-a-reload-by-the-worker-pid-not-the-log",
"reason": "이 층이 받치는 기록들이 이미 필요한 만큼씩 흡수했다 — deploy 훅 Case 가 Restart=on-failure·RestartUSec=100ms·StartLimitBurst=5 와 KillMode=mixed·SIGQUIT 을, d4 Setup 이 systemctl cat 대 show 와 Type=forking 을, 이 Reference 가 마스터·워커 PID 와 cgroup.procs 를 들고 있다. 남은 slice 와 PrivateTmp 는 절 하나 분량이라 그 Reference 의 배경 절로 들어간다"
},
{
"id": "SSOT-concepts-l1-pid1-ignores-sigkill-from-its-own-namespace",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-1층-리눅스와-systemd"
],
"summary": "A-3 에서 컨테이너 안으로 보낸 kill -9 1 이 아무 일도 하지 않은 것은 PID 1 이 자기 네임스페이스에서 온 SIGKILL 을 무시하기 때문이다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "아홉 건의 조용한 실패 중 하나이고 그 Case 의 표에 SSOT-a3-two-clean-zero-loss-results-that-were-not-kills 로 이미 들어가 있다. 이 설명은 그 행의 원인 칸이다"
},
{
"id": "SSOT-concepts-l1-journald-already-held-the-b7-cause",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-1층-리눅스와-systemd"
],
"summary": "B-7 이 계층을 나눠 좁힌 502 의 원인을 호스트 nginx 가 저널에 upstream sent too big header 로 적어 두었고, 증거 파일 147개 중 그 줄을 담은 것은 없다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "reference:verify-the-injection-landed-separately-from-the-result",
"reason": "관측 지점을 늘리라는 규칙에 빠져 있던 네 번째 자리다 — 외부·지표·데이터베이스는 적었는데 호스트 계층 저널이 없었다. 그 Reference 가 이미 세 지점을 적고 있어 행 하나로 들어간다"
},
{
"id": "SSOT-concepts-l2-why-a-correct-rule-never-sees-the-packet",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-2층-네트워크"
],
"summary": "conntrack 이 ESTABLISHED 를 먼저 판정하고, netfilter 는 raw 를 conntrack 앞에 돌리며, kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼우고, flannel VXLAN 이 파드 IP 를 캡슐 안에 감춘다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:nine-injections-that-silently-did-nothing",
"reason": "아홉 실패 중 넷의 원인이고, 그 Case 본문이 conntrack 표 원문과 nf_conntrack_tcp_timeout_established 86400 과 패킷 카운터 0 과 flannel.1 을 이미 같은 순서로 설명한다. raw PREROUTING 으로 옮긴 절차는 a5 Setup 이 담는다. 독립 Concept 으로 올리면 같은 문장이 두 곳에 생긴다"
},
{
"id": "SSOT-concepts-l3-commit-returns-before-the-flush",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-3층-postgresql"
],
"summary": "WAL 을 먼저 쓰고, synchronous_commit 이 그 flush 를 기다릴지 정하며, wal_writer_delay 가 잃을 수 있는 창의 크기를 정한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:four-logins-that-returned-200-and-vanished",
"reason": "그 Case 가 SET LOCAL synchronous_commit TO OFF 포획과 wal_writer_delay 200ms 실측과 not properly shut down·redo starts 재생 로그를 이미 본문에 담고 있다. 설명을 떼어 내면 200 을 받은 네 건이 어디로 사라졌는지 읽는 순서가 끊긴다"
},
{
"id": "SSOT-concepts-l3-write-is-not-durable-without-fsync",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-3층-postgresql"
],
"summary": "write() 는 페이지 캐시까지이고 디스크에 닿게 하려면 fsync 가 따로 필요한데, B-5 는 그 한 단계 밖에서 /data 가 컨테이너 파일시스템이라 함께 사라졌다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:persistence-without-a-volume-and-rotation-without-overlap",
"reason": "그 Case 의 결론이 「볼륨 없는 영속화」이고 이 설명이 그 결론의 전제다. 한 절로 들어간다"
},
{
"id": "SSOT-concepts-l3-liquibase-checksum-blocks-a-downgrade",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-3층-postgresql"
],
"summary": "Liquibase 가 changeset 체크섬을 databasechangelog 에 남기고 기동할 때 파일과 대조하므로, 새 버전이 남긴 행을 옛 버전이 읽으면 멈춘다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-upgrade-that-would-not-roll-back",
"reason": "그 Case 가 ValidationFailedException 원문과 「행 수가 안 바뀌면 롤백된다」는 판정 기준을 이미 담고 있다"
},
{
"id": "SSOT-concepts-l3-optimistic-lock-never-fired-because-login-inserts",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-3층-postgresql"
],
"summary": "A-6 이 예측한 낙관적 락 충돌이 0건이었던 것은 로그인이 세션 행을 INSERT 하지 UPDATE 하지 않아 경합할 대상이 없었기 때문이다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:200ms-of-delay-became-22-seconds",
"reason": "그 Case 가 「예측 두 개 가운데 하나는 틀렸다 · 낙관적 락 충돌 0건 · 로그인은 INSERT 라 경합할 대상이 없다」를 이미 본문에 담고 있다"
},
{
"id": "SSOT-concepts-l4-two-timers-before-a-pod-moves",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-4층-쿠버네티스"
],
"summary": "node-monitor-grace-period 와 tolerationSeconds 를 더한 340초는 계산이고 실제 축출은 240~270초였으며, 죽은 노드의 파드는 보고할 주체가 없어 Running 으로 남고 StatefulSet 은 이름을 못 비워 대체를 만들지 않는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:two-ways-to-lose-a-node",
"reason": "셋 다 A-4 의 관측이고 case:two-ways-to-lose-a-node 와 concept:readiness-hides-the-broken-node 와 case:ghost-rows-are-cleaned-by-the-surviving-coordinator 가 나눠 담고 있다. 340초가 계산이라는 단서도 그 Case 에 이미 붙어 있다"
},
{
"id": "SSOT-concepts-l4-allowlist-policy-and-injected-service-env",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-4층-쿠버네티스"
],
"summary": "NetworkPolicy 는 허용 목록이라 7800 은 빼는 방식으로만 막히고 9000 을 빠뜨리면 분단이 아니라 죽은 파드를 재게 되며, enableServiceLinks 가 REDIS_PORT 를 tcp:// URL 로 덮어쓴다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "setup:reproduce-a1-jgroups-transport-block",
"reason": "앞엣것은 a1 Setup 의 절차 그 자체이고, 뒤엣것은 이미 SSOT-b1-service-links-injects-redis-port 로 b1 Setup 에 들어가 있다"
},
{
"id": "SSOT-concepts-l5-rotation-invalidates-the-whole-sso-session",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-5층-keycloak"
],
"summary": "revokeRefreshToken 이 켜진 상태에서 재사용이 감지되면 그 토큰만이 아니라 SSO 세션 전체가 무효화되고, 세션은 SSO·클라이언트·애플리케이션 세 겹이다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-winner-of-the-rotation-race-also-loses",
"reason": "그 Case 의 결론이 이 규격 그대로라 「이긴 요청의 토큰도 못 쓴다」가 설명된다. 세 겹 구분은 이미 SSOT-c1-user-session-and-client-session-are-two-layers 로 c1 Setup 에 들어가 있다"
},
{
"id": "SSOT-concepts-l5-discovery-transport-scope-and-backchannel",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-5층-keycloak"
],
"summary": "디스커버리는 JGROUPS_PING 이고 트랜스포트는 TCP 7800 이며, refresh 만 CLIENT_SCOPE_CLIENT 를 조회하고, 백채널 로그아웃은 IdP 와 앱 양쪽이 있어야 한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:session-sharing-is-the-database-not-replication",
"reason": "셋이 각각 그 Case 와 case:cache-temperature-decides-the-outcome 과 case:nobody-implemented-backchannel-logout 의 본문에 이미 있다. 한 Concept 으로 묶으면 세 Case 가 같은 설명을 밖에서 다시 읽어야 한다"
},
{
"id": "SSOT-concepts-l6-lookup-keys-primary-key-and-the-pool",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-6층-spring"
],
"summary": "세션은 세션 id 로 인가된 클라이언트는 principal 이름으로 찾고, JDBC 스키마의 기본키에 세션 id 가 없으며, Redis 안의 값은 Java 네이티브 직렬화이고, agroal 이 지연을 한 번 더 곱한다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "concept:two-stores-two-lookup-keys",
"reason": "앞의 셋은 그 Concept 과 case:a-primary-key-without-the-session-id 와 b1 Setup 이 이미 원문으로 담고 있고(\\xac\\xed 까지), agroal 은 case:200ms-of-delay-became-22-seconds 가 22.2초의 두 번째 단계로 담고 있다"
},
{
"id": "SSOT-concepts-l7-hook-directory-backdate-and-sct",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-7층-tls"
],
"summary": "pre·deploy·post 의 실행 조건이 다르고, Let's Encrypt 는 notBefore 를 정확히 한 시간 백데이트하며, SCT 는 CT 로그의 시계로 찍혀 제3의 기준이 된다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "decision:put-the-reload-in-a-deploy-hook",
"reason": "셋이 그 Decision 과 SSOT-d4a-notbefore-is-not-the-issue-time 과 SSOT-d4-crt-sh-indexes-a-subset-of-the-truth 로 이미 들어가 있다"
},
{
"id": "SSOT-concepts-l7-fullchain-jwks-and-the-proxy-ticket",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-7층-tls"
],
"summary": "fullchain.pem 이 중간 인증서까지 담고, 모르는 kid 를 만난 NimbusJwtDecoder 는 캐시 만료를 안 기다리며, oauth2-proxy 의 티켓은 세션 ID 와 복호화 키를 함께 담아 secret 을 바꾸면 지울 대상을 잃는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:orphan-sessions-and-the-ttl-that-finds-them",
"reason": "티켓 구조가 그 Case 의 전제이고, kid 는 case:persistence-without-a-volume-and-rotation-without-overlap 이, fullchain.pem 은 case:the-certificate-that-took-38-minutes-to-reach-the-wire 가 이미 담고 있다"
},
{
"id": "SSOT-concepts-l8-clock-skew-up-and-the-scrape-list",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-8층-측정"
],
"summary": "test-server 는 NTP 를 안 쓰고 106초 빠르며, up 은 스크레이프 성공만 말해 살아 있지만 쓸모없는 상태를 못 보고, Prometheus 가 긁는 대상에 Redis·BFF·PostgreSQL 이 없다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "reference:never-subtract-values-from-two-clocks",
"reason": "셋이 그 Reference 와 concept:the-up-metric-cannot-see-alive-but-useless 와 SSOT-b5-no-metrics-for-the-b-layer 로 이미 들어가 있다"
},
{
"id": "SSOT-concepts-what-this-survey-stands-on",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-이-조사가-선-근거"
],
"summary": "여기 적은 값은 대부분 시스템에서 직접 읽었고, 직접 읽을 수 없는 둘에는 출처를 달았으며, 0층의 아래쪽은 문서에서 옮긴 것이라는 근거 표시",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"reason": "조사 범위와 근거 표시 규약이다. 분석에는 반드시 남아야 하고 독립 기록으로 읽을 사람은 없다 — A층·B층 가이드의 (observed)·(unknown) 규약을 KEEP_IN_SSOT 로 둔 것과 같은 자리다"
},
{
"id": "SSOT-concepts-restart-on-failure-was-read-not-measured",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#실험대가-쓴-개념-조사한-것-이-조사가-선-근거"
],
"summary": "진입점 nginx 의 Restart=on-failure 는 유닛 파일을 읽어 적었을 뿐, 마스터를 죽여 100ms 안에 살아나는지와 그동안 외부 요청이 몇 건 떨어지는지를 재지 않았다",
"disposition": "NEEDS_EVIDENCE",
"dispositionReview": "CONFIRMED",
"reason": "decision:put-the-reload-in-a-deploy-hook 이 restart 대신 reload 를 고른 근거로 이 설정과 StartLimitBurst=5 를 쓰고 있어 판정이 걸려 있다. 그런데 이 실험대가 스물여섯 번 배운 것이 「설정이 그렇다고 그렇게 동작하지는 않는다」이고 SSOT 도 재지 않았다고 스스로 적는다. SSOT 가 다음 측정을 이미 적어 두었으므로 재고 나서 다시 판정한다"
},
{
"id": "SSOT-four-open-questions-carried-over",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#코드보다-먼저-드러난-문제-답할-수-없던-질문-네-개"
],
"summary": "앞선 프로젝트가 남긴 열린 질문 넷(Q1~Q4)과 게시일, 그리고 넷이 함께 서 있는 전제 — 인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모른다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "이 실험대가 왜 섰는지를 적는 틀이다. 넷은 이미 답이 나와 「열린 질문 네 개에 대한 답」 표가 담고, 그 표를 reference:look-at-the-lookup-key-before-moving-the-store · reference:clear-the-header-before-you-trust-it · decision:split-the-two-stores-and-design-each 셋이 가리킨다. 답이 있는 질문은 Open Question 의 「답이 아직 없고 다음 검증과 종료 기준이 있다」를 못 채운다. 질문 목록과 게시일은 SSOT 에 남긴다"
},
{
"id": "SSOT-q2-reproduces-only-after-the-store-is-shared",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#코드보다-먼저-드러난-문제-답할-수-없던-질문-네-개"
],
"summary": "Q2 의 경쟁은 저장소를 공유한 뒤에야 재현되므로 로드맵의 Q2 → Q3 순서를 Q3 → Q2 로 뒤집었고, 그것이 B-1·B-2 가 B-3 보다 앞에 오는 이유다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:the-winner-of-the-rotation-race-also-loses",
"reason": "그 Case 가 성립하기 위한 전제다 — 저장소가 process-local 이면 두 replica 가 같은 refresh token 항목을 보지 않아 경쟁할 대상이 없다. Case 의 「문제」 절이 이미 「인스턴스가 둘이고 요청이 어느 쪽으로 갈지 모르는 구성」을 적고 있어 그 옆 한 문단으로 들어간다. 따로 세우면 실험 순서만 말하는 기록이 된다"
},
{
"id": "SSOT-checking-the-roadmap-against-the-published-questions",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#코드보다-먼저-드러난-문제-답할-수-없던-질문-네-개"
],
"summary": "로드맵을 밖에 게시된 질문 넷과 대조하자 Q4 가 통째로 빠져 있었고(제외가 아니라 누락) 저장소 질문도 「Redis 로 간다」에서 「Redis 와 JDBC 중 무엇인가」로 넓어져 B-1·B-2 가 나뉘었다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "「계획을 게시한 질문과 대조한다」는 규칙처럼 읽히지만 SSOT 가 적용 범위도 예외도 대지 않고, 이 실험대에서 한 번 일어난 계획 수정이다. 흡수할 자리도 없다 — B-4 의 발견은 case:nginx-does-not-overwrite-a-header-it-never-sets 가, 저장소를 둘로 나눈 판단은 decision:split-the-two-stores-and-design-each 가 담는데 여기 적힌 것은 결과가 아니라 계획 쪽이다. 실험 번호가 왜 그렇게 붙었는지의 근거로 SSOT 에 남긴다"
},
{
"id": "SSOT-a-layer-prediction-table-keeps-the-old-roadmap-numbers",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#선택의-이유와-지킨-경계-a층-keycloak-자체가-깨질-때"
],
"summary": "A층 예측표는 A-0 의 「9. 다음 실험에 대한 예측」을 그대로 옮긴 것이라 실험 번호가 옛 로드맵의 것이고(옛 `B-5` 가 최종 B-3, 옛 `A-3 노드 상실` 이 최종 A-4, 옛 `A-4 volatile 비교` 가 최종 A-7) 고쳐 적지 않았다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "인용 표시 규약이다 — 예측을 언제 썼는지가 번호에 남아 있어 고치지 않는다는 것. 같은 표에서 예측 칸이 빈 두 행을 「틀린 예측 다섯」에 넣지 않은 것도 같은 계수 규약이다. A층·B층 가이드의 (observed)·(unknown) 규약을 KEEP_IN_SSOT 로 둔 것과 같은 자리이고, 번호 대응표를 읽을 사람은 이 문서를 읽는 사람뿐이다. 예측을 먼저 적고 대조한다는 실천 자체는 reference:verify-the-injection-landed-separately-from-the-result 가 담는다"
},
{
"id": "SSOT-layer-headings-for-b-and-d",
"kindCandidate": "CONCEPT",
"sourceRefs": [
"final/document.md#선택이-코드와-흐름에-반영되는-방식-b층-열린-질문-네-개에-대한-답",
"final/document.md#선택이-코드와-흐름에-반영되는-방식-d층-운영"
],
"summary": "B층·D층의 층 머리말 — B층은 BFF(Spring Boot) 두 인스턴스와 Redis, oauth2-proxy 두 replica 를 올리고 잰다는 두 문장이고, D층은 제목 아래에 본문이 한 줄도 없다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "아래 h4 로 가는 이정표다. B층 두 문장이 적는 구성은 「실험대」 절과 B층 Setup 여섯이 이미 명령과 매니페스트로 담고, D층은 제목 바로 다음이 D-1 이라 뗄 본문이 없다. 두 층의 발견은 각각 Case 와 Setup 으로 이미 올라가 있다"
},
{
"id": "SSOT-where-the-measurement-goes-wrong-outlasts-the-result-tables",
"kindCandidate": "REFERENCE",
"sourceRefs": [
"final/document.md#결정이-지켜지는지-확인하는-방법-측정이-거짓말할-때"
],
"summary": "이 실험대가 남긴 것 중 결과표보다 오래 갈 것은 어디서 측정이 틀리는가다 — 아래 네 갈래를 묶는 머리말 한 문장",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "주제 when-the-measurement-lies 의 독자 질문이 이미 이 문장이 말하는 것이고, 아래 네 갈래 중 셋은 case:seventy-six-failures-that-were-not-the-servers · reference:never-subtract-values-from-two-clocks · concept:the-up-metric-cannot-see-alive-but-useless 로 올라가 있다. 머리말을 따로 기록으로 만들면 주제 설명이 기록 한 편으로 중복된다"
}
],
"unlisted": [],
"history": {},
"counts": {
"topics": 6,
"nodes": 61,
"written": 61,
"unwritten": 0,
"unlisted": 0,
"candidates": 152
},
"unassignedSsotAssets": {
"d3-secret-exposure": "D-3 의 base64 관측은 KEEP_IN_SSOT 로 두어 글감이 없다. 그림도 함께 남긴다",
"wrong-predictions": "틀린 예측 다섯을 한 자리에 모은 그림이라 특정 Case 에 붙지 않는다. 주제 전체의 그림이다",
"open-questions-answered": "앞 프로젝트가 남긴 열린 질문 넷에 답한 것을 모은 그림이라 이 프로젝트의 어느 한 글감에 붙지 않는다",
"not-applicable-conditions": "적용되지 않는 조건을 모은 그림이고 그것을 담는 것은 Reference 다. Reference 에는 본문이 없어 그림을 렌더링할 자리가 없다",
"guest-as-host-process": "「실험대가 쓴 개념」 0층의 그림이다. 그 층에서 나온 후보 셋이 MERGE_INTO·KEEP_IN_SSOT 라 붙을 글감이 없다 — virsh destroy 가 ACPI 없이 프로세스를 끊는 사실은 case:two-ways-to-lose-a-node 의 한 절로 들어가고, 그 Case 는 노드를 잃는 두 경로를 자기 그림으로 이미 그리고 있다",
"cpu-io-passthrough-paths": "같은 0층의 그림인데 그리는 것이 CPU·virtio·패스스루 세 갈래라 이 실험대가 잰 것이 아니다. SSOT 스스로 문서에서 옮긴 것이라고 적었고 후보도 KEEP_IN_SSOT 다. 같은 구조를 정의로 담는 것은 virtualization 프로젝트이고 이 프로젝트에는 붙을 글감이 없다"
}
}