From a0ca2bb72ab6b63621e1ecc6622b285a42c341b5 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 7 Sep 2026 13:30:12 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EA=B3=84=EC=95=BD=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8D=98=20=EB=91=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EC=97=90=20=EC=A3=BC?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EA=B0=88=EB=9D=BC=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EC=84=B8=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keycloak-session-store 와 TechLog 는 SSOT 만 있고 분해 계약이 없었다. 두 프로젝트 모두 후보를 처분과 함께 남기고 PROMOTE 만 주제로 올린다. keycloak-session-store 주제 6 · 글감 33 · 후보 42 (제외 9) TechLog 주제 13 · 글감 56 · 후보 91 (제외 35) TechLog 는 저장소가 셋이라 sourceRepository 를 목록으로 적는다. 설계 패키지는 이 기계에 체크아웃이 없고 반입한 쪽의 MANIFEST.sha256 이 세 계약의 원본 리비전을 고정하므로 그것을 리비전으로 적었다. 검사기 둘이 그 모양을 읽게 고친다 — verify-tech-log-tree.py 는 목록의 항목마다 path·revision·verified 를 보고, check_evidence.mjs 는 체크아웃이면 git 으로, 매니페스트면 그 파일이 리비전을 적고 있는지로 대조한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/check_evidence.mjs | 27 +- .../tech-log-studio/tech-log-tree.json | 2595 +++++++++++++++++ .../tech-log-studio/tech-log-tree.json | 1413 ++++++++- scripts/verify-tech-log-tree.py | 27 +- 4 files changed, 4028 insertions(+), 34 deletions(-) create mode 100644 docs/TechLog/tech-log-studio/tech-log-tree.json diff --git a/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs b/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs index fd4b5f2..bc5d92f 100755 --- a/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs +++ b/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs @@ -107,17 +107,28 @@ for (const topicDir of readdirSync(studio)) { // 4. (--repo) 저장소가 실재하고 리비전이 맞는가 if (withRepo) { - const repo = tree.sourceRepository || {}; - if (!repo.path) findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", ""]); - else if (!existsSync(repo.path)) findings.push(["tech-log-tree.json", "저장소 경로가 없다", repo.path]); - else { + // 저장소가 여럿인 프로젝트는 목록으로 적는다 + const declared = tree.sourceRepository || {}; + const repos = Array.isArray(declared) ? declared : [declared]; + for (const repo of repos) { + const name = repo.name || project; + if (!repo.path) { findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", name]); continue; } + if (!existsSync(repo.path)) { findings.push(["tech-log-tree.json", "저장소 경로가 없다", `${name} — ${repo.path}`]); continue; } // 갈래가 여럿이면 revisions 로 적는다. 둘 다 없으면 verify-tech-log-tree.py 가 warn 을 낸다 const revs = repo.revision ? { revision: repo.revision } : (repo.revisions || {}); + // 체크아웃이 없는 저장소는 반입한 쪽의 매니페스트가 리비전을 고정한다. 그럴 때는 + // path 가 그 파일이고, git 대신 그 파일이 리비전을 적고 있는지 본다 + const isCheckout = statSync(repo.path).isDirectory(); + const manifest = isCheckout ? "" : readFileSync(repo.path, "utf8"); for (const [label, rev] of Object.entries(revs)) { - try { - execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" }); - } catch { - findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${label} = ${rev}`]); + if (isCheckout) { + try { + execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" }); + } catch { + findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${name} · ${label} = ${rev}`]); + } + } else if (!manifest.includes(rev)) { + findings.push(["tech-log-tree.json", "매니페스트가 그 리비전을 적고 있지 않다", `${name} · ${label} = ${rev}`]); } } } diff --git a/docs/TechLog/tech-log-studio/tech-log-tree.json b/docs/TechLog/tech-log-studio/tech-log-tree.json new file mode 100644 index 0000000..7142d14 --- /dev/null +++ b/docs/TechLog/tech-log-studio/tech-log-tree.json @@ -0,0 +1,2595 @@ +{ + "schemaVersion": 4, + "project": "TechLog", + "ssot": "final/document.md", + "sourceRepository": [ + { + "name": "tech-log-frontend", + "path": "/home/donghyeon/workspace/desktop-server-git/tech-log-frontend", + "revision": "3374f286be99b2e25ef3a920c2f283b12bf16bb1", + "verified": "main 의 HEAD 이고, 이 문서가 인용한 프론트엔드 커밋 108개가 전부 이 커밋의 조상이다 (2026-09-07 확인)" + }, + { + "name": "tech-log-backend", + "path": "/home/donghyeon/workspace/desktop-server-git/tech-log-backend", + "revision": "cc2a4887948669a41867ba8d0c146c1925f58434", + "verified": "main 의 HEAD 이고, 이 문서가 인용한 백엔드 커밋 48개가 전부 이 커밋의 조상이다 (2026-09-07 확인)" + }, + { + "name": "tech-log-design-package", + "path": "/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/openapi/MANIFEST.sha256", + "revisions": { + "studio-v1.yaml": "ca1bbfe", + "public-v1.yaml": "1aae8dc", + "studio-management-v1.yaml": "ffa088b" + }, + "verified": "설계 패키지 저장소는 이 기계에 없다. 반입된 계약의 MANIFEST.sha256 이 세 파일의 원본 리비전과 해시를 고정하고 있어 그것을 리비전으로 적는다 (2026-09-07 확인)" + } + ], + "sourceRevision": "tech-log@2026-09-02", + "generatedAt": "2026-09-07", + "candidateScope": { + "document": "final/document.md", + "sections": [ + "§1", + "§3", + "§4", + "§5", + "§6", + "§7", + "§8", + "§9", + "§10", + "§11", + "§12", + "§13", + "§14", + "§15", + "§16", + "§17" + ], + "excluded": [ + "§2 결함을 어떻게 갈랐나", + "부록 A 커밋 색인" + ], + "excludedAnchorPattern": "#§(?:2|A)(?=[.\\-]|$)", + "note": "처음부터 한 편으로 쓴 글이라 제2부가 없다. §2 는 갈래를 가른 방법과 건수를 세는 기준이고 부록 A 는 커밋 색인이라 후보 자리가 아니다. §1 과 §15 는 후보 범위 안에 두었다 — §1.2 의 경계 열한 개와 §15 머리말의 가드 확인 절차가 각각 독립 기록이 된다" + }, + "note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸은 사람이 적고 file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py TechLog", + "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": { + "hand-listed-kinds": { + "title": "손으로 나열한 종류 목록", + "readerQuestion": "문서 종류를 하나 더했을 때, 왜 오류가 나지 않고 잘못된 값이 나가는가?", + "kinds": { + "case": [ + { + "title": "개념을 하나 더하자 열세 자리가 그것을 조용히 삼켰다", + "slug": "one-new-kind-fell-through-thirteen-places", + "readiness": "READY", + "source": [ + "final/document.md#§3.1", + "final/document.md#§3.2", + "final/document.md#§3.4", + "final/document.md#§10.3" + ], + "code": [ + "Record", + "PublicSql.pathOf", + "LATEST_ENTRY_TYPES" + ], + "evidence": [ + "evidence/raw/guards/kind-tables-now.txt" + ], + "classification": "증상 열셋이 전부 같은 원인 하나로 모이고, 표와 sealed switch 식으로 바꾼 뒤 계약 대조 가드가 실제로 잡는 것까지 확인해 닫힌다", + "missing-verification": "열세 건 중 지금 코드에서 되돌려 확인한 것은 계약 대조 가드 셋뿐이다. 나머지 열 건은 커밋 메시지와 현재 상태로만 확인했고 옛 증상은 재현하지 않았다", + "relations": [ + "concept:exhaustive-switch-and-record-tables", + "reference:enumerate-kinds-where-the-compiler-sees-it", + "question:two-kind-tables-outside-the-compiler", + "case:an-operation-you-can-see-but-cannot-call" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식", + "slug": "exhaustive-switch-and-record-tables", + "readiness": "READY", + "source": [ + "final/document.md#§3.1", + "final/document.md#§3.3" + ], + "code": [ + "Record", + "sealed", + "switch" + ], + "basis-version": "TypeScript 5.x 의 Record 키 전수 요구 · Java 21 sealed interface 와 switch 식 · 이 저장소의 RecordKind 다섯 값", + "classification": "같은 언어 안에서도 문으로 쓴 switch 는 아무것도 요구하지 않고 식으로 쓴 switch 는 요구한다는 갈림길이라, 이것을 먼저 알아야 §3 의 고친 방법이 왜 통하는지 읽힌다", + "relations": [ + "case:one-new-kind-fell-through-thirteen-places", + "reference:enumerate-kinds-where-the-compiler-sees-it", + "concept:where-typescript-stops-checking" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [ + { + "title": "종류를 나열하는 자리는 컴파일러나 계약 대조 검사가 세게 만든다", + "slug": "enumerate-kinds-where-the-compiler-sees-it", + "readiness": "READY", + "source": [ + "final/document.md#§3.5", + "final/document.md#§3.4", + "final/document.md#§17.2" + ], + "classification": "열세 건이 같은 규칙 하나로 닫히고, 그 규칙이 종류가 아닌 다른 유한 집합 — 라우트, 오류 코드 — 에도 이 저장소에서 그대로 적용됐다", + "scope": "값이 유한한 집합인 것을 코드나 계약 여러 곳에서 분기하거나 열거하는 자리", + "exceptions": "그 칸이 집합 밖의 값도 담으면 표로 좁힐 수 없다. 공개 투영의 resource_type 이 그런 자리이고, 그때는 계약을 읽어 대조하는 검사를 대신 둔다", + "relations": [ + "case:one-new-kind-fell-through-thirteen-places", + "concept:exhaustive-switch-and-record-tables", + "reference:compare-the-contract-with-both-implementations" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [ + { + "title": "종류를 세는 자리 둘이 아직 컴파일러의 보호 밖에 있다", + "slug": "two-kind-tables-outside-the-compiler", + "readiness": "OPEN", + "source": [ + "final/document.md#§16.7" + ], + "evidence": [ + "evidence/raw/guards/kind-tables-now.txt" + ], + "known": "PublicSql.pathOf 는 RecordKind 가 아니라 공개 투영의 resource_type(String)으로 switch 하고 default -> null 이 남아 있다. validate-working-copy.ts 의 stringFields 는 아직 삼항 사슬이다. CONCEPT 이 실제로 pathOf 에서 빠져 있었다", + "unknown": "pathOf 가 읽는 칸은 RecordKind 에 더해 PROJECT 와 RELEASE 도 담는다. 그 둘을 어디로 옮기면 나머지를 sealed 로 좁힐 수 있는지", + "next-verification": "pathOf 를 RecordKind switch 와 PROJECT/RELEASE 분기로 갈라 보고, PublicPathsTest 가 닿지 않는 경로 — 홈 focus 의 recentDecision — 까지 덮이는지 본다", + "decision-criterion": "새 종류를 더했을 때 이 두 자리가 컴파일 오류로 먼저 멈추면 닫는다", + "relations": [ + "case:one-new-kind-fell-through-thirteen-places", + "concept:exhaustive-switch-and-record-tables", + "case:an-address-frozen-at-publish-time" + ], + "kind": "question", + "publication": "미작성" + } + ], + "decision": [] + }, + "topic": "hand-listed-kinds" + }, + "declared-but-not-implemented": { + "title": "계약에 선언만 있고 구현이 없다", + "readerQuestion": "계약이 선언한 연산에 구현이 없으면 화면은 무엇으로 보이는가?", + "kinds": { + "case": [ + { + "title": "계약에 선언만 있고 구현이 없어 화면 다섯 곳이 비어 있었다", + "slug": "five-screens-were-quietly-empty", + "readiness": "READY", + "source": [ + "final/document.md#§4.1", + "final/document.md#§4.2", + "final/document.md#§10.4" + ], + "code": [ + "home_focus_config", + "public_resource_projection", + "JdbcCatalogQueryAdapter", + "HomeFocusView.resolve" + ], + "classification": "빈 화면 다섯 곳이 전부 「구현이 없다」 하나로 모이고, 계약과 컨트롤러를 전수 대조하는 가드로 닫힌다", + "missing-verification": "홈 focus 는 세 슬롯이 다 비면 그 영역을 아예 그리지 않아 운영에서 한 번도 나타난 적이 없다. 지금 고쳐져 있는 것은 확인했지만 옛 증상은 재현할 수 없다", + "relations": [ + "case:an-operation-you-can-see-but-cannot-call", + "reference:compare-the-contract-with-both-implementations", + "case:it-said-there-were-no-open-questions" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "타입에는 보이는데 부를 수 없는 연산이 네 번 나왔다", + "slug": "an-operation-you-can-see-but-cannot-call", + "readiness": "READY", + "source": [ + "final/document.md#§4.4" + ], + "code": [ + "tech-log-management-contract-contribution.ts", + "getPublicConcept", + "deleteConceptDraft" + ], + "classification": "네 건이 전부 「계약에서 타입은 생성되는데 기여 목록에 등록하지 않았다」 하나이고, 공개 계약 전수 대조와 관리 계약 부분 대조 두 가드로 닫힌다", + "missing-verification": "관리 계약 쪽 가드는 「한 종류만 빠진 자리」만 본다. 종류가 아니라 연산 전체를 빠뜨리는 경우는 이 가드가 잡지 않는다", + "relations": [ + "case:five-screens-were-quietly-empty", + "reference:compare-the-contract-with-both-implementations", + "case:a-narrow-implementation-satisfied-a-wide-port" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "계약과 구현은 서버와 화면 양쪽에서 전수 대조한다", + "slug": "compare-the-contract-with-both-implementations", + "readiness": "READY", + "source": [ + "final/document.md#§4.3", + "final/document.md#§4.4" + ], + "classification": "한쪽만 대조하면 다른 쪽을 지운 것이 잡히지 않는다는 것을 이 저장소에서 확인했고, 계약을 소유한 저장소가 따로 있는 어떤 구조에도 그대로 적용된다", + "scope": "계약이 한 곳에 있고 두 저장소가 그것을 반입해 각자 구현하는 구조", + "exceptions": "구현하지 않기로 한 연산은 이유와 함께 명시 목록에 넣는다. 「빠뜨린 것」과 구분되지 않으면 대조 결과가 곧 무시된다. 봉투 없이 바이트를 주는 연산도 면제 목록에 적는다", + "relations": [ + "case:five-screens-were-quietly-empty", + "case:an-operation-you-can-see-but-cannot-call", + "reference:enumerate-kinds-where-the-compiler-sees-it" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "declared-but-not-implemented" + }, + "values-lost-between-boundaries": { + "title": "값이 경계에서 사라진다", + "readerQuestion": "DB 에는 작성자가 쓴 값이 그대로 있는데 화면이 비었다면, 그 값은 어느 경계에서 사라졌는가?", + "kinds": { + "case": [ + { + "title": "공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다", + "slug": "a-public-reference-was-entirely-empty", + "readiness": "READY", + "source": [ + "final/document.md#§5.1", + "final/document.md#§6.2" + ], + "code": [ + "purposeSummary", + "scopeSummary", + "reference_detail", + "body_markdown" + ], + "classification": "Studio 에서는 다 보이고 공개 화면만 빈 것을 원인 둘 — 계약에 없는 이름을 읽은 것과 본문이 다른 테이블에 있던 것 — 로 갈랐고, 값이 아니라 이름을 지키는 테스트로 닫힌다", + "missing-verification": "as 단언을 걷어낸 자리를 전수로 세지 않았다. 같은 모양이 남아 있는지는 확인하지 않았다", + "relations": [ + "concept:eleven-boundaries-a-value-crosses", + "reference:a-missing-contract-field-has-a-signature", + "concept:where-typescript-stops-checking" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "관계의 요약이 경계 세 곳을 지나며 사라졌다", + "slug": "a-summary-vanished-at-three-boundaries", + "readiness": "READY", + "source": [ + "final/document.md#§5.2", + "final/document.md#§5.3" + ], + "code": [ + "flattenRelations", + "ResolvedRelation", + "additionalProperties" + ], + "classification": "한 경계를 고치고 「고쳤다」고 판단한 것이 두 번 틀렸고, 세 경계를 다 이은 뒤에야 값이 화면에 닿았다. 그 과정에서 한 칸에 뭉쳐 있던 셋을 갈라 닫는다", + "missing-verification": "라벨·문장·요약 셋으로 가른 뒤 각 경로에서 무엇이 나오는지는 화면으로 확인했고, 세 경로 전부를 자동 검사로 고정하지는 않았다", + "relations": [ + "concept:eleven-boundaries-a-value-crosses", + "reference:verify-at-the-end-of-the-value-journey", + "case:a-list-item-had-to-carry-the-whole-document" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "결정에는 상세 화면이 없어 목록 항목이 문서 전체를 실어야 했다", + "slug": "a-list-item-had-to-carry-the-whole-document", + "readiness": "READY", + "source": [ + "final/document.md#§5.4" + ], + "code": [ + "ProjectDecisionItem", + "statement", + "consequences" + ], + "classification": "어긋난 네 가지가 「상세 endpoint 가 없다」는 구조 하나로 모이고, 목록 항목이 화면이 그리는 칸을 전부 싣게 해서 닫힌다", + "missing-verification": "DB 에 있던 제목·여러 줄 요약·영향 4건이 그대로였다는 것은 조회로 확인했고, 그 시점의 화면 캡처는 남기지 않았다", + "relations": [ + "reference:a-missing-contract-field-has-a-signature", + "case:an-address-frozen-at-publish-time", + "case:a-summary-vanished-at-three-boundaries" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개", + "slug": "eleven-boundaries-a-value-crosses", + "readiness": "READY", + "source": [ + "final/document.md#§1.2", + "final/document.md#§17.1" + ], + "code": [ + "public_resource_projection", + "http-public-content-gateway" + ], + "basis-version": "tech-log-backend · tech-log-frontend 2026-09-02 시점 · OpenAPI 3.1 계약 3종을 반입해 쓰는 구조", + "classification": "이 문서의 결함 절반 이상이 「이 중 한 경계가 값을 버렸다」는 같은 모양이라, 경계의 수와 순서를 모르면 어느 절에서 무엇을 보고 있는지 위치를 잡을 수 없다", + "relations": [ + "case:a-public-reference-was-entirely-empty", + "case:a-summary-vanished-at-three-boundaries", + "reference:verify-at-the-end-of-the-value-journey" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [ + { + "title": "Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다", + "slug": "a-missing-contract-field-has-a-signature", + "readiness": "READY", + "source": [ + "final/document.md#§5.6", + "final/document.md#§5.5" + ], + "classification": "같은 신호가 여덟 건에서 같은 원인을 가리켰고, 두 표면이 각자의 계약으로 같은 데이터를 읽는 구조라면 어디서든 성립한다", + "scope": "같은 데이터를 두 표면이 각자의 계약으로 읽고, 한쪽만 비어 보이는 자리", + "exceptions": "두 표면이 같은 계약을 쓰면 이 신호는 성립하지 않는다. 칸을 더할 때 required 로 올릴지는 따로 판단한다 — 이미 나가 있는 응답을 깨지 않으려면 required 가 아니어야 한다", + "relations": [ + "case:a-public-reference-was-entirely-empty", + "case:a-list-item-had-to-carry-the-whole-document", + "reference:verify-at-the-end-of-the-value-journey" + ], + "kind": "reference", + "publication": "미작성" + }, + { + "title": "한 경계를 고쳤으면 값의 여정 끝에서 확인한다", + "slug": "verify-at-the-end-of-the-value-journey", + "readiness": "READY", + "source": [ + "final/document.md#§17.1", + "final/document.md#§5.2", + "final/document.md#§6.6" + ], + "classification": "한 경계만 보고 「고쳤다」고 판단해 세 번 틀렸고, 값이 여러 번 갈아타는 구조라면 어디서든 같은 판단 오류가 난다", + "scope": "값이 계약·매퍼·포트를 여러 번 갈아타는 구조에서 「고쳤다」를 판단하는 자리", + "exceptions": "경계가 하나뿐이거나 고친 그 자리가 여정의 끝이면 중간 확인으로 충분하다", + "relations": [ + "concept:eleven-boundaries-a-value-crosses", + "case:a-summary-vanished-at-three-boundaries", + "concept:where-typescript-stops-checking" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "values-lost-between-boundaries" + }, + "what-the-compiler-lets-through": { + "title": "타입 검사가 통과시키는 자리", + "readerQuestion": "「타입 검사가 통과했다」는 왜 반영의 증거가 아닌가?", + "kinds": { + "case": [ + { + "title": "구현이 종류를 좁게 적어도 넓은 포트를 만족했다", + "slug": "a-narrow-implementation-satisfied-a-wide-port", + "readiness": "READY", + "source": [ + "final/document.md#§6.1" + ], + "code": [ + "deleteDocument", + "RecordFilters", + "satisfies" + ], + "classification": "타입 통과를 반영으로 읽은 판단이 왜 틀렸는지를 bivariance 하나로 확정했고, 타입을 하나로 합치고 되돌려 깨지는 것을 확인해 닫는다", + "missing-verification": "배포된 번들에 옛 삼항이 남아 있던 것은 서버 로그의 404 로 확인했다. 그 로그 원문은 이 저장소에 없다", + "relations": [ + "concept:where-typescript-stops-checking", + "case:an-operation-you-can-see-but-cannot-call", + "reference:verify-at-the-end-of-the-value-journey" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "`npx tsc --noEmit` 이 한 파일도 검사하지 않고 성공했다", + "slug": "the-typecheck-command-checked-no-files", + "readiness": "READY", + "source": [ + "final/document.md#§6.4" + ], + "code": [ + "\"files\": []", + "npm run check:types" + ], + "classification": "운영에서 난 ReferenceError 의 원인이 코드가 아니라 검사 명령이었음을 확정했고, 올바른 명령으로 돌려 저장소에 남아 있던 다른 오류까지 드러나며 닫힌다", + "missing-verification": "루트 tsconfig 를 그대로 두었으므로 누군가 다시 그 명령을 쓰는 것을 막는 장치는 없다. 메모리에 남긴 것이 전부다", + "relations": [ + "concept:where-typescript-stops-checking", + "case:the-guard-worked-and-i-did-not-run-it", + "reference:what-a-person-must-run-before-deploying" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · `as` 단언 · `(input: never)` 캐스트 · 검사 대상을 갖지 않은 tsconfig", + "slug": "where-typescript-stops-checking", + "readiness": "READY", + "source": [ + "final/document.md#§6.6", + "final/document.md#§6.2", + "final/document.md#§6.3" + ], + "code": [ + "as string", + "(input: never)", + "project references" + ], + "basis-version": "TypeScript 5.x · project references 로 나눈 여섯 프로젝트 구성 · 2026-09-02 시점의 tech-log-frontend", + "classification": "네 자리가 각각 다른 결함을 통과시켰고, 이 네 가지를 알아야 「타입 통과」를 무엇의 증거로 읽을지 정할 수 있다", + "relations": [ + "case:a-narrow-implementation-satisfied-a-wide-port", + "case:the-typecheck-command-checked-no-files", + "case:a-public-reference-was-entirely-empty" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [], + "question": [], + "decision": [] + }, + "topic": "what-the-compiler-lets-through" + }, + "seams-no-test-crosses": { + "title": "테스트가 지나지 않는 이음매", + "readerQuestion": "모든 검사가 통과했는데 운영에서 깨졌다면, 어느 이음매를 아무 테스트도 지나지 않았는가?", + "kinds": { + "case": [ + { + "title": "파드가 두 번 CrashLoopBackOff 로 들어갔다 — 어떤 테스트도 애플리케이션 컨텍스트를 띄우지 않았다", + "slug": "two-pods-crashlooped-with-no-test-starting-the-context", + "readiness": "READY", + "source": [ + "final/document.md#§7.1", + "final/document.md#§6.5", + "final/document.md#§12.1" + ], + "code": [ + "@Autowired", + "tools.jackson.databind", + "com.fasterxml.jackson.databind.ObjectMapper" + ], + "classification": "원인이 다른 두 건 — 생성자 둘, Jackson 2 import — 이 「컨텍스트를 띄우는 테스트가 없다」 하나로 모이고, ArchUnit 규칙을 결함으로 되돌려 확인해 닫힌다", + "missing-verification": "D20 규칙은 생성자 쪽만 막는다. 클래스패스에 남은 옛 라이브러리 타입을 import 하는 것은 이 규칙이 잡지 않고, 그 자리를 막는 검사는 아직 없다", + "relations": [ + "reference:put-one-check-on-each-seam", + "concept:where-typescript-stops-checking", + "case:the-sql-had-never-been-executed" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "그 SQL 은 한 번도 실행된 적이 없었다", + "slug": "the-sql-had-never-been-executed", + "readiness": "READY", + "source": [ + "final/document.md#§7.2" + ], + "code": [ + "public_resource_projection", + "resource_type", + "resource_id" + ], + "classification": "500 의 원인이 컬럼 이름 하나였고, 진짜 실패는 그 SQL 이 한 번도 실행된 적이 없다는 것이었다. 삭제 경로 전용 통합 테스트 태스크로 닫힌다", + "missing-verification": "여덟 시나리오를 실제 PostgreSQL 에서 돌렸지만, 표준 check 는 여전히 Testcontainers 를 띄우지 않는다. 새 어댑터 SQL 이 그 태스크에 등록되는지를 보는 장치는 없다", + "relations": [ + "reference:put-one-check-on-each-seam", + "case:two-pods-crashlooped-with-no-test-starting-the-context", + "question:the-refusal-does-not-name-what-blocks-it" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "합성 루트에 테스트가 없어 공개 사이트 전체가 오류 화면이었다", + "slug": "the-composition-root-had-no-test", + "readiness": "READY", + "source": [ + "final/document.md#§7.4", + "final/document.md#§7.3" + ], + "code": [ + "attachCredentials", + "envelopeError", + "ApiError" + ], + "classification": "세 결함이 겹쳐 각각 다음 것을 가리고 있었고, 실제 어댑터를 배포된 백엔드의 실제 404 본문에 대고 조립하는 회귀 테스트로 닫힌다", + "missing-verification": "공개 소스가 HTTP 가 된 뒤에야 나타날 수 있던 경로다. 다른 프로파일의 합성 루트에도 같은 구멍이 있는지는 세지 않았다", + "relations": [ + "reference:put-one-check-on-each-seam", + "case:records-disappeared-without-a-trace", + "reference:verify-at-the-end-of-the-value-journey" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "생성기가 계약 필드 넷을 조용히 빠뜨렸다 — 파생 스펙에 남은 YAML alias 34곳", + "slug": "the-generator-dropped-four-contract-fields", + "readiness": "READY", + "source": [ + "final/document.md#§7.6" + ], + "code": [ + "LatestEntry", + "publishedAt", + "validateSpec", + "verifyPublicGeneratedModels" + ], + "classification": "스키마 15개가 거절당한 원인을 prepare 단계의 Map 재사용까지 따라갔고, alias 를 원천 차단하는 fail-closed 게이트와 property 단위 대조로 닫힌다", + "missing-verification": "지금 세는 것은 schema 62개 · property 250개다. 이 수를 갱신하는 것은 사람이라, 계약이 줄어드는 방향의 누락은 이 게이트가 잡지 않는다", + "relations": [ + "reference:put-one-check-on-each-seam", + "reference:compare-the-contract-with-both-implementations", + "case:a-list-item-had-to-carry-the-whole-document" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "이음매마다 그 이음매를 실제로 지나는 검사를 하나씩 둔다", + "slug": "put-one-check-on-each-seam", + "readiness": "READY", + "source": [ + "final/document.md#§7.7" + ], + "classification": "다섯 이음매가 각각 「지나지 않았다」는 같은 이유로 통과했고, 층을 스텁으로 나눠 시험하는 어떤 구조에도 같은 목록이 만들어진다", + "scope": "스텁으로 나뉜 층 사이 — 스프링 컨텍스트, persistence SQL, HTTP 매퍼, 합성 루트, 코드 생성기", + "exceptions": "그 층을 실제로 지나는 검사가 이미 있으면 더 두지 않는다. 스텁을 쓰는 테스트를 늘리는 것은 이 문제를 덮지 않는다", + "relations": [ + "case:two-pods-crashlooped-with-no-test-starting-the-context", + "case:the-sql-had-never-been-executed", + "case:the-composition-root-had-no-test", + "case:the-generator-dropped-four-contract-fields" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "seams-no-test-crosses" + }, + "one-route-many-hand-kept-lists": { + "title": "라우트 하나가 울리는 손 목록", + "readerQuestion": "라우트를 하나 더하면 어디가 함께 울리고, 왜 어떤 것은 배포 뒤에야 우는가?", + "kinds": { + "case": [ + { + "title": "nginx 가 모르는 라우트는 새로고침에서 404 다", + "slug": "a-route-the-web-server-never-heard-of", + "readiness": "READY", + "source": [ + "final/document.md#§8.2", + "final/document.md#§12.6" + ], + "code": [ + "tech-log-serving-contract.json", + "location", + "robots.txt" + ], + "classification": "SPA 안에서는 닿는데 하드 로드만 404 인 이유를 서빙 계약의 손 목록으로 확정했고, 라우트 계약에서 패턴을 유도하게 해서 닫힌다", + "missing-verification": "경로 27개가 얼어 있던 상태의 생성 결과물은 남기지 않았다. 지금 생성되는 패턴이 라우트 계약과 같은지만 테스트가 본다", + "relations": [ + "reference:derive-the-route-lists-from-the-route-contract", + "decision:do-not-translate-the-catch-all-route", + "case:eight-places-a-single-route-touches" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "라우트 하나가 건드리는 여덟 자리와, 그것들이 우는 시점", + "slug": "eight-places-a-single-route-touches", + "readiness": "READY", + "source": [ + "final/document.md#§8.1", + "final/document.md#§8.3", + "final/document.md#§8.4" + ], + "code": [ + "tech-log-route-contract.ts", + "TECH_LOG_STUDIO_TOPIC_EDIT", + "FE-GATE-009" + ], + "classification": "여덟 자리를 목록으로 확정하고 각각이 우는 시점 — 빌드 직전, 배포 직전, 배포 뒤 — 까지 갈랐다. 유도할 수 있는 것은 유도하고 기준값은 재현 절차로 닫는다", + "missing-verification": "게이트 기준값 셋은 여전히 손으로 움직인다. 옛 값을 먼저 재현하는 절차는 사람이 기억해야 하고 검사가 강제하지 않는다", + "relations": [ + "reference:derive-the-route-lists-from-the-route-contract", + "question:nobody-signed-the-accessibility-evidence", + "case:the-guard-worked-and-i-did-not-run-it" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "라우트에 딸린 목록은 라우트 계약에서 유도하고, 유도할 수 없는 것은 대조 검사를 둔다", + "slug": "derive-the-route-lists-from-the-route-contract", + "readiness": "READY", + "source": [ + "final/document.md#§8.3", + "final/document.md#§8.2" + ], + "classification": "같은 목록이 손으로 유지되는 동안 두 번 같은 방식으로 실패했고, 라우트가 여러 목록을 함께 움직이는 어떤 프론트엔드에도 적용된다", + "scope": "라우트 하나가 서빙 패턴·청크 이름·게이트 기준값 같은 목록을 함께 움직이는 구조", + "exceptions": "기준값처럼 유도할 수 없는 상수는 옛 값을 먼저 재현해 계산 방법을 확인한 뒤 갱신한다. 그 절차 없이 새 값을 적으면 계산이 바뀐 것과 파일이 바뀐 것을 구분할 수 없다", + "relations": [ + "case:a-route-the-web-server-never-heard-of", + "case:eight-places-a-single-route-touches", + "reference:enumerate-kinds-where-the-compiler-sees-it" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [ + { + "title": "라우트마다 요구하는 수동 접근성 증거를 아무도 서명하지 않았다", + "slug": "nobody-signed-the-accessibility-evidence", + "readiness": "OPEN", + "source": [ + "final/document.md#§16.5", + "final/document.md#§8.4" + ], + "known": "FE-GATE-009 는 설치된 라우트마다 증거 파일 하나를 요구하고 집합이 정확히 일치하지 않으면 거절한다. artifacts/tests/a11y-manual/*.md 는 전부 pending-manual-review 이고, review:a11y-manual 이 실패하는 것이 지금은 정상이다", + "unknown": "게이트가 파일의 존재만 세고 서명을 보지 않는 것이 의도인지, 서명을 게이트 조건에 넣어 라우트를 더할 때마다 배포가 막히는 것을 감수할지", + "next-verification": "라우트 하나를 실제로 사람이 검토해 서명한 뒤 게이트가 서명 여부까지 보게 고쳐, 나머지 라우트에서 실제로 빨개지는지 확인한다", + "decision-criterion": "서명이 게이트의 통과 조건이 되거나, 서명을 요구하지 않기로 적고 게이트에서 그 파일 요구를 빼면 닫는다", + "relations": [ + "case:eight-places-a-single-route-touches", + "reference:revert-the-defect-and-watch-the-guard-fail", + "case:the-guard-worked-and-i-did-not-run-it" + ], + "kind": "question", + "publication": "미작성" + } + ], + "decision": [ + { + "title": "catch-all 라우트는 nginx 패턴으로 번역하지 않는다", + "slug": "do-not-translate-the-catch-all-route", + "readiness": "READY", + "source": [ + "final/document.md#§8.2" + ], + "decision-status": "ADOPTED", + "decision-evidence": "라우트 계약에서 유도한 정규식이 등록된 Public 라우트만 열고 파라미터는 한 세그먼트만 잡아, /cases/a/b 가 404 로 남는 것을 배포 뒤 감사에서 확인했다 — evidence/raw/audit/dead-link-sweep.txt", + "grounds": "모든 미매치 URL 에 index.html 을 주면 엣지 404 가 soft 200 이 되어 깨진 링크를 크롤러와 우리에게서 숨긴다. 감수한 비용은 라우트를 더할 때마다 서빙 패턴이 함께 움직인다는 것이고, 그 비용은 계약에서 유도해 없앴다", + "classification": "프로젝트가 실제로 이 방향을 골랐고 그 대가를 적었다", + "evidence": [ + "evidence/raw/audit/dead-link-sweep.txt" + ], + "relations": [ + "case:a-route-the-web-server-never-heard-of", + "reference:derive-the-route-lists-from-the-route-contract", + "reference:do-not-draw-a-link-that-does-not-resolve" + ], + "kind": "decision", + "publication": "미작성" + } + ] + }, + "topic": "one-route-many-hand-kept-lists" + }, + "addresses-frozen-at-publish-time": { + "title": "주소가 만들어지고 굳어지는 자리", + "readerQuestion": "공개 주소는 누가 언제 만들고, 그것이 틀리면 어디까지 번지는가?", + "kinds": { + "case": [ + { + "title": "계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다 — 이미 저장된 행까지 고쳤다", + "slug": "an-address-frozen-at-publish-time", + "readiness": "READY", + "source": [ + "final/document.md#§9.2" + ], + "code": [ + "PublicPaths", + "PublicSql", + "public_route" + ], + "evidence": [ + "evidence/raw/db/decision-path-after-v15.txt", + "evidence/raw/api/decision-anchor-fixed.txt", + "evidence/raw/audit/dead-link-sweep.txt" + ], + "classification": "계약이 이미 앵커라고 적어 둔 것을 만드는 쪽 두 곳이 따르지 않았다는 것을 확정했고, 코드와 이미 게시된 행을 함께 고친 뒤 서버가 내보내는 주소 35개를 전수로 확인해 닫힌다", + "missing-verification": "전수 감사는 서버가 내보내는 주소만 본다. 본문 안에 작성자가 손으로 쓴 링크는 이 감사의 대상이 아니다", + "relations": [ + "reference:do-not-draw-a-link-that-does-not-resolve", + "case:a-list-item-had-to-carry-the-whole-document", + "case:a-link-that-pointed-at-itself" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "축 링크가 자기 자신을 가리켰고, 고친 뒤에는 백엔드를 먼저 배포했다", + "slug": "a-link-that-pointed-at-itself", + "readiness": "READY", + "source": [ + "final/document.md#§9.1", + "final/document.md#§9.3" + ], + "code": [ + "record_variant", + "variant" + ], + "classification": "주소를 두 번 옮겨 본 끝에 축에 자기 화면을 주는 것으로 닫혔고, 그 과정에서 배포 순서 때문에 생긴 2차 사고까지 원인이 확정된다", + "missing-verification": "축 slug 가 주제 안에서만 유일하다는 전제를 조회에 반영했지만, 주제를 빼고 조회하는 경로가 남아 있는지는 세지 않았다", + "relations": [ + "decision:a-new-route-ships-frontend-first", + "reference:write-down-what-would-undo-a-workaround", + "concept:topic-variant-and-record-variant" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "slug 생성이 한글을 버려 주제 만들기가 간헐적으로 실패했다", + "slug": "a-slug-rule-that-threw-korean-away", + "readiness": "READY", + "source": [ + "final/document.md#§13.6" + ], + "code": [ + "[a-z0-9]", + "baekendeu-akitekcheo" + ], + "classification": "「간헐적」이 실은 두 가지 결정적 어긋남이었음을 확정했고, 음절을 초성·중성·종성으로 산술 분해해 로마자로 옮기는 것으로 닫힌다", + "missing-verification": "음운 변화 규칙을 일부러 뺐으므로 같은 발음의 다른 이름이 다른 slug 가 된다. 그 충돌 빈도는 재지 않았다", + "relations": [ + "case:an-address-frozen-at-publish-time", + "case:nine-names-for-five-kinds", + "reference:do-not-draw-a-link-that-does-not-resolve" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "서버가 준 주소는 라우트 표에 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다", + "slug": "do-not-draw-a-link-that-does-not-resolve", + "readiness": "READY", + "source": [ + "final/document.md#§9.2" + ], + "classification": "같은 부류가 또 생겨도 방문자가 404 를 만나지는 않게 하는 두 겹 가드로 닫혔고, 주소를 서버가 만들어 내보내는 어떤 구조에도 적용된다", + "scope": "주소를 서버가 만들어 내보내고 화면이 그것을 그대로 링크로 그리는 자리", + "exceptions": "외부 주소는 라우트 표에 없으므로 이 대조의 대상이 아니다. 만드는 쪽에도 같은 검사를 두어야 링크가 아예 안 그려지는 것으로 끝나지 않는다", + "relations": [ + "case:an-address-frozen-at-publish-time", + "decision:do-not-translate-the-catch-all-route", + "reference:derive-the-route-lists-from-the-route-contract" + ], + "kind": "reference", + "publication": "미작성" + }, + { + "title": "우회를 남길 때는 되돌릴 조건을 함께 적는다", + "slug": "write-down-what-would-undo-a-workaround", + "readiness": "READY", + "source": [ + "final/document.md#§9.4" + ], + "classification": "우회 자체는 틀리지 않았고 남겨 두는 것이 문제였다는 것을 뒤집힌 판단 하나로 확정했으며, 조건을 커밋 메시지에 적어 두고 실제로 그 조건에서 되돌리며 닫혔다", + "scope": "그 화면이 줄 수 있는 것이 아직 비어 있어 링크를 다른 자리로 돌리는 임시 조치", + "exceptions": "되돌릴 생각이 없는 변경은 우회가 아니라 결정이다. 그때는 조건이 아니라 근거와 감수한 비용을 적는다", + "relations": [ + "case:a-link-that-pointed-at-itself", + "case:the-comparison-band-changed-three-times", + "decision:a-new-route-ships-frontend-first" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [ + { + "title": "새 라우트는 프론트엔드를 먼저 배포한다", + "slug": "a-new-route-ships-frontend-first", + "readiness": "READY", + "source": [ + "final/document.md#§9.1" + ], + "decision-status": "ADOPTED", + "decision-evidence": "축 화면을 만들고 백엔드를 먼저 배포했더니 서버는 이미 그 주소를 내보내는데 엣지에는 그 경로가 없어, 사용자가 네 링크 전부 404 인 화면을 봤다 — final/document.md#§9.1", + "grounds": "nginx 설정이 라우트 계약에서 생성되므로 프론트가 배포되기 전까지 그 경로는 엣지에서 404 다. 감수한 비용은 새 주소를 내보내는 백엔드 변경이 한 배포 늦게 나간다는 것이다", + "classification": "프로젝트가 이 순서를 실제로 택했고 그 전에 반대 순서로 사고를 냈다", + "relations": [ + "case:a-link-that-pointed-at-itself", + "case:a-route-the-web-server-never-heard-of", + "reference:do-not-draw-a-link-that-does-not-resolve" + ], + "kind": "decision", + "publication": "미작성" + } + ] + }, + "topic": "addresses-frozen-at-publish-time" + }, + "failure-drawn-as-absence": { + "title": "실패를 없음으로 그린다", + "readerQuestion": "화면이 못 읽은 것을 「없다」고 그리면 무엇이 잘못되는가?", + "kinds": { + "case": [ + { + "title": "「이 프로젝트에 열린 질문이 없습니다」 — 실제로는 넷이 있었다", + "slug": "it-said-there-were-no-open-questions", + "readiness": "READY", + "source": [ + "final/document.md#§10.1" + ], + "code": [ + "home focus", + "getHomeFocus" + ], + "classification": "빈 배열로 삼킨 실패가 작성자에게 「아직 안 쓴 글」로 읽혔다는 것을 확정했고, 못 읽었다고 말하게 해서 닫힌다", + "missing-verification": "화면이 못 읽은 것을 없다고 그리는 자리를 전수로 세지 않았다. 고친 것은 홈 편집기와 주제 탭 둘이다", + "relations": [ + "reference:say-you-could-not-read-it", + "case:five-screens-were-quietly-empty", + "case:one-cell-failing-took-its-neighbour-down" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "한 칸의 실패가 옆 칸을 끌고 내려갔다", + "slug": "one-cell-failing-took-its-neighbour-down", + "readiness": "READY", + "source": [ + "final/document.md#§10.2" + ], + "code": [ + "Promise.all" + ], + "classification": "묶어 읽은 것이 원인이라는 것과, 호출이 동기적으로 던지면 rejection handler 를 지나지도 못한다는 더 미묘한 변종까지 갈랐고, 둘을 따로 읽게 해서 닫힌다", + "missing-verification": "같은 모양으로 묶어 읽는 자리를 전수로 세지 않았다. 홈 편집기와 주제 탭 둘에만 적용했다", + "relations": [ + "reference:say-you-could-not-read-it", + "case:it-said-there-were-no-open-questions", + "case:the-comparison-band-changed-three-times" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다", + "slug": "records-disappeared-without-a-trace", + "readiness": "READY", + "source": [ + "final/document.md#§10.6" + ], + "code": [ + "filter", + "CASE", + "REFERENCE" + ], + "classification": "응답 모양이 다른 목록에 다른 목록의 매퍼를 쓴 것이 원인이고, 오류도 빈 자리도 남기지 않고 목록이 한 줄 짧아질 뿐이라는 관측까지 확정된다", + "missing-verification": "null 을 돌려주고 호출부가 거르는 매퍼가 다른 목록에도 남아 있는지는 세지 않았다", + "relations": [ + "reference:say-you-could-not-read-it", + "case:one-new-kind-fell-through-thirteen-places", + "case:the-composition-root-had-no-test" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "화면은 못 읽은 것을 없다고 말하지 않는다", + "slug": "say-you-could-not-read-it", + "readiness": "READY", + "source": [ + "final/document.md#§10.1", + "final/document.md#§17.3" + ], + "classification": "같은 규칙이 목록·탭·편집기 세 자리에서 같은 결함을 막았고, 「비어 있음」이 정상값인 어떤 화면에도 적용된다", + "scope": "목록·요약처럼 「비어 있음」이 정상값이라 실패와 구분되지 않는 자리", + "exceptions": "정말로 0건인 것과 못 읽은 것을 구분할 수 없는 자리라면 그 구분을 먼저 만든다. 구분 없이 문구만 바꾸면 0건이 실패로 읽힌다", + "relations": [ + "case:it-said-there-were-no-open-questions", + "case:one-cell-failing-took-its-neighbour-down", + "case:records-disappeared-without-a-trace" + ], + "kind": "reference", + "publication": "미작성" + }, + { + "title": "매번 우는 검사는 읽히지 않는다 — 기대된 실패는 조건을 적어 빼고 나머지는 전부 실패시킨다", + "slug": "an-expected-failure-must-not-be-counted-as-a-failure", + "readiness": "READY", + "source": [ + "final/document.md#§10.5" + ], + "classification": "같은 빨간 줄이 매번 남아 읽히지 않게 된 것을 확정했고, 기대된 두 응답을 조건으로 빼고 나머지 4xx·5xx 를 전부 실패로 세는 것으로 닫힌다", + "scope": "배포 뒤 전 화면을 훑는 스모크 스윕처럼 결과를 사람이 훑어보는 검사", + "exceptions": "기대된 실패의 조건을 적을 수 없으면 빼지 않는다. 조건 없이 빼면 진짜 실패도 같이 빠진다", + "relations": [ + "reference:say-you-could-not-read-it", + "reference:revert-the-defect-and-watch-the-guard-fail", + "case:an-address-frozen-at-publish-time" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "failure-drawn-as-absence" + }, + "css-rules-that-leak": { + "title": "CSS 규칙이 구역을 넘어 샌다", + "readerQuestion": "「디자인이 안 된 것처럼 보인다」는 보고는 왜 대부분 선택자 문제인가?", + "kinds": { + "case": [ + { + "title": "구역 전체에 건 격자가 제목까지 잡아 h2 높이가 199px 이 됐다", + "slug": "a-section-wide-rule-caught-the-heading", + "readiness": "READY", + "source": [ + "final/document.md#§11.1", + "final/document.md#§11.3", + "final/document.md#§16.3" + ], + "code": [ + ".home-comparison a", + "grid-template-columns", + "li > a" + ], + "classification": "세 건이 전부 「규칙이 샜다」 하나로 모이고, 선택자를 좁히고 구조까지 바꾼 뒤 배치 규칙의 범위를 보는 검사로 닫힌다", + "missing-verification": "CSS module 을 쓰는 화면은 전역 규칙이 닿지 않아 따로 고쳤고, 「지금 집중하는 것」 탭과 주제 탭의 표시 방식이 아직 다르다. 한 화면 안에 두 언어가 남아 있다", + "relations": [ + "reference:scope-a-layout-rule-to-what-uses-it", + "case:the-rule-was-not-missing-it-was-half-there", + "reference:measure-what-is-visible-not-a-proxy" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "규칙이 없었던 게 아니라 절반만 있었다", + "slug": "the-rule-was-not-missing-it-was-half-there", + "readiness": "READY", + "source": [ + "final/document.md#§11.2" + ], + "code": [ + ".section-heading-row h2", + "font-size", + "font-weight" + ], + "classification": "한 구역만 급이 낮아 보인 이유가 크기만 각자 적고 굵기를 아무도 정하지 않은 것임을 확정했고, 나란히 서는 제목들이 정본과 같은 값을 쓰는지 보는 검사로 닫힌다", + "missing-verification": "처음에 격자와 열만 재고 정상이라 답했다가 전체 페이지 스크린샷을 찍고서야 봤다. 지금 검사가 보는 것은 CSS 값이고, 실제로 그려진 크기는 아니다", + "relations": [ + "reference:measure-what-is-visible-not-a-proxy", + "case:a-section-wide-rule-caught-the-heading", + "reference:scope-a-layout-rule-to-what-uses-it" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "배치를 거는 규칙은 그 배치를 쓰는 요소까지 좁혀 적는다", + "slug": "scope-a-layout-rule-to-what-uses-it", + "readiness": "READY", + "source": [ + "final/document.md#§11.1" + ], + "classification": "같은 모양이 다른 구역 아홉 곳에도 있어 선택자 51개를 고쳤고, 구역 클래스 아래에 태그 선택자로 배치를 거는 어떤 CSS 에도 적용된다", + "scope": "구역 클래스 아래에 태그 선택자로 display·grid-template-columns·padding 을 거는 자리", + "exceptions": "색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로 대상이 아니다. 이미 좁혀 둔 자리는 명시해 검사에서 뺀다", + "relations": [ + "case:a-section-wide-rule-caught-the-heading", + "case:the-rule-was-not-missing-it-was-half-there", + "reference:measure-what-is-visible-not-a-proxy" + ], + "kind": "reference", + "publication": "미작성" + }, + { + "title": "프록시 지표가 아니라 보이는 것을 측정한다", + "slug": "measure-what-is-visible-not-a-proxy", + "readiness": "READY", + "source": [ + "final/document.md#§17.5", + "final/document.md#§11.2" + ], + "evidence": [ + "evidence/browser/tab-metrics.txt", + "evidence/browser/home-topic-tabs.png" + ], + "classification": "격자와 열만 재고 정상이라 답한 것이 두 번 틀렸고, 촬영을 스크립트로 고정해 폭마다 측정을 함께 남기는 것으로 닫힌다", + "scope": "화면이 잘못됐다는 보고를 확인하는 자리", + "exceptions": "측정 대상이 좌표나 간격 자체라면 바운딩 박스는 프록시가 아니라 대상이다", + "relations": [ + "case:the-rule-was-not-missing-it-was-half-there", + "case:a-section-wide-rule-caught-the-heading", + "case:the-comparison-band-changed-three-times" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "css-rules-that-leak" + }, + "only-visible-after-deploying": { + "title": "배포해 봐야 드러난 것", + "readerQuestion": "컨테이너가 healthy 인데 사이트가 동작하지 않으면, 무엇이 배포 산출물과 어긋나 있는가?", + "kinds": { + "case": [ + { + "title": "컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다", + "slug": "a-healthy-container-that-served-one-403", + "readiness": "READY", + "source": [ + "final/document.md#§12.4", + "final/document.md#§12.5" + ], + "code": [ + "config.json", + "favicon.svg", + "favicon.ico" + ], + "classification": "healthy 로 올라온 컨테이너가 파일 하나만 못 내주는 원인을 빌드가 쓴 권한으로 확정했고, 이미지가 권한을 정규화하게 해서 닫힌다", + "missing-verification": "이미지 안의 다른 파일 권한을 전수로 확인하지 않았다. 고친 것은 빌드가 쓰는 자리 하나다", + "relations": [ + "concept:deploying-without-a-registry", + "case:a-route-the-web-server-never-heard-of", + "case:a-build-argument-left-out" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다", + "slug": "a-build-argument-left-out", + "readiness": "READY", + "source": [ + "final/document.md#§12.2" + ], + "code": [ + "RUNTIME_API_BASE_URL", + "api.example.com", + "kubectl rollout undo" + ], + "classification": "Dockerfile 이 문자 그대로 그 경고를 적어 두고 있었는데도 빠뜨린 것이고, 되돌리고 다시 빌드해 닫혔다. 인자 목록을 적어 두는 것이 지금의 대책이다", + "missing-verification": "빌드가 이 인자를 요구하도록 막지 않았다. 빠뜨리면 여전히 빌드는 성공하고 배포본만 틀린다", + "relations": [ + "concept:deploying-without-a-registry", + "reference:what-a-person-must-run-before-deploying", + "case:a-healthy-container-that-served-one-403" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "레지스트리 없이 tar 를 import 하는 배포 경로", + "slug": "deploying-without-a-registry", + "readiness": "READY", + "source": [ + "final/document.md#§1.3", + "final/document.md#§12.2" + ], + "code": [ + "docker save", + "containerd", + "kubectl set image" + ], + "basis-version": "k3s + containerd · hyeonworks.com 단일 배포 단위 · 2026-09 시점", + "classification": "이미지가 어떤 경로로 클러스터에 들어가고 무엇이 빌드 인자로만 들어가는지를 알아야, §12 의 두 건이 왜 배포에서만 드러나는지 읽힌다", + "relations": [ + "case:a-build-argument-left-out", + "case:a-healthy-container-that-served-one-403", + "reference:what-a-person-must-run-before-deploying" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [], + "question": [], + "decision": [] + }, + "topic": "only-visible-after-deploying" + }, + "one-thing-many-names": { + "title": "같은 것이 화면마다 다른 이름", + "readerQuestion": "같은 것을 화면마다 손으로 적으면 무엇이 갈라지고, 그 말은 누가 정하는가?", + "kinds": { + "case": [ + { + "title": "한 화면에 종류 이름이 아홉 개 떠 있었다 — 표가 여섯 벌이었다", + "slug": "nine-names-for-five-kinds", + "readiness": "READY", + "source": [ + "final/document.md#§13.1", + "final/document.md#§13.5" + ], + "code": [ + "Record" + ], + "classification": "같은 QUESTION 이 화면마다 다른 이름으로 나온 원인을 여섯 벌로 복사된 표로 확정했고, 표 하나로 모으고 편집기 칸 이름까지 공개 화면과 맞춰 닫는다", + "missing-verification": "이름을 바꾸기 전보다 나빠진 자리가 홈 하나였다는 것은 화면으로 확인했고, 다른 화면에서 두 이름이 같이 뜨는 자리를 전수로 세지는 않았다", + "relations": [ + "case:renaming-the-kinds-twice", + "case:one-new-kind-fell-through-thirteen-places", + "reference:ask-which-words-to-use" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "종류 이름을 두 번 바꿨다 — 화면의 이름과 계약의 kind 를 갈랐다", + "slug": "renaming-the-kinds-twice", + "readiness": "READY", + "source": [ + "final/document.md#§13.2" + ], + "code": [ + "CASE", + "CONCEPT", + "REFERENCE", + "DECISION", + "QUESTION" + ], + "classification": "독자가 담아 둔 방식의 이름을 먼저 배워야 목록을 읽을 수 있었다는 문제에서 출발해, 역할을 말하되 기술 기록의 톤에 맞는 이름으로 두 번 만에 닫혔다. 계약의 kind 는 건드리지 않는다", + "missing-verification": "바꾼 이름이 읽기 쉬워졌는지는 재지 않았다. 첫 번째 안이 가볍다는 판단은 사용자의 지적이고 측정이 아니다", + "relations": [ + "case:nine-names-for-five-kinds", + "reference:ask-which-words-to-use", + "case:a-slug-rule-that-threw-korean-away" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "서버는 하나를 답했는데 화면은 추측 셋을 출력했다", + "slug": "an-error-message-that-guessed", + "readiness": "READY", + "source": [ + "final/document.md#§13.4" + ], + "code": [ + "another record still links to this one" + ], + "classification": "버전 충돌이 「사용 중」으로 읽혀 작성자가 두 실패를 구분할 수 없던 것을 확정했고, 서버의 클라이언트 안전 메시지를 그대로 실어 나르게 해서 닫는다", + "missing-verification": "서버가 답하는 그 한 문장이 다섯 이유를 다 같은 말로 덮고 있다. 그 자리는 아직 열려 있다", + "relations": [ + "question:the-refusal-does-not-name-what-blocks-it", + "reference:say-you-could-not-read-it", + "case:nine-names-for-five-kinds" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "톤을 지적받으면 고쳐 쓰지 말고 어떤 말을 쓸지 묻는다", + "slug": "ask-which-words-to-use", + "readiness": "READY", + "source": [ + "final/document.md#§13.3" + ], + "classification": "고쳐 쓴 첫 번째 안이 거절당하고 사용자가 직접 쓴 텍스트를 그대로 실은 것으로 닫혔고, 사이트의 글이 특정한 사람의 목소리인 어떤 자리에도 적용된다", + "scope": "공개 화면의 글이 작성자의 목소리인 자리 — 프로필, 프로젝트 소개, 구역 제목", + "exceptions": "오류 문구처럼 서버가 답한 사실을 그대로 실어야 하는 자리는 목소리의 문제가 아니다. 그때는 무엇을 실을지가 문제다", + "relations": [ + "case:nine-names-for-five-kinds", + "case:renaming-the-kinds-twice", + "case:an-error-message-that-guessed" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [ + { + "title": "삭제를 막는 이유 다섯 가지가 전부 같은 한 문장으로 나온다", + "slug": "the-refusal-does-not-name-what-blocks-it", + "readiness": "OPEN", + "source": [ + "final/document.md#§16.1" + ], + "evidence": [ + "evidence/raw/db/delete-blocked-by-project-link.txt" + ], + "known": "참조 검사는 다섯 테이블을 UNION ALL 로 묶어 하나라도 걸리면 같은 문장을 낸다. 실제 사례에서 막은 것은 project_document_link 의 PRIMARY 링크 한 행이었고, 그것은 「관계」 편집기가 아니라 문서의 Project 필드다", + "unknown": "무엇이 막는지 말하면서 내부 테이블 이름을 노출하지 않는 문구", + "next-verification": "참조 검사를 종류별로 갈라 어느 것이 걸렸는지 돌려주고, 화면이 그 종류를 사용자가 고칠 수 있는 자리의 이름으로 옮겨 보인다", + "decision-criterion": "삭제가 막혔을 때 어디를 고쳐야 하는지 문구만 보고 알 수 있으면 닫는다", + "relations": [ + "case:an-error-message-that-guessed", + "case:the-sql-had-never-been-executed", + "reference:say-you-could-not-read-it" + ], + "kind": "question", + "publication": "미작성" + } + ], + "decision": [] + }, + "topic": "one-thing-many-names" + }, + "an-axis-inside-a-topic": { + "title": "주제 안의 축", + "readerQuestion": "한 질문에 네 개의 답을 만들었을 때 그것을 어디에 담고, 무엇이 자동으로 따라오지 않는가?", + "kinds": { + "case": [ + { + "title": "홈의 비교 구역이 세 번 바뀌었다 — 상한을 없애고 요청을 목록 하나와 주제 하나로 고정했다", + "slug": "the-comparison-band-changed-three-times", + "readiness": "READY", + "source": [ + "final/document.md#§14.2" + ], + "evidence": [ + "evidence/browser/home-tabs-grouped.png", + "evidence/browser/home-topic-tabs.png", + "evidence/browser/tab-metrics.txt" + ], + "classification": "세 단계가 각각 무엇을 고치려 했는지와 왜 다음 단계로 갔는지가 화면 캡처와 실측값으로 남아 있고, 주제 수와 무관하게 첫 요청이 고정되는 것으로 닫힌다", + "missing-verification": "주제가 스무 개일 때의 화면은 만들어 보지 않았다. 상한을 없앤 근거는 구조이지 그 규모의 측정이 아니다", + "relations": [ + "concept:topic-variant-and-record-variant", + "question:the-conclusion-line-does-not-follow-the-records", + "case:one-cell-failing-took-its-neighbour-down" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "축은 주제가 이름을 정하고, 기록은 종류와 아이디의 쌍으로 축에 걸린다", + "slug": "topic-variant-and-record-variant", + "readiness": "READY", + "source": [ + "final/document.md#§14.1" + ], + "code": [ + "topic_variant", + "record_variant", + "variant_label", + "variantIds" + ], + "basis-version": "tech-log-backend 2026-09-01 의 축 스키마 · record_variant 에 외래키 없음 · studio_validation 과 publication 이 쓰는 방식을 따름", + "classification": "기록이 종류마다 다른 테이블에 살아 외래키를 걸 수 없다는 것과, 아무 축에도 걸리지 않은 기록이 공통 기록으로 읽힌다는 두 가지를 알아야 축 화면과 비교 구역이 읽힌다", + "relations": [ + "decision:an-axis-inside-a-topic-not-four-topics", + "case:a-link-that-pointed-at-itself", + "case:the-comparison-band-changed-three-times" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [], + "question": [ + { + "title": "축의 결론 문장과 기록 수는 기록을 붙여도 따라오지 않는다", + "slug": "the-conclusion-line-does-not-follow-the-records", + "readiness": "OPEN", + "source": [ + "final/document.md#§14.3", + "final/document.md#§16.2" + ], + "evidence": [ + "evidence/raw/db/record-variant-links.txt" + ], + "known": "홈의 줄은 문서가 아니라 축이라, 기록을 20개 붙여도 줄 수는 그대로다. 결론은 축에 손으로 쓴 글이라 누가 고치기 전까지 그대로다. 주제 화면에는 축마다 「기록 N」이 붙는데 홈에는 없다", + "unknown": "결론을 사람이 갱신하도록 남길지, 기록 수처럼 유도할 수 있는 것만 유도해 갱신이 필요한 자리를 좁힐지", + "next-verification": "홈 비교표에 기록 수를 붙여 보고, 축에 기록을 더했을 때 홈에서 무엇이 달라지고 무엇이 그대로인지 화면으로 가른다", + "decision-criterion": "축에 기록을 더했을 때 자동으로 따라오는 것과 사람이 고쳐야 하는 것이 화면에서 구분되면 닫는다", + "relations": [ + "decision:an-axis-inside-a-topic-not-four-topics", + "case:the-comparison-band-changed-three-times", + "reference:measure-what-is-visible-not-a-proxy" + ], + "kind": "question", + "publication": "미작성" + } + ], + "decision": [ + { + "title": "주제를 넷으로 쪼개지 않고 주제 안에 축을 하나 뒀다", + "slug": "an-axis-inside-a-topic-not-four-topics", + "readiness": "READY", + "source": [ + "final/document.md#§14.1", + "final/document.md#§14.3" + ], + "decision-status": "ADOPTED", + "decision-evidence": "두 주제가 같은 구조를 쓰고 축에 걸린 기록 수만 다른 것을 실제 행으로 확인했다 — evidence/raw/db/topic-variant-rows.txt · evidence/raw/db/record-variant-links.txt", + "grounds": "주제를 쪼개면 PKCE·CSRF·Authorization Code 처럼 네 구조가 함께 쓰는 기록을 어디에 둘지 애매해지고 비교도 어려워진다. 감수한 비용은 축의 이름·요약·결론이 기록에서 자동으로 나오지 않고 사람이 써야 한다는 것이다", + "classification": "프로젝트가 실제로 이 방향을 골랐고 그 대가를 editorial 칸으로 명시했다", + "evidence": [ + "evidence/raw/db/topic-variant-rows.txt", + "evidence/raw/db/record-variant-links.txt" + ], + "relations": [ + "concept:topic-variant-and-record-variant", + "question:the-conclusion-line-does-not-follow-the-records", + "case:the-comparison-band-changed-three-times" + ], + "kind": "decision", + "publication": "미작성" + } + ] + }, + "topic": "an-axis-inside-a-topic" + }, + "when-a-guard-can-be-trusted": { + "title": "가드를 언제 믿을 수 있는가", + "readerQuestion": "가드를 넣었다는 것만으로는 왜 부족한가?", + "kinds": { + "case": [ + { + "title": "가드는 작동했는데 제가 그것을 돌리지 않아 두 번 새어 나갔다", + "slug": "the-guard-worked-and-i-did-not-run-it", + "readiness": "READY", + "source": [ + "final/document.md#§7.5", + "final/document.md#§8.5", + "final/document.md#§17.4" + ], + "code": [ + "test:tech-log", + "test:unit" + ], + "classification": "두 건 다 가드가 아니라 실행이 빠진 것이었음을 확정했고, 사람이 기억해서 돌리는 가드는 절반만 존재한다는 결론으로 닫힌다", + "missing-verification": "다섯 명령을 CI 에 묶는 작업은 하지 않았다. 지금 남은 것은 메모리와 이 문서의 목록이다", + "relations": [ + "reference:what-a-person-must-run-before-deploying", + "reference:revert-the-defect-and-watch-the-guard-fail", + "case:eight-places-a-single-route-touches" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "가드는 결함을 되돌려 실제로 멈추는 것을 확인한 뒤 커밋한다", + "slug": "revert-the-defect-and-watch-the-guard-fail", + "readiness": "READY", + "source": [ + "final/document.md#§15", + "final/document.md#§3.5", + "final/document.md#§17.4" + ], + "evidence": [ + "evidence/raw/guards/guards-actually-fail.txt" + ], + "classification": "가드 셋을 각각 결함으로 되돌려 실제로 빨개지는 것을 확인한 기록이 남아 있고, 재발 방지로 넣는 어떤 검사에도 같은 절차가 적용된다", + "scope": "재발 방지로 넣는 테스트·아키텍처 규칙·CI 게이트", + "exceptions": "결함을 되돌릴 수 없는 것 — 이미 마이그레이션으로 고친 데이터 — 은 현재 상태가 고쳐져 있음을 대신 증거로 남긴다", + "relations": [ + "case:the-guard-worked-and-i-did-not-run-it", + "reference:put-one-check-on-each-seam", + "reference:enumerate-kinds-where-the-compiler-sees-it" + ], + "kind": "reference", + "publication": "미작성" + }, + { + "title": "배포 전에 사람이 돌려야 하는 것과 그 함정", + "slug": "what-a-person-must-run-before-deploying", + "readiness": "READY", + "source": [ + "final/document.md#§15.4", + "final/document.md#§12.3", + "final/document.md#§12.7", + "final/document.md#§12.8", + "final/document.md#§16.6" + ], + "classification": "빠뜨려서 실제로 새어 나간 명령과, 순서를 몰라 두 번 헤맨 자리와, 환경 변수 때문에 실패하는 자리가 한 목록으로 모인다", + "scope": "CI 에 묶이지 않은 검증이 남아 있는 저장소에서 배포 직전에 하는 일", + "exceptions": "CI 가 그 명령을 돌리면 이 목록에서 뺀다. 사람이 기억해서 돌리는 가드는 절반만 존재한다", + "relations": [ + "case:the-guard-worked-and-i-did-not-run-it", + "case:the-typecheck-command-checked-no-files", + "concept:deploying-without-a-registry" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "when-a-guard-can-be-trusted" + } + }, + "candidates": [ + { + "id": "SSOT-§1.1", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§1.1" + ], + "summary": "세 저장소와 계약의 반입 흐름", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "배경이다. 이것만으로 서는 기록이 없고 경계 열한 개를 설명하는 자리에서 함께 읽힌다" + }, + { + "id": "SSOT-§1.2", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§1.2" + ], + "summary": "공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:eleven-boundaries-a-value-crosses", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§1.3", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§1.3" + ], + "summary": "레지스트리 없이 tar 를 import 하는 배포", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:deploying-without-a-registry", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§3.1", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§3.1" + ], + "summary": "삼항 사슬의 마지막 else 가 모르는 것을 받아 간다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "concept:exhaustive-switch-and-record-tables", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§3.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§3.2" + ], + "summary": "개념을 더하자 열세 자리가 조용히 삼켰다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:one-new-kind-fell-through-thirteen-places", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§3.3", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§3.3" + ], + "summary": "Record 표와 sealed switch 식으로 바꾸면 컴파일러가 요구한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:exhaustive-switch-and-record-tables", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§3.4", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§3.4" + ], + "summary": "계약을 읽어 대조하는 가드", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:enumerate-kinds-where-the-compiler-sees-it", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§3.5", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§3.5" + ], + "summary": "종류를 나열하는 자리의 규칙 셋", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:enumerate-kinds-where-the-compiler-sees-it", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§4.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§4.1" + ], + "summary": "화면 다섯 곳이 조용히 비어 있었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:five-screens-were-quietly-empty", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§4.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§4.2" + ], + "summary": "편집기가 부르는 두 목록에 컨트롤러가 없었다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:five-screens-were-quietly-empty", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§4.3", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§4.3" + ], + "summary": "계약과 컨트롤러 전수 대조", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:compare-the-contract-with-both-implementations", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§4.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§4.4" + ], + "summary": "등록되지 않은 연산은 타입에는 보이는데 부를 수 없다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:an-operation-you-can-see-but-cannot-call", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§5.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§5.1" + ], + "summary": "공개 Reference 가 통째로 비어 있었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-public-reference-was-entirely-empty", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§5.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§5.2" + ], + "summary": "관계의 요약이 경계 세 곳을 지나며 사라졌다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-summary-vanished-at-three-boundaries", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§5.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§5.3" + ], + "summary": "관계 한 줄에 세 가지가 뭉쳐 있었다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-summary-vanished-at-three-boundaries", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§5.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§5.4" + ], + "summary": "결정 화면이 네 가지를 못 그렸다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-list-item-had-to-carry-the-whole-document", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§5.5", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§5.5" + ], + "summary": "계약에 칸이 없어 비어 있던 나머지 여덟 자리", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:a-missing-contract-field-has-a-signature", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§5.6", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§5.6" + ], + "summary": "계약의 빈칸을 알아보는 신호와 required 판단", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:a-missing-contract-field-has-a-signature", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§6.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§6.1" + ], + "summary": "메서드 매개변수는 bivariant 다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-narrow-implementation-satisfied-a-wide-port", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§6.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§6.2" + ], + "summary": "as 단언이 계약과의 어긋남을 가렸다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-public-reference-was-entirely-empty", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§6.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§6.3" + ], + "summary": "(input: never) 로 받아 캐스팅하는 조립기", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "concept:where-typescript-stops-checking", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§6.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§6.4" + ], + "summary": "루트 tsconfig 가 한 파일도 검사하지 않았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-typecheck-command-checked-no-files", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§6.5", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§6.5" + ], + "summary": "클래스패스에 남은 Jackson 2 를 컴파일이 통과시켰다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:two-pods-crashlooped-with-no-test-starting-the-context", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§6.6", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§6.6" + ], + "summary": "타입 검사를 놓아 주는 네 자리", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:where-typescript-stops-checking", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§7.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§7.1" + ], + "summary": "컨텍스트를 띄우지 않는 테스트", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:two-pods-crashlooped-with-no-test-starting-the-context", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§7.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§7.2" + ], + "summary": "SQL 이 한 번도 실행되지 않았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-sql-had-never-been-executed", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§7.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§7.3" + ], + "summary": "HTTP 게이트웨이의 매핑을 지나는 테스트가 없었다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:the-composition-root-had-no-test", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§7.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§7.4" + ], + "summary": "합성 루트에 테스트가 없었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-composition-root-had-no-test", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§7.5", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§7.5" + ], + "summary": "화면 테스트를 아예 돌리지 않았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-guard-worked-and-i-did-not-run-it", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§7.6", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§7.6" + ], + "summary": "생성기가 계약 필드를 조용히 빠뜨렸다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-generator-dropped-four-contract-fields", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§7.7", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§7.7" + ], + "summary": "이음매와 그것을 덮은 방법의 표", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:put-one-check-on-each-seam", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§8.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§8.1" + ], + "summary": "라우트 하나가 건드리는 여덟 자리", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:eight-places-a-single-route-touches", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§8.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§8.2" + ], + "summary": "nginx 가 모르는 라우트는 404 다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-route-the-web-server-never-heard-of", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§8.2b", + "kindCandidate": "DECISION", + "sourceRefs": [ + "final/document.md#§8.2" + ], + "summary": "catch-all 라우트는 번역하지 않는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "decision:do-not-translate-the-catch-all-route", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§8.3", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§8.3" + ], + "summary": "vite chunk 이름 표도 손 목록이라 대조하게 했다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:derive-the-route-lists-from-the-route-contract", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§8.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§8.4" + ], + "summary": "CI 게이트 기준값이 라우트와 함께 움직인다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:eight-places-a-single-route-touches", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§8.5", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§8.5" + ], + "summary": "주제 화면 셋을 더할 때 그 목록을 또 빠뜨렸다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:the-guard-worked-and-i-did-not-run-it", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§9.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§9.1" + ], + "summary": "축 링크가 자기 자신을 가리켰다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-link-that-pointed-at-itself", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§9.1b", + "kindCandidate": "DECISION", + "sourceRefs": [ + "final/document.md#§9.1" + ], + "summary": "새 라우트는 프론트가 먼저다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "decision:a-new-route-ships-frontend-first", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§9.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§9.2" + ], + "summary": "결정 링크가 404 였다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:an-address-frozen-at-publish-time", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§9.2b", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§9.2" + ], + "summary": "열리지 않는 주소는 링크로 그리지 않는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:do-not-draw-a-link-that-does-not-resolve", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§9.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§9.3" + ], + "summary": "주제가 없는 기록이 이름 없는 링크를 달았다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-link-that-pointed-at-itself", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§9.4", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§9.4" + ], + "summary": "주제 화면이 셋만 열어 우회했고, 조건이 충족되자 되돌렸다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:write-down-what-would-undo-a-workaround", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§10.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§10.1" + ], + "summary": "「이 프로젝트에 열린 질문이 없습니다」", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:it-said-there-were-no-open-questions", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§10.1b", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§10.1" + ], + "summary": "거짓말을 하느니 못 읽었다고 말한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:say-you-could-not-read-it", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§10.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§10.2" + ], + "summary": "한 칸의 실패가 옆 칸을 끌고 내려간다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:one-cell-failing-took-its-neighbour-down", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§10.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§10.3" + ], + "summary": "계약 밖 값이 500 을 만들어 질의가 먼저 걸러 냈다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:one-new-kind-fell-through-thirteen-places", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§10.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§10.4" + ], + "summary": "배포 직후 첫 요청부터 홈이 깨졌다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:five-screens-were-quietly-empty", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§10.5", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§10.5" + ], + "summary": "스모크 스윕이 늑대를 외쳤다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:an-expected-failure-must-not-be-counted-as-a-failure", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§10.6", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§10.6" + ], + "summary": "기록이 조용히 사라졌다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:records-disappeared-without-a-trace", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§11.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§11.1" + ], + "summary": "구역 전체에 건 격자가 제목까지 잡았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-section-wide-rule-caught-the-heading", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§11.1b", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§11.1" + ], + "summary": "배치 규칙은 그 배치를 쓰는 요소까지 좁힌다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:scope-a-layout-rule-to-what-uses-it", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§11.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§11.2" + ], + "summary": "규칙이 없었던 게 아니라 절반만 있었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-rule-was-not-missing-it-was-half-there", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§11.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§11.3" + ], + "summary": "CSS module 은 전역 규칙이 닿지 않는다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-section-wide-rule-caught-the-heading", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§12.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§12.1" + ], + "summary": "파드가 CrashLoopBackOff 로 들어간 두 건", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:two-pods-crashlooped-with-no-test-starting-the-context", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§12.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§12.2" + ], + "summary": "배포 인자를 빠뜨려 배포본이 api.example.com 을 불렀다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-build-argument-left-out", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§12.3", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§12.3" + ], + "summary": "stale JAR 검사는 커밋한 뒤에 돌린다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:what-a-person-must-run-before-deploying", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§12.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§12.4" + ], + "summary": "컨테이너가 읽을 수 없는 설정 파일", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-healthy-container-that-served-one-403", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§12.5", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§12.5" + ], + "summary": "favicon 이 404 였다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-healthy-container-that-served-one-403", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§12.6", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§12.6" + ], + "summary": "robots.txt 가 404 였다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-route-the-web-server-never-heard-of", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§12.7", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§12.7" + ], + "summary": "테스트 JVM 이 OOM 났고 증상이 원인을 가렸다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:what-a-person-must-run-before-deploying", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§12.8", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§12.8" + ], + "summary": "npm 환경 변수 누출을 피해 vitest 를 돌린다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:what-a-person-must-run-before-deploying", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§13.1", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§13.1" + ], + "summary": "한 화면에 종류 이름이 아홉 개", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:nine-names-for-five-kinds", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§13.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§13.2" + ], + "summary": "종류 이름을 두 번 바꿨다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:renaming-the-kinds-twice", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§13.3", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§13.3" + ], + "summary": "AI 스러운 문구를 지적받고 고쳐 쓴 안도 거절당했다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:ask-which-words-to-use", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§13.4", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§13.4" + ], + "summary": "오류 문구가 추측을 출력했다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:an-error-message-that-guessed", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§13.5", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§13.5" + ], + "summary": "편집기 칸 이름을 공개 화면과 맞췄다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:nine-names-for-five-kinds", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§13.6", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§13.6" + ], + "summary": "한글 slug 가 간헐적으로 충돌했다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-slug-rule-that-threw-korean-away", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§14.1", + "kindCandidate": "DECISION", + "sourceRefs": [ + "final/document.md#§14.1" + ], + "summary": "하나의 질문에 네 개의 답 — 주제 안에 축을 뒀다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "decision:an-axis-inside-a-topic-not-four-topics", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§14.1b", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#§14.1" + ], + "summary": "topic_variant 와 record_variant 의 모양", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:topic-variant-and-record-variant", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§14.2", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§14.2" + ], + "summary": "홈의 비교 구역이 세 번 바뀌었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-comparison-band-changed-three-times", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§14.3", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§14.3" + ], + "summary": "축이 무엇을 기준으로 묶이나 — 자동으로 안 따라오는 것", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "question:the-conclusion-line-does-not-follow-the-records", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§15", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§15" + ], + "summary": "가드를 각각 결함으로 되돌려 멈추는 것을 확인하고 커밋했다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:revert-the-defect-and-watch-the-guard-fail", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§15.1", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§15.1" + ], + "summary": "프론트엔드 가드 열두 개의 목록", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "가드 목록이다. 각 Case 의 재발 방지 절이 그 자리에서 필요한 줄만 가리킨다" + }, + { + "id": "SSOT-§15.2", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§15.2" + ], + "summary": "백엔드 가드 아홉 개의 목록", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "가드 목록이다. 각 Case 의 재발 방지 절이 그 자리에서 필요한 줄만 가리킨다" + }, + { + "id": "SSOT-§15.3", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§15.3" + ], + "summary": "설계 패키지 가드 다섯 개의 목록", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "가드 목록이다. 각 Case 의 재발 방지 절이 그 자리에서 필요한 줄만 가리킨다" + }, + { + "id": "SSOT-§15.4", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§15.4" + ], + "summary": "배포 전 사람이 돌려야 하는 검증", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:what-a-person-must-run-before-deploying", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§16.1", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.1" + ], + "summary": "삭제를 막는 이유를 문구가 말하지 않는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "question:the-refusal-does-not-name-what-blocks-it", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§16.2", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.2" + ], + "summary": "홈 비교표에 기록 수가 없다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "question:the-conclusion-line-does-not-follow-the-records", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§16.3", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§16.3" + ], + "summary": "두 탭 줄의 표시 방식이 다르다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:a-section-wide-rule-caught-the-heading", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§16.4", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.4" + ], + "summary": "릴리즈 0.3.0 이 초안 상태다", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "이 저장소 밖의 게시 상태다. 독자가 쓸 것이 없고 사용자 검토로 닫힌다" + }, + { + "id": "SSOT-§16.5", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.5" + ], + "summary": "수동 접근성 증거가 전부 미서명이다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "question:nobody-signed-the-accessibility-evidence", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§16.6", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.6" + ], + "summary": "환경 의존으로 실패하는 테스트 3개", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:what-a-person-must-run-before-deploying", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§16.7", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.7" + ], + "summary": "종류 열거 두 곳이 아직 컴파일러의 보호를 못 받는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "question:two-kind-tables-outside-the-compiler", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§16.8", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#§16.8" + ], + "summary": "검토용 스크린샷 3장이 저장소에 커밋돼 있다", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "정리하면 끝나는 작업 항목이다. 독립 기록으로 만들 것이 없다" + }, + { + "id": "SSOT-§16.9", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#§16.9" + ], + "summary": "주제 논지와 축 결론의 출처가 미검토 초안이다", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "reason": "자료의 상태를 적어 둔 것이다. 축의 결론을 사람이 갱신해야 한다는 물음은 따로 세워 두었다" + }, + { + "id": "SSOT-§17.1", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§17.1" + ], + "summary": "값의 여정 끝에서 확인한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:verify-at-the-end-of-the-value-journey", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-§17.2", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§17.2" + ], + "summary": "손으로 나열한 목록은 반드시 갈라진다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:enumerate-kinds-where-the-compiler-sees-it", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§17.3", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§17.3" + ], + "summary": "화면은 못 읽은 것을 없다고 말하면 안 된다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:say-you-could-not-read-it", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§17.4", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§17.4" + ], + "summary": "가드는 넣는 것보다 돌리는 것이 어렵다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:the-guard-worked-and-i-did-not-run-it", + "reason": "없애고 대상 기록의 한 절로 넣어도 이해·결정·재사용성이 그대로다" + }, + { + "id": "SSOT-§17.5", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#§17.5" + ], + "summary": "프록시 지표가 아니라 보이는 것을 측정한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:measure-what-is-visible-not-a-proxy", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + } + ], + "unlisted": [], + "history": {}, + "counts": { + "topics": 13, + "nodes": 56, + "written": 0, + "unwritten": 56, + "unlisted": 0, + "candidates": 91 + }, + "ssotSha256": "fe8f789edaa3bcf2c88f9ce83983d437ac34cfacda42e75dba6733ec1344893d" +} diff --git a/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json b/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json index 459d87a..b8ca484 100644 --- a/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json +++ b/docs/keycloak-session-store/tech-log-studio/tech-log-tree.json @@ -1,5 +1,5 @@ { - "schemaVersion": 2, + "schemaVersion": 4, "project": "keycloak-session-store", "ssot": "final/document.md", "sourceRepository": { @@ -7,25 +7,1406 @@ "revision": "cdac9b8178391311d8eca1ebc6cac15bb62d79af", "verified": "이 커밋이 A-1 부터 D-4a 까지 실험 전량을 병합한 tip 이고 저장소 HEAD 다. git branch --contains 가 experiment 브랜치 26 개를 모두 낸다 (2026-09-07 확인)" }, - "ssotSha256": "f177f53c5d7305ec842e2cd64bda76ca76b9f3f0306bc3c0e7f0315a883e54d1", - "generatedAt": "2026-09-05", - "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.", - "readinessValues": [ - "READY", - "NEEDS_EVIDENCE", - "BLOCKED", - "REJECTED" - ], + "ssotSha256": "9d2acdd6d38074572d6707655ede4b6768a1e508345e458483da33539fe9e65c", + "sourceRevision": "keycloak-session-lab@2026-09", + "generatedAt": "2026-09-07", + "candidateScope": { + "document": "final/document.md", + "sections": [ + "코드보다 먼저 드러난 문제", + "문제를 어렵게 만든 제약", + "검토한 선택지와 막힌 지점", + "선택의 이유와 지킨 경계", + "선택이 코드와 흐름에 반영되는 방식", + "결정이 지켜지는지 확인하는 방법", + "얻은 것, 잃은 것, 적용하지 않을 때", + "결국 지키려던 것은 무엇이었나" + ], + "excluded": [ + "자료", + "이 기록에 아직 없는 것" + ], + "note": "처음부터 한 편으로 쓴 글이라 제2부가 없다. 맨 뒤 두 절은 증거 목록과 그림 제작 메모라 후보 자리가 아니다" + }, + "note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸은 사람이 적고 file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py keycloak-session-store", + "contract": { + "decomposition": [ + "글감을 찾는 입력은 final/document.md 하나다. 거기에 없는 근거는 먼저 SSOT 에 넣는다.", + "후보 전부는 candidates 에 처분과 함께 남고 PROMOTE 만 topics 로 올라간다.", + "없애고 관련 Case 나 Concept 의 한 절로 넣어도 이해·결정·재사용성이 그대로라면 독립 기록으로 만들지 않는다.", + "Topic 은 독자 질문 하나다. 그 물음에 답하지 않는 글감은 다른 Topic 으로 옮긴다.", + "Concept 은 Case·Decision·Question 을 먼저 고른 뒤 그것을 이해하는 데 필요한 것만 거꾸로 더한다." + ], + "readinessValues": [ + "READY", + "OPEN", + "NEEDS_EVIDENCE", + "NEEDS_DECISION", + "BLOCKED" + ], + "dispositionValues": { + "PROMOTE": "독립 Tech Log 로 쓴다", + "MERGE_INTO": "다른 기록의 한 절로 흡수한다", + "KEEP_IN_SSOT": "분석에는 남기고 독립 기록으로 만들지 않는다 — 정상적인 성공 결과다", + "NEEDS_EVIDENCE": "주장에 아직 검증이 없다", + "NEEDS_DECISION": "방향이 그럴듯하지만 프로젝트가 정하지 않았다", + "BLOCKED": "원본이 불완전하거나 서로 어긋난다" + } + }, "topics": { - "keycloak-session-store-resilience": { - "topic": "keycloak-session-store-resilience", + "session-custody-across-nodes": { + "title": "세션의 거처 — 두 노드가 같은 답을 내는 이유", + "readerQuestion": "Keycloak 세션은 실제로 어디에 있고, 두 노드가 같은 세션을 쓰는 이유는 무엇인가?", "kinds": { - "case": [], - "concept": [], - "reference": [], + "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 캐시" + ], + "evidence": [ + "evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt", + "evidence/raw/a1-jgroups-transport-block__10-logout-not-propagated.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "같은 설정이 캐시 온도만으로 세 가지 답을 냈다", + "slug": "cache-temperature-decides-the-outcome", + "readiness": "READY", + "source": [ + "final/document.md#선택의-이유와-지킨-경계-a7-a7a" + ], + "code": [ + "CLIENT_SCOPE_CLIENT", + "REVOKED_TOKEN", + "DEFAULT_SCOPE" + ], + "evidence": [ + "evidence/raw/a7a-volatile-cause__01-cause-determined.txt", + "evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt" + ], + "classification": "가설(REVOKED_TOKEN)을 문장 로깅으로 반증하고 실제 조회를 확정한 뒤, 캐시 온도 셋을 모두 재현해 표 하나로 닫히지 않는다는 결론까지 갔다", + "missing-verification": "캐시가 식는 시간을 재지 않았다. 냉·중간·온 세 상태를 만들어 확인했을 뿐 그 사이의 전이는 관측하지 않았다", + "relations": [ + "concept:persistent-vs-volatile-user-sessions", + "reference:state-the-version-and-the-setting-with-the-result" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "롤링 재시작은 세션을 남기고 캐시만 지웠다", + "slug": "rolling-restart-keeps-sessions-drops-cache", + "readiness": "READY", + "source": [ + "final/document.md#선택의-이유와-지킨-경계-a8" + ], + "code": [ + "persistent-user-sessions" + ], + "evidence": [ + "evidence/raw/a8-rolling-restart__01-restart-availability.txt", + "evidence/raw/a8-rolling-restart__02-session-survival.txt" + ], + "classification": "재시작 전후의 DB 세션 수와 캐시 수를 따로 세어 무엇이 남고 무엇이 사라지는지 갈랐고, 그 설정을 켜는 이유로 닫힌다", + "missing-verification": "표본이 적어 무중단 주장을 처음에 과장했다가 고쳤다. 재시작 중 진행 중이던 요청은 재지 않았다", + "relations": [ + "concept:persistent-vs-volatile-user-sessions", + "case:session-sharing-is-the-database-not-replication" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "persistent-user-sessions 가 세션의 거처를 정한다", + "slug": "persistent-vs-volatile-user-sessions", + "readiness": "READY", + "source": [ + "final/document.md#코드보다-먼저-드러난-문제-버전-조건" + ], + "basis-version": "Keycloak 26.7.0 · persistent-user-sessions 기본 활성 · 24 이전과 대조", + "classification": "세션이 DB 에 있는지 메모리에 있는지를 이 설정이 정하고, 그것을 모르면 A 층 결과 셋이 왜 뒤집히는지 읽을 수 없다", + "relations": [ + "case:session-sharing-is-the-database-not-replication", + "case:rolling-restart-keeps-sessions-drops-cache", + "reference:state-the-version-and-the-setting-with-the-result" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [ + { + "title": "버전과 설정을 결과와 함께 적는다", + "slug": "state-the-version-and-the-setting-with-the-result", + "readiness": "READY", + "source": [ + "final/document.md#코드보다-먼저-드러난-문제-버전-조건", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건" + ], + "classification": "같은 제품의 같은 실험이 설정 하나로 뒤집히는 것을 세 건에서 확인했고, 그 규칙은 다른 제품의 기본값 변경에도 그대로 적용된다", + "scope": "기본값이 메이저 버전 사이에 바뀐 제품을 측정해 결과를 적는 자리", + "exceptions": "측정 대상이 그 설정에 걸리지 않는 경로라면 조건을 달지 않아도 된다. 무엇이 걸리는지를 먼저 확인한다", + "relations": [ + "concept:persistent-vs-volatile-user-sessions", + "case:cache-temperature-decides-the-outcome" + ], + "kind": "reference", + "publication": "미작성" + } + ], "question": [], "decision": [] - } + }, + "topic": "session-custody-across-nodes" + }, + "losing-a-node-or-the-store": { + "title": "노드와 저장소를 잃을 때", + "readerQuestion": "노드나 저장소를 잃으면 무엇이 먼저 깨지고, 복구까지 시간을 쓰는 것은 어느 단계인가?", + "kinds": { + "case": [ + { + "title": "200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다", + "slug": "four-logins-that-returned-200-and-vanished", + "readiness": "READY", + "source": [ + "final/document.md#선택의-이유와-지킨-경계-a2-a3" + ], + "code": [ + "SET LOCAL synchronous_commit TO OFF", + "wal_writer_delay" + ], + "evidence": [ + "evidence/raw/a3-database-crash__03-loss-measurement.txt", + "evidence/raw/a3-database-crash__07-loss-result.txt", + "evidence/raw/a3-database-crash__08-wal-settings.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것", + "slug": "two-ways-to-lose-a-node", + "readiness": "READY", + "source": [ + "final/document.md#선택의-이유와-지킨-경계-a4" + ], + "code": [ + "node-monitor-grace-period", + "tolerationSeconds", + "StatefulSet" + ], + "evidence": [ + "evidence/raw/a4-node-loss__02-worker-node-killed.txt", + "evidence/raw/a4-node-loss__03-state-during-loss.txt", + "evidence/raw/a4-node-loss__04-eviction-timing.txt" + ], + "classification": "같은 복구 시간을 내는 두 장애의 원인이 다르다는 것을 갈랐고, 축출까지 5분 40초라는 내역으로 닫힌다", + "missing-verification": "저장소를 두 노드에 나눠 배치한 구성에서는 재지 않았다. 4a 의 결과는 이 실험대의 배치에 걸려 있다", + "relations": [ + "case:four-logins-that-returned-200-and-vanished", + "concept:readiness-hides-the-broken-node", + "reference:most-of-an-outage-is-noticing" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "200 밀리초를 넣었더니 응답이 22.2 초가 됐다", + "slug": "200ms-of-delay-became-22-seconds", + "readiness": "READY", + "source": [ + "final/document.md#선택의-이유와-지킨-경계-a6" + ], + "code": [ + "agroal 커넥션 풀", + "readiness probe" + ], + "evidence": [ + "evidence/raw/a6-latency-injection__02-delay-injected.txt", + "evidence/raw/a6-latency-injection__04-pool-under-load.txt" + ], + "classification": "28 배가 어디서 왔는지를 왕복 누적과 풀 경합 두 단계로 분해했고, 느림이 장애로 승격되는 지점까지 관측해 닫았다", + "missing-verification": "지연 값을 200ms 한 점에서만 쟀다. 어느 지연부터 readiness 가 실패하는지는 재지 않았다", + "relations": [ + "concept:readiness-hides-the-broken-node", + "case:two-ways-to-lose-a-node" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "readiness 가 깨진 노드를 시야에서 먼저 치운다", + "slug": "readiness-hides-the-broken-node", + "readiness": "READY", + "source": [ + "final/document.md#검토한-선택지와-막힌-지점-관측을-어디에" + ], + "basis-version": "k3s · Kubernetes readiness probe · Keycloak 26.7.0 /health", + "classification": "밖에서 재면 장애가 보이지 않는 이유가 이 동작이고, 이것을 모르면 A 층 결과 대부분을 아무 일도 없었던 것으로 읽는다", + "relations": [ + "case:200ms-of-delay-became-22-seconds", + "case:two-ways-to-lose-a-node", + "concept:the-up-metric-cannot-see-alive-but-useless" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [ + { + "title": "장애 시간의 대부분은 알아채는 데 걸린다", + "slug": "most-of-an-outage-is-noticing", + "readiness": "READY", + "source": [ + "final/document.md#선택의-이유와-지킨-경계-a4" + ], + "classification": "복구 절차가 아니라 감지 지연이 장애 시간을 정한다는 것을 타이머 두 개의 합으로 확인했고, 다른 오케스트레이터에도 같은 축이 있다", + "scope": "노드 상실을 오케스트레이터가 감지해 대체를 만드는 구성", + "exceptions": "진입 경로 자체를 잃은 장애는 감지가 빨라도 복구되지 않는다. 그때는 이중화 지점이 문제다", + "relations": [ + "case:two-ways-to-lose-a-node", + "case:four-logins-that-returned-200-and-vanished" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "losing-a-node-or-the-store" + }, + "where-application-state-lives": { + "title": "애플리케이션이 서버에 든 상태의 자리", + "readerQuestion": "로그인 세션과 OAuth 토큰을 서버 어디에 두어야 하고, 저장소를 옮기면 무엇이 따라오지 않는가?", + "kinds": { + "case": [ + { + "title": "세션만 Redis 로 옮기자 토큰이 따라오지 않았다", + "slug": "moving-the-session-left-the-tokens-behind", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b0", + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b1" + ], + "code": [ + "InMemoryOAuth2AuthorizedClientService", + "AuthenticatedPrincipalOAuth2AuthorizedClientRepository", + "SPRING_SESSION_STORE_TYPE" + ], + "evidence": [ + "evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt", + "evidence/raw/b1-redis-session-store__03-redis-contents.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다", + "slug": "a-primary-key-without-the-session-id", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b2" + ], + "code": [ + "JdbcOAuth2AuthorizedClientService", + "PRIMARY KEY (client_registration_id, principal_name)" + ], + "evidence": [ + "evidence/raw/b0-bff-redis-deploy__02-autoconfiguration.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다", + "slug": "the-winner-of-the-rotation-race-also-loses", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b3" + ], + "code": [ + "revokeRefreshToken=true", + "refreshTokenMaxReuse=0" + ], + "evidence": [ + "evidence/raw/b0-bff-redis-deploy__01-deploy.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "볼륨 없는 영속화와 유예 없는 키 회전", + "slug": "persistence-without-a-volume-and-rotation-without-overlap", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b5-b6" + ], + "code": [ + "appendonly yes", + "NimbusJwtDecoder", + "JWKS kid" + ], + "evidence": [ + "evidence/raw/b5-redis-loss__04-persistence.txt", + "evidence/raw/b6-key-rotation__03-old-key-removed.txt" + ], + "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", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "세션과 인가된 클라이언트는 조회 키가 다르다", + "slug": "two-stores-two-lookup-keys", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b0" + ], + "basis-version": "Spring Boot 3 · Spring Security OAuth2 Client · Spring Session Redis", + "classification": "하나는 세션 id 로 다른 하나는 principal 이름으로 찾는다는 것을 알아야 저장소를 옮긴 결과를 읽을 수 있다", + "relations": [ + "case:moving-the-session-left-the-tokens-behind", + "case:a-primary-key-without-the-session-id", + "decision:split-the-two-stores-and-design-each" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [ + { + "title": "저장소를 옮기기 전에 조회 키를 본다", + "slug": "look-at-the-lookup-key-before-moving-the-store", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b0", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답" + ], + "classification": "무엇으로 찾는지가 무엇을 옮겨야 하는지를 정한다는 기준이고, 세션과 토큰이 아닌 다른 짝에도 그대로 적용된다", + "scope": "서버가 든 상태를 외부 저장소로 옮기는 자리", + "exceptions": "상태가 하나뿐이고 조회 키도 하나면 이 확인이 필요 없다. 다만 자동구성이 무엇을 골랐는지는 그때도 읽는다", + "relations": [ + "concept:two-stores-two-lookup-keys", + "case:a-primary-key-without-the-session-id" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [ + { + "title": "세션과 토큰의 저장소를 나눠 각각 설계한다", + "slug": "split-the-two-stores-and-design-each", + "readiness": "READY", + "decision-status": "ADOPTED", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b2", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답" + ], + "decision-evidence": [ + "case:moving-the-session-left-the-tokens-behind", + "case:a-primary-key-without-the-session-id" + ], + "grounds": "조회 키가 달라 한쪽을 옮겨도 다른 쪽이 따라오지 않고, 나눠 옮긴 뒤에도 남는 문제의 원인은 저장소가 아니라 스키마였다", + "classification": "한 저장소로 묶는 대안을 실제로 시도해 반쪽만 옮겨지는 것을 확인한 뒤 나누기로 정했고, 남는 비용을 함께 적었다", + "relations": [ + "concept:two-stores-two-lookup-keys", + "case:a-primary-key-without-the-session-id" + ], + "kind": "decision", + "publication": "미작성" + } + ] + }, + "topic": "where-application-state-lives" + }, + "trust-handed-over-at-the-edge": { + "title": "엣지와 IdP 가 건네는 신뢰", + "readerQuestion": "엣지와 IdP 가 만든 인증 결과는 애플리케이션까지 어떻게 전달되고, 무엇이 그 전달을 끊는가?", + "kinds": { + "case": [ + { + "title": "nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다", + "slug": "nginx-does-not-overwrite-a-header-it-never-sets", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b4" + ], + "code": [ + "proxy_set_header X-Auth-Request-Roles", + "--cookie-refresh" + ], + "evidence": [ + "evidence/raw/b4-edge-authorization__01-header-handling.txt" + ], + "classification": "위조 헤더를 실제로 보내 통과하는 것을 관측하고 원인을 지우지 않은 설정에서 확정했으며, 클레임 변경이 재인증까지 반영되지 않는 것도 같은 실험에서 닫았다", + "missing-verification": "--cookie-refresh 를 켠 구성에서는 재지 않았다. 클레임 반영 지연이 얼마나 줄어드는지는 확인하지 않았다", + "relations": [ + "reference:clear-the-header-before-you-trust-it", + "case:nobody-implemented-backchannel-logout" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "백채널 로그아웃은 양쪽 다 없었다", + "slug": "nobody-implemented-backchannel-logout", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-c층" + ], + "code": [ + "backchannelLogoutUrl", + "oidcLogout" + ], + "evidence": [ + "evidence/raw/b4-edge-authorization__01-header-handling.txt" + ], + "classification": "네 가지를 순서대로 확인해 설정 결손과 기능 부재가 한 경로에 겹쳐 있는 것을 갈랐고, 한쪽만 고쳐서는 되지 않는다는 결론으로 닫힌다", + "missing-verification": "양쪽을 다 구현해서 전파가 되는지 확인하지 않았다. 원인 확정까지가 이 실험의 범위다", + "relations": [ + "case:nginx-does-not-overwrite-a-header-it-never-sets", + "reference:clear-the-header-before-you-trust-it" + ], + "kind": "case", + "publication": "미작성" + }, + { + "title": "쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션", + "slug": "orphan-sessions-and-the-ttl-that-finds-them", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a" + ], + "code": [ + "--cookie-secret", + "--cookie-refresh", + "refresh:disabled" + ], + "evidence": [ + "evidence/raw/b7a-orphan-session__01-orphan-lifecycle.txt", + "evidence/raw/b7-cookie-secret__01-deploy.txt" + ], + "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", + "publication": "미작성" + } + ], + "concept": [], + "question": [], + "decision": [], + "reference": [ + { + "title": "믿기 전에 그 헤더를 먼저 지운다", + "slug": "clear-the-header-before-you-trust-it", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b4", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답" + ], + "classification": "프록시가 동명 헤더를 자동으로 덮어쓸 것이라는 전제가 틀렸다는 것을 확인했고, 다른 프록시에서도 같은 확인이 필요하다", + "scope": "엣지가 인증 결과를 헤더로 실어 업스트림에 넘기는 구성", + "exceptions": "업스트림이 헤더가 아니라 서명된 토큰을 검증하면 이 지우기가 필요 없다", + "relations": [ + "case:nginx-does-not-overwrite-a-header-it-never-sets", + "case:orphan-sessions-and-the-ttl-that-finds-them" + ], + "kind": "reference", + "publication": "미작성" + } + ] + }, + "topic": "trust-handed-over-at-the-edge" + }, + "operations-that-report-success": { + "title": "성공이라고 적으면서 실패하는 운영 절차", + "readerQuestion": "운영 절차가 성공이라고 보고하는데도 의도한 일이 일어나지 않는 자리는 어디이고, 무엇으로 판정하는가?", + "kinds": { + "case": [ + { + "title": "새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다", + "slug": "the-certificate-that-took-38-minutes-to-reach-the-wire", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4" + ], + "code": [ + "certbot-renew.service ExecStartPost", + "/etc/letsencrypt/renewal-hooks/", + "nginx 마스터·워커 PID" + ], + "evidence": [ + "evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt", + "evidence/raw/d4-certificate-renewal__09-serial-timeline.txt", + "evidence/raw/d4-certificate-renewal__12-certbot-state.txt", + "evidence/raw/d4-certificate-renewal__13-verdict.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "deploy 훅 하나가 그 공백을 1~2초로 줄였다", + "slug": "a-deploy-hook-closed-the-gap-to-two-seconds", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "code": [ + "renewal-hooks/deploy/", + "types_hash 경고" + ], + "evidence": [ + "evidence/raw/d4a-deploy-hook__01-hook-verified.txt", + "evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt", + "evidence/raw/d4a-deploy-hook__03-after-state.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다", + "slug": "the-upgrade-that-would-not-roll-back", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2" + ], + "code": [ + "liquibase ValidationFailedException", + "databasechangelog", + "StatefulSet 롤링 업데이트" + ], + "evidence": [ + "evidence/raw/d4-certificate-renewal__01-certificate-state.txt" + ], + "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", + "publication": "미작성" + } + ], + "concept": [], + "reference": [ + { + "title": "적용됐는지는 로그 문구가 아니라 상태로 판정한다", + "slug": "judge-a-reload-by-the-worker-pid-not-the-log", + "readiness": "READY", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4", + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "classification": "같은 절차에서 로그는 성공이라고 적는데 상태는 바뀌지 않은 자리를 두 번 만났고, 판정을 상태로 옮기는 규칙은 다른 재적재에도 적용된다", + "scope": "설정이나 인증서를 다시 읽게 하는 절차. reload · rotate · reconcile", + "exceptions": "절차가 프로세스를 완전히 교체하면 PID 비교가 판정이 되지 않는다. 그때는 적재한 값 자체를 확인한다", + "relations": [ + "case:a-deploy-hook-closed-the-gap-to-two-seconds", + "case:the-upgrade-that-would-not-roll-back" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [ + { + "title": "갱신 타이머가 실제 갱신에서도 도는가", + "slug": "does-the-renewal-timer-actually-renew", + "readiness": "OPEN", + "source": [ + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-재보지-않은-것", + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "known": "강제 갱신에서는 deploy 훅이 1~2 초 만에 reload 를 걸었다. 타이머 자체는 매번 SUCCESS 로 끝나고 있다", + "unknown": "만료 30 일 전 조건이 성립해 타이머가 실제 갱신을 수행할 때도 같은 훅이 도는가", + "next-verification": "만료 30 일 전(약 89 일 뒤)에 타이머가 돈 뒤 워커 PID 와 서빙 인증서의 일련번호를 확인한다", + "decision-criterion": "워커 PID 가 바뀌고 서빙 일련번호가 새 인증서와 같으면 닫는다. 그렇지 않으면 훅이 강제 갱신에서만 도는 것이므로 타이머 유닛 쪽에 훅을 다시 건다", + "relations": [ + "case:the-certificate-that-took-38-minutes-to-reach-the-wire", + "case:a-deploy-hook-closed-the-gap-to-two-seconds" + ], + "kind": "question", + "publication": "미작성" + } + ], + "decision": [ + { + "title": "reload 를 사람이 아니라 deploy 훅이 부르게 한다", + "slug": "put-the-reload-in-a-deploy-hook", + "readiness": "READY", + "decision-status": "ADOPTED", + "source": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "decision-evidence": [ + "case:the-certificate-that-took-38-minutes-to-reach-the-wire", + "case:a-deploy-hook-closed-the-gap-to-two-seconds" + ], + "grounds": "훅이 없을 때 갱신에서 서빙까지 2305 초가 걸렸고 그 reload 를 부른 것은 자동화가 아니라 사람이었다. 훅을 넣자 1~2 초가 됐다", + "classification": "사람이 치는 절차로 두는 대안을 실제 공백으로 확인한 뒤 훅으로 정했고, 훅 로그가 error 를 찍는다는 비용을 함께 적었다", + "relations": [ + "case:a-deploy-hook-closed-the-gap-to-two-seconds", + "reference:judge-a-reload-by-the-worker-pid-not-the-log", + "question:does-the-renewal-timer-actually-renew" + ], + "kind": "decision", + "publication": "미작성" + } + ] + }, + "topic": "operations-that-report-success" + }, + "when-the-measurement-lies": { + "title": "측정이 거짓말하는 자리", + "readerQuestion": "무엇을 재야 잰 것이 되는가 — 주입과 관측 사이에서 측정은 어디서 거짓말하는가?", + "kinds": { + "case": [ + { + "title": "주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다", + "slug": "nine-injections-that-silently-did-nothing", + "readiness": "READY", + "source": [ + "final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다" + ], + "code": [ + "conntrack", + "kube-router FORWARD", + "PID 1 SIGKILL", + "flannel VXLAN" + ], + "evidence": [ + "evidence/raw/a1-jgroups-transport-block__05-conntrack-problem.txt", + "evidence/raw/a6-latency-injection__03-flannel-injection.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다", + "slug": "seventy-six-failures-that-were-not-the-servers", + "readiness": "READY", + "source": [ + "final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는" + ], + "code": [ + "TCP 연결수 0", + "50µs" + ], + "evidence": [ + "evidence/raw/d4-certificate-renewal__05-control-no-injection.txt", + "evidence/raw/d4-certificate-renewal__11-inflight-full.txt" + ], + "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", + "publication": "미작성" + }, + { + "title": "산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다", + "slug": "commands-written-as-prose-do-not-run", + "readiness": "READY", + "source": [ + "final/document.md#결정이-지켜지는지-확인하는-방법-재현-가능성", + "final/document.md#검토한-선택지와-막힌-지점-스크립트를-쓰지-않는다" + ], + "code": [ + "kubectl run --rm -i", + "상주 탐침" + ], + "evidence": [ + "evidence/raw/a6-latency-injection__04-pool-under-load.txt" + ], + "classification": "헤드라인 수치를 만든 명령 넷이 산문이던 것을 셸 표현식으로 바꾸고 실제로 돌려 확인했으며, 그 확인에서 한 건이 깨진 것까지 닫았다", + "missing-verification": "고친 명령을 다른 환경에서 돌려 보지 않았다. 이 실험대에서만 확인했다", + "relations": [ + "case:nine-injections-that-silently-did-nothing", + "reference:verify-the-injection-landed-separately-from-the-result" + ], + "kind": "case", + "publication": "미작성" + } + ], + "concept": [ + { + "title": "up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다", + "slug": "the-up-metric-cannot-see-alive-but-useless", + "readiness": "READY", + "source": [ + "final/document.md#검토한-선택지와-막힌-지점-관측을-어디에", + "final/document.md#결정이-지켜지는지-확인하는-방법-관측-도구는-부분집합" + ], + "basis-version": "Prometheus up 지표 · Keycloak 26.7.0 /metrics · 이 실험대의 스크레이프 대상", + "classification": "프로세스가 살아 있고 /metrics 가 응답하기만 하면 1 이 된다는 것을 알아야 503 중에도 1 이던 관측을 읽을 수 있다", + "relations": [ + "case:seventy-six-failures-that-were-not-the-servers", + "concept:readiness-hides-the-broken-node", + "reference:verify-the-injection-landed-separately-from-the-result" + ], + "kind": "concept", + "publication": "미작성" + } + ], + "reference": [ + { + "title": "예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다", + "slug": "verify-the-injection-landed-separately-from-the-result", + "readiness": "READY", + "source": [ + "final/document.md#결국-지키려던-것은-무엇이었나", + "final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다", + "final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는" + ], + "classification": "세 규칙이 각각 어겨졌을 때 무엇이 틀렸는지가 이 실험대의 이력으로 남아 있고, 장애 주입을 하는 다른 실험에도 그대로 적용된다", + "scope": "상태를 일부러 망가뜨리고 그 영향을 재는 모든 실험", + "exceptions": "주입 없이 평시를 관측하는 측정에는 첫 두 규칙이 걸리지 않는다. 대조군 규칙은 그때도 걸린다", + "relations": [ + "case:nine-injections-that-silently-did-nothing", + "case:seventy-six-failures-that-were-not-the-servers", + "concept:the-up-metric-cannot-see-alive-but-useless" + ], + "kind": "reference", + "publication": "미작성" + }, + { + "title": "두 시계에서 온 값을 빼지 않는다", + "slug": "never-subtract-values-from-two-clocks", + "readiness": "READY", + "source": [ + "final/document.md#결정이-지켜지는지-확인하는-방법-두-시계" + ], + "classification": "보정 없이 뺀 값이 자릿수가 아니라 방향까지 틀리는 것을 실제로 만났고, 시각을 다루는 다른 측정에도 같은 확인이 필요하다", + "scope": "서로 다른 기계에서 온 타임스탬프의 차이를 재는 자리", + "exceptions": "같은 기계의 단조 시계로만 잰 구간에는 걸리지 않는다", + "relations": [ + "case:seventy-six-failures-that-were-not-the-servers", + "case:commands-written-as-prose-do-not-run" + ], + "kind": "reference", + "publication": "미작성" + } + ], + "question": [], + "decision": [] + }, + "topic": "when-the-measurement-lies" } + }, + "candidates": [ + { + "id": "SSOT-session-sharing-is-the-database-not-replication", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#코드보다-먼저-드러난-문제-전제가-무너졌다", + "final/document.md#선택의-이유와-지킨-경계-a1" + ], + "summary": "클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:session-sharing-is-the-database-not-replication", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-cache-temperature-decides-the-outcome", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a7-a7a" + ], + "summary": "같은 설정이 캐시 온도만으로 세 가지 답을 냈다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:cache-temperature-decides-the-outcome", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-rolling-restart-keeps-sessions-drops-cache", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a8" + ], + "summary": "롤링 재시작은 세션을 남기고 캐시만 지웠다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:rolling-restart-keeps-sessions-drops-cache", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-persistent-vs-volatile-user-sessions", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#코드보다-먼저-드러난-문제-버전-조건" + ], + "summary": "persistent-user-sessions 가 세션의 거처를 정한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:persistent-vs-volatile-user-sessions", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-state-the-version-and-the-setting-with-the-result", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#코드보다-먼저-드러난-문제-버전-조건", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건" + ], + "summary": "버전과 설정을 결과와 함께 적는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:state-the-version-and-the-setting-with-the-result", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-four-logins-that-returned-200-and-vanished", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a2-a3" + ], + "summary": "200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:four-logins-that-returned-200-and-vanished", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-two-ways-to-lose-a-node", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a4" + ], + "summary": "노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:two-ways-to-lose-a-node", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-200ms-of-delay-became-22-seconds", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a6" + ], + "summary": "200 밀리초를 넣었더니 응답이 22.2 초가 됐다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:200ms-of-delay-became-22-seconds", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-readiness-hides-the-broken-node", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#검토한-선택지와-막힌-지점-관측을-어디에" + ], + "summary": "readiness 가 깨진 노드를 시야에서 먼저 치운다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:readiness-hides-the-broken-node", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-most-of-an-outage-is-noticing", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a4" + ], + "summary": "장애 시간의 대부분은 알아채는 데 걸린다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:most-of-an-outage-is-noticing", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-moving-the-session-left-the-tokens-behind", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b0", + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b1" + ], + "summary": "세션만 Redis 로 옮기자 토큰이 따라오지 않았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:moving-the-session-left-the-tokens-behind", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-a-primary-key-without-the-session-id", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b2" + ], + "summary": "기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-primary-key-without-the-session-id", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-the-winner-of-the-rotation-race-also-loses", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b3" + ], + "summary": "회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-winner-of-the-rotation-race-also-loses", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-persistence-without-a-volume-and-rotation-without-overlap", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b5-b6" + ], + "summary": "볼륨 없는 영속화와 유예 없는 키 회전", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:persistence-without-a-volume-and-rotation-without-overlap", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-two-stores-two-lookup-keys", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b0" + ], + "summary": "세션과 인가된 클라이언트는 조회 키가 다르다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:two-stores-two-lookup-keys", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-look-at-the-lookup-key-before-moving-the-store", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b0", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답" + ], + "summary": "저장소를 옮기기 전에 조회 키를 본다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:look-at-the-lookup-key-before-moving-the-store", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-split-the-two-stores-and-design-each", + "kindCandidate": "DECISION", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b2", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답" + ], + "summary": "세션과 토큰의 저장소를 나눠 각각 설계한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "decision:split-the-two-stores-and-design-each", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-nginx-does-not-overwrite-a-header-it-never-sets", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b4" + ], + "summary": "nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:nginx-does-not-overwrite-a-header-it-never-sets", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-nobody-implemented-backchannel-logout", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-c층" + ], + "summary": "백채널 로그아웃은 양쪽 다 없었다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:nobody-implemented-backchannel-logout", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-orphan-sessions-and-the-ttl-that-finds-them", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a" + ], + "summary": "쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:orphan-sessions-and-the-ttl-that-finds-them", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-clear-the-header-before-you-trust-it", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-b4", + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답" + ], + "summary": "믿기 전에 그 헤더를 먼저 지운다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:clear-the-header-before-you-trust-it", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-the-certificate-that-took-38-minutes-to-reach-the-wire", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4" + ], + "summary": "새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-certificate-that-took-38-minutes-to-reach-the-wire", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-a-deploy-hook-closed-the-gap-to-two-seconds", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "summary": "deploy 훅 하나가 그 공백을 1~2초로 줄였다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:a-deploy-hook-closed-the-gap-to-two-seconds", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-the-upgrade-that-would-not-roll-back", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2" + ], + "summary": "되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:the-upgrade-that-would-not-roll-back", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-judge-a-reload-by-the-worker-pid-not-the-log", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4", + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "summary": "적용됐는지는 로그 문구가 아니라 상태로 판정한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:judge-a-reload-by-the-worker-pid-not-the-log", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-does-the-renewal-timer-actually-renew", + "kindCandidate": "QUESTION", + "sourceRefs": [ + "final/document.md#얻은-것-잃은-것-적용하지-않을-때-재보지-않은-것", + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "summary": "갱신 타이머가 실제 갱신에서도 도는가", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "question:does-the-renewal-timer-actually-renew", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-put-the-reload-in-a-deploy-hook", + "kindCandidate": "DECISION", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a" + ], + "summary": "reload 를 사람이 아니라 deploy 훅이 부르게 한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "decision:put-the-reload-in-a-deploy-hook", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-nine-injections-that-silently-did-nothing", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다" + ], + "summary": "주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:nine-injections-that-silently-did-nothing", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-seventy-six-failures-that-were-not-the-servers", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는" + ], + "summary": "실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:seventy-six-failures-that-were-not-the-servers", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-commands-written-as-prose-do-not-run", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#결정이-지켜지는지-확인하는-방법-재현-가능성", + "final/document.md#검토한-선택지와-막힌-지점-스크립트를-쓰지-않는다" + ], + "summary": "산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "case:commands-written-as-prose-do-not-run", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-the-up-metric-cannot-see-alive-but-useless", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#검토한-선택지와-막힌-지점-관측을-어디에", + "final/document.md#결정이-지켜지는지-확인하는-방법-관측-도구는-부분집합" + ], + "summary": "up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "concept:the-up-metric-cannot-see-alive-but-useless", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-verify-the-injection-landed-separately-from-the-result", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#결국-지키려던-것은-무엇이었나", + "final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다", + "final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는" + ], + "summary": "예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:verify-the-injection-landed-separately-from-the-result", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-never-subtract-values-from-two-clocks", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#결정이-지켜지는지-확인하는-방법-두-시계" + ], + "summary": "두 시계에서 온 값을 빼지 않는다", + "disposition": "PROMOTE", + "dispositionReview": "CONFIRMED", + "target": "reference:never-subtract-values-from-two-clocks", + "reason": "SSOT 가 이 주장을 독립된 관측·규칙·결정·질문으로 담고 있고 독립성 검사를 통과한다" + }, + { + "id": "SSOT-a1-transport-vs-discovery", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a1" + ], + "summary": "7800 을 끊었더니 세션 공유는 안 깨지고 로그아웃 전파가 깨졌다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:session-sharing-is-the-database-not-replication", + "reason": "SSOT 가 이 결과를 A-0 의 확증으로 적는다. 발견은 DB 를 쓰고 전송은 7800 을 쓴다는 같은 사실의 두 면이다" + }, + { + "id": "SSOT-a5-asymmetric-partition", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택의-이유와-지킨-경계-a5" + ], + "summary": "비대칭 분단은 클러스터를 가르지 못하고 전면 장애 경로도 없다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:two-ways-to-lose-a-node", + "reason": "결론이 「전면 장애가 없다」라 독립 기록으로 읽을 이유가 약하다. 여기서 나온 주입 실패 셋은 측정 Topic 의 Case 가 담는다" + }, + { + "id": "SSOT-c1-sso-across-apps", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-c층" + ], + "summary": "두 앱이 같은 realm 으로 SSO 되는 것을 확인했다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:nobody-implemented-backchannel-logout", + "reason": "SSO 가 되는 것 자체는 기대한 동작이고, 이 실험이 남긴 것은 그 옆에서 관측한 로그아웃 미전파다" + }, + { + "id": "SSOT-d1-backup-restore", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2" + ], + "summary": "백업과 복구 — 본문 RTO 30초와 타임라인 41초가 어긋났다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:seventy-six-failures-that-were-not-the-servers", + "reason": "SSOT 가 이 건을 문서와 증거가 어긋난 사례 표에 넣었고, 그 표가 측정 Topic 의 근거다" + }, + { + "id": "SSOT-d3-secret-base64", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#선택이-코드와-흐름에-반영되는-방식-d3" + ], + "summary": "kubectl get secret 의 base64 는 암호화가 아니라 인코딩이다", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "target": null, + "reason": "널리 알려진 사실이고 이 실험이 더한 것은 두 노출 경로를 확인한 것까지다. 규칙으로 빼면 다른 프로젝트에 줄 것이 없다" + }, + { + "id": "SSOT-lab-topology", + "kindCandidate": "CONCEPT", + "sourceRefs": [ + "final/document.md#문제를-어렵게-만든-제약-실험대" + ], + "summary": "베어메탈 한 대 위의 VM 두 대와 인증서 SAN 셋", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "target": null, + "reason": "측정 환경의 구성이다. 각 Case 의 검증 환경이 필요한 만큼을 이미 담는다" + }, + { + "id": "SSOT-guest-host-sudo", + "kindCandidate": "CASE", + "sourceRefs": [ + "final/document.md#문제를-어렵게-만든-제약-sudo" + ], + "summary": "호스트의 sudo 가 비밀번호를 요구해 조용히 실패했다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "case:nine-injections-that-silently-did-nothing", + "reason": "아홉 건 표의 8 번이 이 사건이다. 표 밖으로 빼면 같은 이야기가 둘이 된다" + }, + { + "id": "SSOT-observation-three-points", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#검토한-선택지와-막힌-지점-관측을-어디에" + ], + "summary": "관측 지점을 외부·지표·데이터베이스 셋으로 늘렸다", + "disposition": "MERGE_INTO", + "dispositionReview": "CONFIRMED", + "target": "reference:verify-the-injection-landed-separately-from-the-result", + "reason": "주입이 걸렸는지 결과와 따로 확인한다는 규칙이 이 세 지점을 요구한다. 그 규칙의 한 절이다" + }, + { + "id": "SSOT-techviz-remake", + "kindCandidate": "REFERENCE", + "sourceRefs": [ + "final/document.md#이-기록에-아직-없는-것" + ], + "summary": "손그림 28 건을 techviz 로 다시 만들며 lint 가 잡은 두 가지", + "disposition": "KEEP_IN_SSOT", + "dispositionReview": "CONFIRMED", + "target": null, + "reason": "이 저장소의 그림 제작 절차이지 이 주제의 독자 질문에 답하지 않는다. 후보 범위 밖이다" + } + ], + "unlisted": [], + "history": {}, + "counts": { + "topics": 6, + "nodes": 33, + "written": 0, + "unwritten": 33, + "unlisted": 0, + "candidates": 42 } } diff --git a/scripts/verify-tech-log-tree.py b/scripts/verify-tech-log-tree.py index eb56994..25f4a3a 100755 --- a/scripts/verify-tech-log-tree.py +++ b/scripts/verify-tech-log-tree.py @@ -142,20 +142,27 @@ def verify(project: str) -> Report: "— 칸마다 error 를 내지 않는 대신 미채택 자체를 여기서 한 번 센다") # ── 분석한 저장소 ────────────────────────────────────────────── + # 저장소가 여럿인 프로젝트는 목록으로 적는다. 하나면 객체 하나로 적어도 된다 repo = index.get("sourceRepository") or {} + repos = repo if isinstance(repo, list) else [repo] if has_contract: - if not repo.get("path"): + if not repos: rep.error("sourceRepository.path 가 없다", f"{project}: 어느 저장소를 읽고 쓴 글인지 적혀 있지 않다") - elif not os.path.isdir(repo["path"]) and "://" not in repo["path"]: - rep.warn("sourceRepository.path 가 이 기계에 없다", repo["path"]) - # 갈래가 여럿이면 단일 커밋으로 표현되지 않는다. revisions 로 적는다 - if not repo.get("revision") and not repo.get("revisions"): - rep.warn("sourceRepository 에 리비전이 없다", - f"{project}: 문서가 서술한 상태의 커밋을 고정하지 않았다") - elif not repo.get("verified"): - rep.warn("sourceRepository.verified 가 없다", - f"{project}: 그 리비전이 맞다고 판단한 근거가 없다") + for r in repos: + name = r.get("name") or project + if not r.get("path"): + rep.error("sourceRepository.path 가 없다", + f"{project}: 어느 저장소를 읽고 쓴 글인지 적혀 있지 않다") + elif not os.path.exists(r["path"]) and "://" not in r["path"]: + rep.warn("sourceRepository.path 가 이 기계에 없다", f"{name}: {r['path']}") + # 갈래가 여럿이면 단일 커밋으로 표현되지 않는다. revisions 로 적는다 + if not r.get("revision") and not r.get("revisions"): + rep.warn("sourceRepository 에 리비전이 없다", + f"{project}/{name}: 문서가 서술한 상태의 커밋을 고정하지 않았다") + elif not r.get("verified"): + rep.warn("sourceRepository.verified 가 없다", + f"{project}/{name}: 그 리비전이 맞다고 판단한 근거가 없다") # ── 후보를 찾는 범위 ─────────────────────────────────────────── scope = index.get("candidateScope") or {}