refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,164 @@
{
"version": "1.1",
"id": "composition-root-seam",
"title": "테스트가 끊긴 곳과 실제 런타임이 지나는 합성 루트",
"question": "게이트웨이 테스트와 화면 테스트가 통과했는데 왜 합성 루트의 credential 결함은 운영에서만 드러났는가?",
"type": "component",
"direction": "LR",
"audience": [
"프론트엔드 개발자",
"테스트 설계자"
],
"summary": "화면 테스트는 게이트웨이를 스텁하고 게이트웨이 테스트는 실행기를 스텁해서, 실제 런타임만 지나는 합성 루트의 credential 결정이 두 테스트에서 빠졌다.",
"alt": "화면, 게이트웨이, 합성 루트의 credential 결정, 실제 런타임 어댑터가 이어진 경로. 화면에는 게이트웨이 스텁, 게이트웨이에는 실행기 스텁이 표시되고 합성 루트가 테스트 공백으로 강조되어 있다.",
"long_description": "왼쪽에서 오른쪽으로 실제 런타임 경로를 읽는다. 화면에서 게이트웨이로 가고 합성 루트에서 credential을 결정한 뒤 실제 런타임 어댑터가 배포된 백엔드의 실제 404 본문을 읽는다. 화면 테스트는 게이트웨이를 스텁하고 게이트웨이 테스트는 실행기를 스텁했기 때문에 가운데 합성 루트의 credential 결정은 두 테스트가 지나지 않았다.",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "7. 테스트가 지나지 않는 이음매",
"line": 690
}
},
"composition": {
"profile": "component-flow",
"diagram_only": true,
"reference_ids": [
"payment-event-flow"
],
"rationale": "본문은 화면·게이트웨이 테스트가 스텁에서 끊기는 반면 실제 런타임 어댑터가 합성 루트의 credential 결정을 지나가는 경로를 설명한다.",
"focus_node": "composition-root"
},
"groups": [],
"nodes": [
{
"id": "screen",
"label": "화면",
"kind": "component",
"role": "source",
"details": [
"화면 테스트: gateway stub"
],
"evidence": [
{
"start_line": 740,
"end_line": 742
},
{
"start_line": 770,
"end_line": 771
}
],
"assumption": false
},
{
"id": "gateway",
"label": "Gateway",
"kind": "service",
"role": "service",
"details": [
"gateway 테스트: executor stub"
],
"evidence": [
{
"start_line": 766,
"end_line": 771
}
],
"assumption": false
},
{
"id": "composition-root",
"label": "Composition Root",
"kind": "component",
"role": "service",
"details": [
"credential decision",
"테스트 공백"
],
"evidence": [
{
"start_line": 748,
"end_line": 767
}
],
"assumption": false,
"emphasis": "warning"
},
{
"id": "runtime-adapter",
"label": "Runtime Adapter + 404",
"kind": "service",
"role": "sink",
"details": [
"실제 어댑터",
"배포된 백엔드 404 본문"
],
"evidence": [
{
"start_line": 769,
"end_line": 771
}
],
"assumption": false
}
],
"edges": [
{
"id": "screen-gateway",
"from": "screen",
"to": "gateway",
"label": "요청",
"kind": "request",
"evidence": [
{
"start_line": 740,
"end_line": 742
},
{
"start_line": 766,
"end_line": 771
}
],
"assumption": false
},
{
"id": "gateway-root",
"from": "gateway",
"to": "composition-root",
"label": "runtime 조립",
"kind": "request",
"evidence": [
{
"start_line": 766,
"end_line": 771
}
],
"assumption": false
},
{
"id": "root-adapter",
"from": "composition-root",
"to": "runtime-adapter",
"label": "credential 판정",
"kind": "request",
"evidence": [
{
"start_line": 755,
"end_line": 767
},
{
"start_line": 769,
"end_line": 771
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "테스트 두 개를 별도 카드로 다시 그리지 않고 실제 런타임 경로에 각 테스트가 어디에서 스텁되는지 details로 표시했다.",
"layout_note": "선형 4단계라 LR을 유지한다. 테스트가 끊기는 두 지점과 가운데 composition-root 공백을 한 시야에서 비교하는 것이 목적이다."
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,23 +5,28 @@
"question": "계약은 앵커 주소를 적어 두었는데 방문자는 왜 404 를 만났는가?",
"type": "sequence",
"direction": "TB",
"audience": ["백엔드 개발자", "프론트엔드 개발자"],
"audience": [
"백엔드 개발자",
"프론트엔드 개발자"
],
"summary": "만드는 쪽 두 곳이 계약과 다른 슬래시 주소를 만들었고, 그 주소가 게시 시점에 저장돼 방문자에게 그대로 나갔다.",
"alt": "계약, 게시 시점 경로 생성, 저장 테이블, 조회 시점 경로 생성, 방문자, 공개 라우트 여섯 참가자 사이에서 주소가 만들어져 저장되고 방문 시 404 로 끝나는 순서도.",
"long_description": "위에서 아래로 여섯 번의 이동이 있다. 계약 ProjectDecisionItem 은 공개 주소가 decisions#{slug} 앵커라고 규정한다. 게시 시점의 PublicPaths.forKind 는 그 대신 decisions/{slug} 를 만들어 public_resource_projection 에 저장한다. 조회 시점의 PublicSql.pathOf 가 저장된 주소를 읽고 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 projects/{slug}/decisions 하나뿐이라 맞는 라우트가 없고 404 가 돌아온다.",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "marker",
"value": "decision-path-404",
"line": 673
"line": 916
}
},
"composition": {
"profile": "sequence",
"diagram_only": true,
"reference_ids": ["payment-approval-sequence"],
"reference_ids": [
"payment-approval-sequence"
],
"rationale": "본문은 주소가 계약에서 규정되고, 게시 시점에 만들어져 저장되고, 조회 시점에 읽혀 방문자에게 나가고, 방문했을 때 404 가 되는 순서를 적는다. 「주소가 게시 시점에 굳는다」가 이 결함의 핵심이라 시점의 순서가 그림의 뼈대여야 한다."
},
"nodes": [
@@ -31,7 +36,12 @@
"kind": "participant",
"role": "participant",
"description": "공개 주소를 앵커로 규정한 OpenAPI 계약.",
"evidence": [{ "start_line": 676, "end_line": 678 }],
"evidence": [
{
"start_line": 919,
"end_line": 921
}
],
"assumption": false
},
{
@@ -39,10 +49,17 @@
"label": "PublicPaths.forKind",
"kind": "participant",
"role": "participant",
"details": ["게시 시점"],
"details": [
"게시 시점"
],
"emphasis": "warning",
"description": "게시할 때 공개 주소를 만드는 코드.",
"evidence": [{ "start_line": 677, "end_line": 678 }],
"evidence": [
{
"start_line": 919,
"end_line": 921
}
],
"assumption": false
},
{
@@ -51,7 +68,12 @@
"kind": "participant",
"role": "participant",
"description": "만들어진 주소가 저장되는 투영 테이블.",
"evidence": [{ "start_line": 682, "end_line": 683 }],
"evidence": [
{
"start_line": 925,
"end_line": 926
}
],
"assumption": false
},
{
@@ -59,10 +81,17 @@
"label": "PublicSql.pathOf",
"kind": "participant",
"role": "participant",
"details": ["조회 시점"],
"details": [
"조회 시점"
],
"emphasis": "warning",
"description": "조회할 때 공개 주소를 만드는 코드.",
"evidence": [{ "start_line": 677, "end_line": 678 }],
"evidence": [
{
"start_line": 919,
"end_line": 921
}
],
"assumption": false
},
{
@@ -71,7 +100,12 @@
"kind": "actor",
"role": "participant",
"description": "「다음에 읽을 것」 링크를 따라간 사람.",
"evidence": [{ "start_line": 670, "end_line": 671 }],
"evidence": [
{
"start_line": 913,
"end_line": 914
}
],
"assumption": false
},
{
@@ -79,9 +113,16 @@
"label": "공개 라우트",
"kind": "participant",
"role": "participant",
"details": ["/projects/{slug}/decisions 하나뿐"],
"details": [
"/projects/{slug}/decisions 하나뿐"
],
"description": "결정에는 상세 화면이 없어 라우트가 하나뿐이다.",
"evidence": [{ "start_line": 675, "end_line": 676 }],
"evidence": [
{
"start_line": 918,
"end_line": 919
}
],
"assumption": false
}
],
@@ -93,7 +134,12 @@
"label": "…/decisions#{slug} 로 규정",
"kind": "request",
"order": 1,
"evidence": [{ "start_line": 676, "end_line": 678 }],
"evidence": [
{
"start_line": 919,
"end_line": 921
}
],
"assumption": false
},
{
@@ -104,7 +150,16 @@
"kind": "request",
"order": 2,
"emphasis": "warning",
"evidence": [{ "start_line": 675, "end_line": 678 }],
"evidence": [
{
"start_line": 919,
"end_line": 921
},
{
"start_line": 925,
"end_line": 926
}
],
"assumption": false
},
{
@@ -114,7 +169,12 @@
"label": "저장된 주소 조회",
"kind": "response",
"order": 3,
"evidence": [{ "start_line": 682, "end_line": 683 }],
"evidence": [
{
"start_line": 919,
"end_line": 926
}
],
"assumption": false
},
{
@@ -124,7 +184,12 @@
"label": "같은 형태로 링크 전달",
"kind": "response",
"order": 4,
"evidence": [{ "start_line": 677, "end_line": 678 }],
"evidence": [
{
"start_line": 913,
"end_line": 921
}
],
"assumption": false
},
{
@@ -134,7 +199,12 @@
"label": "…/decisions/{slug} 요청",
"kind": "request",
"order": 5,
"evidence": [{ "start_line": 670, "end_line": 675 }],
"evidence": [
{
"start_line": 913,
"end_line": 919
}
],
"assumption": false
},
{
@@ -145,7 +215,12 @@
"kind": "response",
"order": 6,
"emphasis": "warning",
"evidence": [{ "start_line": 670, "end_line": 675 }],
"evidence": [
{
"start_line": 913,
"end_line": 919
}
],
"assumption": false
}
],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,155 @@
{
"version": "1.1",
"id": "record-kind-fanout",
"title": "새 CONCEPT 종류가 세 영역의 손 목록으로 퍼진 구조",
"question": "CONCEPT 하나를 추가했는데 왜 계약·백엔드·프론트엔드 여러 위치를 동시에 고쳐야 했는가?",
"type": "dependency",
"direction": "LR",
"audience": [
"프론트엔드 개발자",
"백엔드 개발자",
"계약 설계자"
],
"summary": "종류를 손으로 나열한 코드가 여러 영역에 흩어져 있어 CONCEPT 하나가 프론트엔드 9곳, 백엔드 1곳, 계약 3곳에서 따로 빠졌다.",
"alt": "새 CONCEPT 노드에서 프론트엔드 손 목록, 백엔드 손 목록, 계약 enum 세 갈래로 퍼지는 팬아웃 그림. 각 갈래에는 실제 누락 건수 9, 1, 3이 적혀 있다.",
"long_description": "왼쪽의 새 CONCEPT가 세 갈래로 퍼진다. 프론트엔드에는 게이트웨이 분기, 매퍼, 필터 같은 손 목록이 아홉 곳 있었고, 백엔드에는 PublicSql.pathOf 한 곳이 빠졌다. 계약에는 CatalogEntry.kind, ResolvedRelation.targetKind, RelatedEntry.type 세 enum 누락이 있었다. 아래 본문 표가 열세 위치를 정확히 나열하고, 그림은 왜 한 종류 변경이 세 영역으로 퍼졌는지만 보여 준다.",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "3. 손으로 나열한 목록이 새 종류를 삼킨다",
"line": 311
}
},
"composition": {
"profile": "component-flow",
"diagram_only": true,
"reference_ids": [
"payment-event-flow"
],
"rationale": "정확한 열세 위치는 이미 표가 있으므로, 그림은 새 종류 하나에서 계약·백엔드·프론트엔드로 퍼지는 의존 방향만 보여 준다.",
"focus_node": "concept"
},
"groups": [],
"nodes": [
{
"id": "concept",
"label": "CONCEPT",
"kind": "message",
"role": "source",
"details": [
"새 Record Kind"
],
"evidence": [
{
"start_line": 316,
"end_line": 331
}
],
"assumption": false,
"emphasis": "primary"
},
{
"id": "frontend",
"label": "Frontend 손 목록",
"kind": "component",
"role": "service",
"details": [
"9곳",
"gateway · mapper · filter"
],
"evidence": [
{
"start_line": 335,
"end_line": 345
}
],
"assumption": false
},
{
"id": "backend",
"label": "Backend 손 목록",
"kind": "component",
"role": "service",
"details": [
"1곳",
"PublicSql.pathOf"
],
"evidence": [
{
"start_line": 345,
"end_line": 349
}
],
"assumption": false
},
{
"id": "contract",
"label": "Contract enums",
"kind": "component",
"role": "sink",
"details": [
"3곳",
"CatalogEntry.kind",
"ResolvedRelation.targetKind",
"RelatedEntry.type"
],
"evidence": [
{
"start_line": 346,
"end_line": 352
}
],
"assumption": false
}
],
"edges": [
{
"id": "to-frontend",
"from": "concept",
"to": "frontend",
"label": "kind 추가",
"kind": "data",
"evidence": [
{
"start_line": 330,
"end_line": 345
}
],
"assumption": false
},
{
"id": "to-backend",
"from": "concept",
"to": "backend",
"label": "kind 추가",
"kind": "data",
"evidence": [
{
"start_line": 345,
"end_line": 349
}
],
"assumption": false
},
{
"id": "to-contract",
"from": "concept",
"to": "contract",
"label": "enum 추가",
"kind": "data",
"evidence": [
{
"start_line": 346,
"end_line": 352
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "13개의 동일한 카드 대신 세 영역으로만 묶는다. 정확한 누락 위치와 증상은 본문 표가 유지한다."
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,198 @@
{
"version": "1.1",
"id": "route-fanout",
"title": "라우트 하나가 건드리는 손 목록과 검출 시점",
"question": "새 라우트 하나가 어디까지 퍼지고, 빠뜨린 항목은 어느 시점에 처음 드러나는가?",
"type": "dependency",
"direction": "LR",
"audience": [
"프론트엔드 개발자",
"배포 파이프라인 유지보수자"
],
"summary": "Route Contract에서 런타임·빌드·CI·nginx 쪽 손 목록으로 갈라지고, 누락은 빌드 매니페스트·배포 직전·배포 뒤 서로 다른 시점에 드러난다.",
"alt": "새 Route가 Route Contract를 거쳐 Runtime 계약, 배포 전 검사, Edge 서빙 세 묶음으로 갈라지는 팬아웃 그림. 각 묶음에는 누락이 처음 드러나는 시점이 적혀 있다.",
"long_description": "왼쪽의 새 Route가 Route Contract로 들어간 뒤 세 갈래로 퍼진다. Runtime 계약에는 runtime 등록과 메시지 카탈로그가 있다. 배포 전 검사에는 vite chunk 이름 표와 접근성 증거·아티팩트 기준선·gate digest가 묶여 있고 누락은 빌드 매니페스트 또는 배포 직전에 드러난다. Edge 서빙 규칙 누락은 하드 로드나 새로고침 때 배포 뒤 404로 드러난다.",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "8. 라우트를 하나 더하면 함께 울리는 손 목록",
"line": 814
}
},
"composition": {
"profile": "query-fanout",
"diagram_only": true,
"reference_ids": [
"metrics-query-fanout"
],
"rationale": "본문의 핵심은 새 Route 하나가 여러 손 목록으로 퍼지는 fan-out이다. 정확한 여덟 위치는 표가 맡고, 그림은 네 소유 묶음과 검출 시점 차이를 보여 준다.",
"focus_node": "route-contract"
},
"groups": [],
"nodes": [
{
"id": "route",
"label": "새 Route",
"kind": "request",
"role": "query",
"evidence": [
{
"start_line": 816,
"end_line": 821
}
],
"assumption": false
},
{
"id": "route-contract",
"label": "Route Contract",
"kind": "component",
"role": "router",
"details": [
"tech-log-route-contract.ts"
],
"evidence": [
{
"start_line": 819,
"end_line": 831
}
],
"assumption": false,
"emphasis": "primary"
},
{
"id": "runtime",
"label": "Runtime 계약",
"kind": "component",
"role": "store",
"details": [
"route-runtime-contract",
"메시지 카탈로그",
"검출: 실행 경로"
],
"evidence": [
{
"start_line": 824,
"end_line": 826
}
],
"assumption": false
},
{
"id": "predeploy",
"label": "배포 전 검사",
"kind": "component",
"role": "store",
"details": [
"vite chunk 표 · 빌드 매니페스트",
"접근성 증거 · 아티팩트 기준선 · gate digest",
"검출: 빌드 / 배포 직전"
],
"evidence": [
{
"start_line": 828,
"end_line": 831
},
{
"start_line": 854,
"end_line": 877
}
],
"assumption": false
},
{
"id": "nginx",
"label": "Edge serving",
"kind": "component",
"role": "store",
"details": [
"nginx serving contract",
"검출: 배포 뒤 404"
],
"evidence": [
{
"start_line": 827,
"end_line": 827
},
{
"start_line": 834,
"end_line": 852
}
],
"assumption": false
}
],
"edges": [
{
"id": "register",
"from": "route",
"to": "route-contract",
"label": "등록",
"kind": "request",
"evidence": [
{
"start_line": 819,
"end_line": 831
}
],
"assumption": false
},
{
"id": "runtime-edge",
"from": "route-contract",
"to": "runtime",
"label": "반영",
"kind": "data",
"evidence": [
{
"start_line": 824,
"end_line": 826
}
],
"assumption": false
},
{
"id": "predeploy-edge",
"from": "route-contract",
"to": "predeploy",
"label": "대조",
"kind": "data",
"evidence": [
{
"start_line": 828,
"end_line": 831
},
{
"start_line": 854,
"end_line": 877
}
],
"assumption": false
},
{
"id": "nginx-edge",
"from": "route-contract",
"to": "nginx",
"label": "서빙 패턴",
"kind": "data",
"evidence": [
{
"start_line": 827,
"end_line": 827
},
{
"start_line": 834,
"end_line": 852
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "8개 항목을 같은 카드로 반복하지 않고, 독자가 먼저 알아야 할 fan-out과 검출 시점만 묶었다. 정확한 항목 수와 커밋별 수치는 본문 표에 남긴다.",
"layout_note": "세 fan-out 가지를 한 화면에 유지한다. 9px shared-edge-run advisory가 남더라도 의미상 간선이 다른 노드를 통과하지 않는지 overlap 검사와 PNG preview에서 재확인한다."
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,153 @@
{
"version": "1.1",
"id": "summary-drop-path",
"title": "summary가 세 경계에서 사라진 경로",
"question": "계약과 DB에 있던 summary가 공개 relation 목록까지 오지 못한 세 유실 지점은 어디였는가?",
"type": "data-flow",
"direction": "LR",
"audience": [
"프론트엔드 개발자",
"계약 설계자"
],
"summary": "계약에는 summary가 있었지만 flattenRelations가 담지 않았고, ResolvedRelation에는 칸이 없었고, 화면 목록으로 넘길 때 다시 버렸다.",
"alt": "Contract summary에서 flattenRelations, ResolvedRelation, 화면 목록으로 이어지는 흐름. 세 중간 지점에 DROP 1, 칸 없음, DROP 3이 표시되어 있다.",
"long_description": "왼쪽 Contract에는 summary가 있다. flattenRelations가 그 값을 담지 않아 첫 번째로 끊긴다. 다음 ResolvedRelation 계약에는 summary 칸 자체가 없어 두 번째로 막힌다. 그 칸을 추가한 뒤에도 화면 목록으로 넘길 때 값을 버려 세 번째로 끊겼다. 최종 수정에서는 세 경계를 모두 이어 공개 relation 목록까지 summary가 도착하게 했다.",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "5. 계약에 자리가 없어 값이 경계에서 사라진다",
"line": 516
}
},
"composition": {
"profile": "component-flow",
"diagram_only": true,
"reference_ids": [
"payment-event-flow"
],
"rationale": "본문 자체가 계약에서 화면 목록까지 summary가 지나가는 순서를 네 단계로 적고 세 유실 지점을 명시한다.",
"focus_node": "resolved-relation"
},
"groups": [],
"nodes": [
{
"id": "contract",
"label": "Contract summary",
"kind": "message",
"role": "source",
"details": [
"summary 있음"
],
"evidence": [
{
"start_line": 540,
"end_line": 547
}
],
"assumption": false
},
{
"id": "flatten",
"label": "flattenRelations",
"kind": "component",
"role": "service",
"details": [
"DROP #1"
],
"evidence": [
{
"start_line": 544,
"end_line": 547
}
],
"assumption": false,
"emphasis": "warning"
},
{
"id": "resolved-relation",
"label": "ResolvedRelation",
"kind": "component",
"role": "service",
"details": [
"summary 칸 없음",
"additionalProperties: false"
],
"evidence": [
{
"start_line": 546,
"end_line": 552
}
],
"assumption": false,
"emphasis": "warning"
},
{
"id": "screen-list",
"label": "화면 relation 목록",
"kind": "component",
"role": "sink",
"details": [
"DROP #3"
],
"evidence": [
{
"start_line": 547,
"end_line": 552
}
],
"assumption": false,
"emphasis": "warning"
}
],
"edges": [
{
"id": "to-flatten",
"from": "contract",
"to": "flatten",
"label": "summary",
"kind": "data",
"evidence": [
{
"start_line": 544,
"end_line": 545
}
],
"assumption": false
},
{
"id": "to-model",
"from": "flatten",
"to": "resolved-relation",
"label": "렌더 모델",
"kind": "data",
"evidence": [
{
"start_line": 545,
"end_line": 550
}
],
"assumption": false
},
{
"id": "to-screen",
"from": "resolved-relation",
"to": "screen-list",
"label": "화면 전달",
"kind": "data",
"evidence": [
{
"start_line": 546,
"end_line": 552
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "상위 Concept의 11경계 그림과 겹치지 않도록 이번 사고에서 실제로 summary가 끊긴 세 지점만 그린다.",
"layout_note": "네 경계를 한 줄로 따라가는 사고 경로라 LR을 유지한다. 이 그림은 전체 11경계가 아니라 세 유실 지점만 좁힌다."
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,12 +13,12 @@
"alt": "왼쪽부터 topic, topic_variant, record_variant 로 이어지고 record_variant 가 document·open_question·project_decision 세 테이블을 가리키는 구조도.",
"long_description": "왼쪽에 topic 이 있고 variant_label 로 축의 이름을 스스로 정한다. 그 오른쪽에 topic_variant 가 있고 SPA, Mediator, BFF, Forward-Auth 같은 축의 값들을 담는다. 그 오른쪽에 record_variant 가 있고 어느 기록이 어느 축에 걸리는지를 종류와 아이디의 쌍으로 적는다. record_variant 는 오른쪽의 document, open_question, project_decision 세 테이블을 가리키는데, 기록이 종류마다 다른 테이블에 살기 때문에 외래키를 걸지 못하고 쌍으로만 가리킨다.",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "marker",
"value": "topic-variant-model",
"line": 1064
"line": 1375
}
},
"composition": {
@@ -38,8 +38,8 @@
"role": "zone",
"evidence": [
{
"start_line": 1071,
"end_line": 1073
"start_line": 1382,
"end_line": 1384
}
],
"assumption": false
@@ -58,12 +58,12 @@
"description": "주제. 축의 이름을 주제가 정한다.",
"evidence": [
{
"start_line": 1057,
"end_line": 1059
"start_line": 1368,
"end_line": 1370
},
{
"start_line": 1067,
"end_line": 1068
"start_line": 1377,
"end_line": 1379
}
],
"assumption": false
@@ -80,12 +80,12 @@
"description": "축의 값들.",
"evidence": [
{
"start_line": 1060,
"end_line": 1060
"start_line": 1371,
"end_line": 1371
},
{
"start_line": 1047,
"end_line": 1048
"start_line": 1358,
"end_line": 1359
}
],
"assumption": false
@@ -103,12 +103,12 @@
"description": "어느 기록이 어느 축에 걸리는지 적는 자리. 외래키를 걸지 못한다.",
"evidence": [
{
"start_line": 1061,
"end_line": 1061
"start_line": 1372,
"end_line": 1372
},
{
"start_line": 1071,
"end_line": 1073
"start_line": 1382,
"end_line": 1384
}
],
"assumption": false
@@ -123,8 +123,8 @@
"description": "기록 테이블 하나.",
"evidence": [
{
"start_line": 1071,
"end_line": 1072
"start_line": 1382,
"end_line": 1384
}
],
"assumption": false
@@ -139,8 +139,8 @@
"description": "기록 테이블 하나.",
"evidence": [
{
"start_line": 1071,
"end_line": 1072
"start_line": 1382,
"end_line": 1384
}
],
"assumption": false
@@ -155,8 +155,8 @@
"description": "기록 테이블 하나.",
"evidence": [
{
"start_line": 1071,
"end_line": 1072
"start_line": 1382,
"end_line": 1384
}
],
"assumption": false
@@ -172,8 +172,8 @@
"style": "solid",
"evidence": [
{
"start_line": 1057,
"end_line": 1060
"start_line": 1368,
"end_line": 1371
}
],
"assumption": false
@@ -187,8 +187,8 @@
"style": "solid",
"evidence": [
{
"start_line": 1060,
"end_line": 1061
"start_line": 1371,
"end_line": 1372
}
],
"assumption": false
@@ -202,8 +202,8 @@
"style": "dashed",
"evidence": [
{
"start_line": 1061,
"end_line": 1073
"start_line": 1372,
"end_line": 1384
}
],
"assumption": false
@@ -217,8 +217,8 @@
"style": "dashed",
"evidence": [
{
"start_line": 1061,
"end_line": 1073
"start_line": 1372,
"end_line": 1384
}
],
"assumption": false
@@ -232,8 +232,8 @@
"style": "dashed",
"evidence": [
{
"start_line": 1061,
"end_line": 1073
"start_line": 1372,
"end_line": 1384
}
],
"assumption": false
@@ -249,4 +249,4 @@
"rationale": "축에 걸리지 않은 기록이 공통 기록이 된다는 규칙과 editorial 칸(thesis·summary·conclusion) 이야기는 같은 절의 문장으로 남긴다. 그림은 자리와 참조 방향만 담는다.",
"profile_deviation": "techviz references 가 고른 후보(two-zone-pipeline, sequence, comparison) 밖의 프로필이다. 이 절에는 시간 순서도 두 구역도 비교 대상도 없어 후보로는 그릴 수 없었다."
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,15 +11,15 @@
"아키텍처 검토자"
],
"summary": "열한 개의 경계를 지나는 자리별로 묶으면 저장 둘, 백엔드 조립 넷, 전선 하나, 프론트엔드 조립 셋, 화면 하나다.",
"alt": "저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 tech-log-backend·전선·tech-log-frontend 구역으로 나눠 이은 흐름도. 묶음마다 그 안에 든 경계 수가 2·4·1·3·1 로 적혀 있다.",
"alt": "열한 개 경계를 저장·백엔드 조립·전선·프론트엔드 조립·화면 다섯 구간으로 묶고, tech-log-backend·HTTP·tech-log-frontend 소유 구역을 함께 표시한 흐름도.",
"long_description": "왼쪽에서 오른쪽으로 읽는다. tech-log-backend 구역에 저장 묶음과 백엔드 조립 묶음이 있고 각각 경계 둘과 넷을 담는다. 전선 구역에는 HTTP envelope 하나가 있다. tech-log-frontend 구역에는 프론트엔드 조립 묶음과 화면 컴포넌트가 있고 각각 경계 셋과 하나다. 다 더하면 열한 개이고, 각 경계의 이름은 그림 위 목록에 있다.",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "marker",
"value": "value-boundaries",
"line": 82
"line": 85
}
},
"composition": {
@@ -235,6 +235,7 @@
"metadata": {
"rationale": "각 경계의 이름은 바로 위 목록이 이미 순서대로 적는다. 그림은 그 열한 개가 어느 소유 구역에 몇 개씩 놓이는지만 담는다.",
"profile_deviation": "techviz references 가 고른 후보 밖의 프로필이다. 후보 셋으로는 사슬을 그릴 수 없어 component-flow 로 갔고 lint 는 0 error 로 통과했다.",
"layout_note": "LR 로 둔다. TB 는 aspect-ratio 경고를 없애지만 그룹 이름이 잘리고(tech-log-fro) 오른쪽이 비어, 읽기에는 LR 이 낫다. 남는 경고는 advisory 다."
"layout_note": "LR 로 둔다. TB 는 aspect-ratio 경고를 없애지만 그룹 이름이 잘리고(tech-log-fro) 오른쪽이 비어, 읽기에는 LR 이 낫다. 남는 경고는 advisory 다.",
"advisory_acceptance": "LR aspect-ratio advisory를 허용한다. TB로 바꾸면 그룹 이름이 잘리고 소유 구역 비교가 더 어려워져, 값의 진행 방향과 세 소유 구역을 한 줄에 유지하는 편이 독해에 낫다."
}
}
}
@@ -0,0 +1,22 @@
# 테스트가 끊긴 곳과 실제 런타임이 지나는 합성 루트
## Alternative text
화면, 게이트웨이, 합성 루트의 credential 결정, 실제 런타임 어댑터가 이어진 경로. 화면에는 게이트웨이 스텁, 게이트웨이에는 실행기 스텁이 표시되고 합성 루트가 테스트 공백으로 강조되어 있다.
## Long description
왼쪽에서 오른쪽으로 실제 런타임 경로를 읽는다. 화면에서 게이트웨이로 가고 합성 루트에서 credential을 결정한 뒤 실제 런타임 어댑터가 배포된 백엔드의 실제 404 본문을 읽는다. 화면 테스트는 게이트웨이를 스텁하고 게이트웨이 테스트는 실행기를 스텁했기 때문에 가운데 합성 루트의 credential 결정은 두 테스트가 지나지 않았다.
## Elements and evidence
- **화면** (component): No additional description. Evidence: L740L742, L770L771.
- **Gateway** (service): No additional description. Evidence: L766L771.
- **Composition Root** (component): No additional description. Evidence: L748L767.
- **Runtime Adapter + 404** (service): No additional description. Evidence: L769L771.
## Relationships
- **Gateway → Composition Root:** runtime 조립. Evidence: L766L771.
- **Composition Root → Runtime Adapter + 404:** credential 판정. Evidence: L755L767, L769L771.
- **화면 → Gateway:** 요청. Evidence: L740L742, L766L771.
@@ -0,0 +1,18 @@
# 테스트가 끊긴 곳과 실제 런타임이 지나는 합성 루트
# Question: 게이트웨이 테스트와 화면 테스트가 통과했는데 왜 합성 루트의 credential 결함은 운영에서만 드러났는가?
direction: right
n0: "화면" {
shape: rectangle
}
n1: "Gateway" {
shape: rectangle
}
n2: "Composition Root" {
shape: rectangle
}
n3: "Runtime Adapter + 404" {
shape: rectangle
}
n0 -> n1: "요청"
n1 -> n2: "runtime 조립"
n2 -> n3: "credential 판정"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="화면", shape=box, style="rounded,filled"];
n1 [label="Gateway", shape=box, style="rounded,filled"];
n2 [label="Composition Root", shape=box, style="rounded,filled"];
n3 [label="Runtime Adapter + 404", shape=box, style="rounded,filled"];
n0 -> n1 [label="요청", style=solid];
n1 -> n2 [label="runtime 조립", style=solid];
n2 -> n3 [label="credential 판정", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="composition-root-seam" name="테스트가 끊긴 곳과 실제 런타임이 지나는 합성 루트">
<mxGraphModel dx="1333" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1333" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_screen" value="화면&lt;br/&gt;화면 테스트: gateway stub" tooltip="component | Evidence: L740-L742, L770-L771" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="68.5" width="174.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_gateway" value="Gateway&lt;br/&gt;gateway 테스트: executor stub" tooltip="service | Evidence: L766-L771" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="404.0" y="68.5" width="216.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_composition-root" value="Composition Root&lt;br/&gt;credential decision&lt;br/&gt;테스트 공백" tooltip="component | Evidence: L748-L767" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="780.0" y="60.0" width="167.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_runtime-adapter" value="Runtime Adapter + 404&lt;br/&gt;실제 어댑터&lt;br/&gt;배포된 백엔드 404 본문" tooltip="service | Evidence: L769-L771" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1107.0" y="60.0" width="181.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="e_gateway-root" value="runtime 조립" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_gateway" target="n_composition-root">
<mxGeometry relative="1" as="geometry">
<mxPoint x="700.0" y="76.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_root-adapter" value="credential 판정" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_composition-root" target="n_runtime-adapter">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1027.0" y="76.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_screen-gateway" value="요청" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_screen" target="n_gateway">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="76.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-gateway-root",
"type": "arrow",
"x": 620.0,
"y": 104.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 327602906,
"version": 1,
"versionNonce": 1078118842,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-gateway",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-composition-root",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-gateway-root",
"type": "text",
"x": 655.0,
"y": 64.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1007189003,
"version": 1,
"versionNonce": 80325236,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "runtime 조립",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "runtime 조립",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-root-adapter",
"type": "arrow",
"x": 947.0,
"y": 104.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 358009000,
"version": 1,
"versionNonce": 1415432956,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-composition-root",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-runtime-adapter",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-root-adapter",
"type": "text",
"x": 975.0,
"y": 64.0,
"width": 104,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1363224969,
"version": 1,
"versionNonce": 878347516,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "credential 판정",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "credential 판정",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-screen-gateway",
"type": "arrow",
"x": 244.0,
"y": 104.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1497458713,
"version": 1,
"versionNonce": 208440406,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-screen",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-gateway",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-screen-gateway",
"type": "text",
"x": 279.0,
"y": 64.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1813770200,
"version": 1,
"versionNonce": 1860428082,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "요청",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "요청",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-screen",
"type": "rectangle",
"x": 70.0,
"y": 68.5,
"width": 174.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1341250523,
"version": 1,
"versionNonce": 1431512902,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-screen",
"type": "text",
"x": 80.0,
"y": 78.5,
"width": 154.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1751546587,
"version": 1,
"versionNonce": 1327173583,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "화면\n화면 테스트: gateway stub",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "화면\n화면 테스트: gateway stub",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-gateway",
"type": "rectangle",
"x": 404.0,
"y": 68.5,
"width": 216.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1264474515,
"version": 1,
"versionNonce": 568841533,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-gateway",
"type": "text",
"x": 414.0,
"y": 78.5,
"width": 196.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 222918616,
"version": 1,
"versionNonce": 1589548629,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Gateway\ngateway 테스트: executor stub",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Gateway\ngateway 테스트: executor stub",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-composition-root",
"type": "rectangle",
"x": 780.0,
"y": 60.0,
"width": 167.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1480993774,
"version": 1,
"versionNonce": 95918647,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-composition-root",
"type": "text",
"x": 790.0,
"y": 70.0,
"width": 147.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1973216795,
"version": 1,
"versionNonce": 169159091,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Composition Root\ncredential decision\n테스트 공백",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Composition Root\ncredential decision\n테스트 공백",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-runtime-adapter",
"type": "rectangle",
"x": 1107.0,
"y": 60.0,
"width": 181.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1916862381,
"version": 1,
"versionNonce": 1065856754,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-runtime-adapter",
"type": "text",
"x": 1117.0,
"y": 70.0,
"width": 161.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 38827956,
"version": 1,
"versionNonce": 780247791,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Runtime Adapter + 404\n실제 어댑터\n배포된 백엔드 404 본문",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Runtime Adapter + 404\n실제 어댑터\n배포된 백엔드 404 본문",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "composition-root-seam",
"spec_version": "1.1",
"spec_sha256": "c0a7d549c0473287614576b8474c3a21ac594de835dad9ed9dc9383e01b7b5e0",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "7. 테스트가 지나지 않는 이음매",
"line": 690
}
},
"outputs": [
"composition-root-seam.svg",
"composition-root-seam.drawio",
"composition-root-seam.mmd",
"composition-root-seam.d2",
"composition-root-seam.dot",
"composition-root-seam.excalidraw",
"composition-root-seam.alt.md"
],
"lint_issue_count": 1,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 테스트가 끊긴 곳과 실제 런타임이 지나는 합성 루트
%% question: 게이트웨이 테스트와 화면 테스트가 통과했는데 왜 합성 루트의 credential 결함은 운영에서만 드러났는가?
flowchart LR
n0["화면"]
n1["Gateway"]
n2["Composition Root"]
n3["Runtime Adapter + 404"]
n0 -->|"요청"| n1
n1 -->|"runtime 조립"| n2
n2 -->|"credential 판정"| n3
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1333" height="300" viewBox="0 0 1333 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">테스트가 끊긴 곳과 실제 런타임이 지나는 합성 루트</title>
<desc id="diagram-description">왼쪽에서 오른쪽으로 실제 런타임 경로를 읽는다. 화면에서 게이트웨이로 가고 합성 루트에서 credential을 결정한 뒤 실제 런타임 어댑터가 배포된 백엔드의 실제 404 본문을 읽는다. 화면 테스트는 게이트웨이를 스텁하고 게이트웨이 테스트는 실행기를 스텁했기 때문에 가운데 합성 루트의 credential 결정은 두 테스트가 지나지 않았다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;composition-root-seam&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;7. 테스트가 지나지 않는 이음매&quot;,&quot;line&quot;:690}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1333" height="300" />
<polyline class="edge kind-request style-solid emphasis-normal" points="620.0,104.0 700.0,104.0 700.0,104.0 780.0,104.0" data-evidence="766-771" />
<rect class="edge-label-bg" x="657.5" y="62.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="700.0" y="77.0">runtime 조립</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="947.0,104.0 1027.0,104.0 1027.0,104.0 1107.0,104.0" data-evidence="755-767,769-771" />
<rect class="edge-label-bg" x="974.5" y="62.0" width="105.1" height="22" rx="3" />
<text class="edge-label" x="1027.0" y="77.0">credential 판정</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="244.0,104.0 324.0,104.0 324.0,104.0 404.0,104.0" data-evidence="740-742,766-771" />
<rect class="edge-label-bg" x="302.0" y="62.0" width="44.0" height="22" rx="3" />
<text class="edge-label" x="324.0" y="77.0">요청</text>
<g id="node-screen">
<rect class="node-shape kind-component emphasis-normal role-source" data-evidence="740-742,770-771" x="70.0" y="68.5" width="174.0" height="71.0" rx="7" />
<text class="node-label" x="157.0" y="95.5">화면</text>
<line class="node-detail-divider" x1="84.0" y1="116.5" x2="230.0" y2="116.5" />
<text class="node-detail" x="86.0" y="133.5">화면 테스트: gateway stub</text>
</g>
<g id="node-gateway">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="766-771" x="404.0" y="68.5" width="216.0" height="71.0" rx="7" />
<text class="node-label" x="512.0" y="95.5">Gateway</text>
<line class="node-detail-divider" x1="418.0" y1="116.5" x2="606.0" y2="116.5" />
<text class="node-detail" x="420.0" y="133.5">gateway 테스트: executor stub</text>
</g>
<g id="node-composition-root">
<rect class="node-shape kind-component emphasis-warning role-service" data-evidence="748-767" x="780.0" y="60.0" width="167.0" height="88.0" rx="7" />
<text class="node-label" x="863.5" y="87.0">Composition Root</text>
<line class="node-detail-divider" x1="794.0" y1="108.0" x2="933.0" y2="108.0" />
<text class="node-detail" x="796.0" y="125.0">credential decision</text>
<text class="node-detail" x="796.0" y="141.0">테스트 공백</text>
</g>
<g id="node-runtime-adapter">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="769-771" x="1107.0" y="60.0" width="181.0" height="88.0" rx="7" />
<text class="node-label" x="1197.5" y="87.0">Runtime Adapter + 404</text>
<line class="node-detail-divider" x1="1121.0" y1="108.0" x2="1274.0" y2="108.0" />
<text class="node-detail" x="1123.0" y="125.0">실제 어댑터</text>
<text class="node-detail" x="1123.0" y="141.0">배포된 백엔드 404 본문</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -10,18 +10,18 @@
## Elements and evidence
- **계약 ProjectDecisionItem** (participant): 공개 주소를 앵커로 규정한 OpenAPI 계약. Evidence: L676L678.
- **PublicPaths.forKind** (participant): 게시할 때 공개 주소를 만드는 코드. Evidence: L677L678.
- **public_resource_projection** (participant): 만들어진 주소가 저장되는 투영 테이블. Evidence: L682L683.
- **PublicSql.pathOf** (participant): 조회할 때 공개 주소를 만드는 코드. Evidence: L677L678.
- **방문자** (actor): 「다음에 읽을 것」 링크를 따라간 사람. Evidence: L670L671.
- **공개 라우트** (participant): 결정에는 상세 화면이 없어 라우트가 하나뿐이다. Evidence: L675L676.
- **계약 ProjectDecisionItem** (participant): 공개 주소를 앵커로 규정한 OpenAPI 계약. Evidence: L919L921.
- **PublicPaths.forKind** (participant): 게시할 때 공개 주소를 만드는 코드. Evidence: L919L921.
- **public_resource_projection** (participant): 만들어진 주소가 저장되는 투영 테이블. Evidence: L925L926.
- **PublicSql.pathOf** (participant): 조회할 때 공개 주소를 만드는 코드. Evidence: L919L921.
- **방문자** (actor): 「다음에 읽을 것」 링크를 따라간 사람. Evidence: L913L914.
- **공개 라우트** (participant): 결정에는 상세 화면이 없어 라우트가 하나뿐이다. Evidence: L918L919.
## Relationships
- **계약 ProjectDecisionItem → PublicPaths.forKind:** …/decisions#{slug} 로 규정. Evidence: L676L678.
- **PublicPaths.forKind → public_resource_projection:** …/decisions/{slug} 저장. Evidence: L675L678.
- **public_resource_projection → PublicSql.pathOf:** 저장된 주소 조회. Evidence: L682L683.
- **PublicSql.pathOf → 방문자:** 같은 형태로 링크 전달. Evidence: L677L678.
- **방문자 → 공개 라우트:** …/decisions/{slug} 요청. Evidence: L670L675.
- **공개 라우트 → 방문자:** 404. Evidence: L670L675.
- **계약 ProjectDecisionItem → PublicPaths.forKind:** …/decisions#{slug} 로 규정. Evidence: L919L921.
- **PublicPaths.forKind → public_resource_projection:** …/decisions/{slug} 저장. Evidence: L919L921, L925L926.
- **public_resource_projection → PublicSql.pathOf:** 저장된 주소 조회. Evidence: L919L926.
- **PublicSql.pathOf → 방문자:** 같은 형태로 링크 전달. Evidence: L913L921.
- **방문자 → 공개 라우트:** …/decisions/{slug} 요청. Evidence: L913L919.
- **공개 라우트 → 방문자:** 404. Evidence: L913L919.
@@ -5,22 +5,22 @@
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_contract" value="계약 ProjectDecisionItem" tooltip="공개 주소를 앵커로 규정한 OpenAPI 계약. | Evidence: L676-L678" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_contract" value="계약 ProjectDecisionItem" tooltip="공개 주소를 앵커로 규정한 OpenAPI 계약. | Evidence: L919-L921" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="45.0" y="35.0" width="188.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_publish-path" value="PublicPaths.forKind&lt;br/&gt;게시 시점" tooltip="게시할 때 공개 주소를 만드는 코드. | Evidence: L677-L678" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxCell id="n_publish-path" value="PublicPaths.forKind&lt;br/&gt;게시 시점" tooltip="게시할 때 공개 주소를 만드는 코드. | Evidence: L919-L921" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="255.0" y="35.0" width="167.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_projection" value="public_resource_projection" tooltip="만들어진 주소가 저장되는 투영 테이블. | Evidence: L682-L683" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_projection" value="public_resource_projection" tooltip="만들어진 주소가 저장되는 투영 테이블. | Evidence: L925-L926" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="465.0" y="35.0" width="190.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_read-path" value="PublicSql.pathOf&lt;br/&gt;조회 시점" tooltip="조회할 때 공개 주소를 만드는 코드. | Evidence: L677-L678" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxCell id="n_read-path" value="PublicSql.pathOf&lt;br/&gt;조회 시점" tooltip="조회할 때 공개 주소를 만드는 코드. | Evidence: L919-L921" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="675.0" y="35.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_visitor" value="방문자" tooltip="「다음에 읽을 것」 링크를 따라간 사람. | Evidence: L670-L671" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxCell id="n_visitor" value="방문자" tooltip="「다음에 읽을 것」 링크를 따라간 사람. | Evidence: L913-L914" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxGeometry x="885.0" y="35.0" width="150.0" height="78.0" as="geometry"/>
</mxCell>
<mxCell id="n_public-route" value="공개 라우트&lt;br/&gt;/projects/{slug}/decisions 하나뿐" tooltip="결정에는 상세 화면이 없어 라우트가 하나뿐이다. | Evidence: L675-L676" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_public-route" value="공개 라우트&lt;br/&gt;/projects/{slug}/decisions 하나뿐" tooltip="결정에는 상세 화면이 없어 라우트가 하나뿐이다. | Evidence: L918-L919" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1095.0" y="35.0" width="190.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_m1" value="…/decisions#{slug} 로 규정" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_contract" target="n_publish-path">
@@ -2,22 +2,22 @@
"harness_version": "0.2.0",
"spec_id": "decision-path-404",
"spec_version": "1.1",
"spec_sha256": "277ea0b16980c58b052895bdd09c2dcc15aa24b33173785c3b9b37d6888e0daf",
"spec_sha256": "ea5cad43e1a5d76650bbe4bd30ce3bc070c30aa2bf778fbf7b2a81cee8e6c4e5",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "marker",
"value": "decision-path-404",
"line": 673
"line": 916
}
},
"outputs": [
"decision-path-404.svg",
"decision-path-404.drawio",
"decision-path-404.mmd",
"decision-path-404.d2",
"decision-path-404.dot",
"decision-path-404.drawio",
"decision-path-404.excalidraw",
"decision-path-404.alt.md"
],
@@ -2,7 +2,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1330" height="542" viewBox="0 0 1330 542" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">결정 주소가 게시 시점에 굳어져 방문자가 404 를 만나기까지</title>
<desc id="diagram-description">위에서 아래로 여섯 번의 이동이 있다. 계약 ProjectDecisionItem 은 공개 주소가 decisions#{slug} 앵커라고 규정한다. 게시 시점의 PublicPaths.forKind 는 그 대신 decisions/{slug} 를 만들어 public_resource_projection 에 저장한다. 조회 시점의 PublicSql.pathOf 가 저장된 주소를 읽고 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 projects/{slug}/decisions 하나뿐이라 맞는 라우트가 없고 404 가 돌아온다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;decision-path-404&quot;,&quot;profile&quot;:&quot;sequence&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;decision-path-404&quot;,&quot;line&quot;:673}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;decision-path-404&quot;,&quot;profile&quot;:&quot;sequence&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;decision-path-404&quot;,&quot;line&quot;:916}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
@@ -50,48 +50,48 @@
</style>
</defs>
<rect class="canvas" width="1330" height="542" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="676-678" x="45.0" y="35.0" width="188.0" height="64.0" rx="7" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="919-921" x="45.0" y="35.0" width="188.0" height="64.0" rx="7" />
<text class="node-label" x="139.0" y="65.0">계약 ProjectDecisionItem</text>
<line class="lifeline" x1="139.0" y1="99.0" x2="139.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-warning role-participant" data-evidence="677-678" x="255.0" y="35.0" width="167.0" height="71.0" rx="7" />
<rect class="node-shape kind-participant emphasis-warning role-participant" data-evidence="919-921" x="255.0" y="35.0" width="167.0" height="71.0" rx="7" />
<text class="node-label" x="338.5" y="62.0">PublicPaths.forKind</text>
<line class="node-detail-divider" x1="269.0" y1="83.0" x2="408.0" y2="83.0" />
<text class="node-detail" x="271.0" y="100.0">게시 시점</text>
<line class="lifeline" x1="338.5" y1="106.0" x2="338.5" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="682-683" x="465.0" y="35.0" width="190.0" height="64.0" rx="7" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="925-926" x="465.0" y="35.0" width="190.0" height="64.0" rx="7" />
<text class="node-label" x="560.0" y="65.0">public_resource_projection</text>
<line class="lifeline" x1="560.0" y1="99.0" x2="560.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-warning role-participant" data-evidence="677-678" x="675.0" y="35.0" width="150.0" height="71.0" rx="7" />
<rect class="node-shape kind-participant emphasis-warning role-participant" data-evidence="919-921" x="675.0" y="35.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="750.0" y="62.0">PublicSql.pathOf</text>
<line class="node-detail-divider" x1="689.0" y1="83.0" x2="811.0" y2="83.0" />
<text class="node-detail" x="691.0" y="100.0">조회 시점</text>
<line class="lifeline" x1="750.0" y1="106.0" x2="750.0" y2="512.0" />
<g class="actor-symbol emphasis-normal" data-evidence="670-671"><circle cx="960.0" cy="55.0" r="11.0" /><line x1="960.0" y1="71.0" x2="960.0" y2="74.0" /><line x1="942.0" y1="81.0" x2="978.0" y2="81.0" /><line x1="960.0" y1="74.0" x2="945.0" y2="91.0" /><line x1="960.0" y1="74.0" x2="975.0" y2="91.0" /></g>
<g class="actor-symbol emphasis-normal" data-evidence="913-914"><circle cx="960.0" cy="55.0" r="11.0" /><line x1="960.0" y1="71.0" x2="960.0" y2="84.0" /><line x1="942.0" y1="81.0" x2="978.0" y2="81.0" /><line x1="960.0" y1="84.0" x2="945.0" y2="101.0" /><line x1="960.0" y1="84.0" x2="975.0" y2="101.0" /></g>
<text class="node-label" x="960.0" y="106.0">방문자</text>
<line class="lifeline" x1="960.0" y1="113.0" x2="960.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="675-676" x="1095.0" y="35.0" width="190.0" height="71.0" rx="7" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="918-919" x="1095.0" y="35.0" width="190.0" height="71.0" rx="7" />
<text class="node-label" x="1190.0" y="62.0">공개 라우트</text>
<line class="node-detail-divider" x1="1109.0" y1="83.0" x2="1271.0" y2="83.0" />
<text class="node-detail" x="1111.0" y="100.0">/projects/{slug}/decisions 하나뿐</text>
<line class="lifeline" x1="1190.0" y1="106.0" x2="1190.0" y2="512.0" />
<polyline class="edge kind-request style-solid emphasis-normal" points="139.0,140.0 338.5,140.0" data-evidence="676-678" />
<polyline class="edge kind-request style-solid emphasis-normal" points="139.0,140.0 338.5,140.0" data-evidence="919-921" />
<rect class="edge-label-bg" x="142.6" y="114.0" width="192.2" height="22" rx="3" />
<text class="edge-label" x="238.8" y="129.0">1. …/decisions#{slug} 로 규정</text>
<polyline class="edge kind-request style-solid emphasis-warning" points="338.5,202.0 560.0,202.0" data-evidence="675-678" />
<polyline class="edge kind-request style-solid emphasis-warning" points="338.5,202.0 560.0,202.0" data-evidence="919-921,925-926" />
<rect class="edge-label-bg" x="359.9" y="176.0" width="178.8" height="22" rx="3" />
<text class="edge-label" x="449.2" y="191.0">2. …/decisions/{slug} 저장</text>
<line class="failure-mark" x1="547.0" y1="189.0" x2="573.0" y2="215.0" />
<line class="failure-mark" x1="573.0" y1="189.0" x2="547.0" y2="215.0" />
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="560.0,264.0 750.0,264.0" data-evidence="682-683" />
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="560.0,264.0 750.0,264.0" data-evidence="919-926" />
<rect class="edge-label-bg" x="605.8" y="238.0" width="98.4" height="22" rx="3" />
<text class="edge-label" x="655.0" y="253.0">3. 저장된 주소 조회</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="750.0,326.0 960.0,326.0" data-evidence="677-678" />
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="750.0,326.0 960.0,326.0" data-evidence="913-921" />
<rect class="edge-label-bg" x="795.8" y="300.0" width="118.5" height="22" rx="3" />
<text class="edge-label" x="855.0" y="315.0">4. 같은 형태로 링크 전달</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="960.0,388.0 1190.0,388.0" data-evidence="670-675" />
<polyline class="edge kind-request style-solid emphasis-normal" points="960.0,388.0 1190.0,388.0" data-evidence="913-919" />
<rect class="edge-label-bg" x="985.6" y="362.0" width="178.8" height="22" rx="3" />
<text class="edge-label" x="1075.0" y="377.0">5. …/decisions/{slug} 요청</text>
<polyline class="edge kind-response style-solid emphasis-warning semantic-dashed" points="1190.0,450.0 960.0,450.0" data-evidence="670-675" />
<polyline class="edge kind-response style-solid emphasis-warning semantic-dashed" points="1190.0,450.0 960.0,450.0" data-evidence="913-919" />
<rect class="edge-label-bg" x="1045.9" y="424.0" width="58.2" height="22" rx="3" />
<text class="edge-label" x="1075.0" y="439.0">6. 404</text>
<line class="failure-mark" x1="947.0" y1="437.0" x2="973.0" y2="463.0" />

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

@@ -0,0 +1,22 @@
# 새 CONCEPT 종류가 세 영역의 손 목록으로 퍼진 구조
## Alternative text
새 CONCEPT 노드에서 프론트엔드 손 목록, 백엔드 손 목록, 계약 enum 세 갈래로 퍼지는 팬아웃 그림. 각 갈래에는 실제 누락 건수 9, 1, 3이 적혀 있다.
## Long description
왼쪽의 새 CONCEPT가 세 갈래로 퍼진다. 프론트엔드에는 게이트웨이 분기, 매퍼, 필터 같은 손 목록이 아홉 곳 있었고, 백엔드에는 PublicSql.pathOf 한 곳이 빠졌다. 계약에는 CatalogEntry.kind, ResolvedRelation.targetKind, RelatedEntry.type 세 enum 누락이 있었다. 아래 본문 표가 열세 위치를 정확히 나열하고, 그림은 왜 한 종류 변경이 세 영역으로 퍼졌는지만 보여 준다.
## Elements and evidence
- **CONCEPT** (message): No additional description. Evidence: L316L331.
- **Frontend 손 목록** (component): No additional description. Evidence: L335L345.
- **Backend 손 목록** (component): No additional description. Evidence: L345L349.
- **Contract enums** (component): No additional description. Evidence: L346L352.
## Relationships
- **CONCEPT → Backend 손 목록:** kind 추가. Evidence: L345L349.
- **CONCEPT → Contract enums:** enum 추가. Evidence: L346L352.
- **CONCEPT → Frontend 손 목록:** kind 추가. Evidence: L330L345.
@@ -0,0 +1,18 @@
# 새 CONCEPT 종류가 세 영역의 손 목록으로 퍼진 구조
# Question: CONCEPT 하나를 추가했는데 왜 계약·백엔드·프론트엔드 여러 위치를 동시에 고쳐야 했는가?
direction: right
n0: "CONCEPT" {
shape: rectangle
}
n1: "Frontend 손 목록" {
shape: rectangle
}
n2: "Backend 손 목록" {
shape: rectangle
}
n3: "Contract enums" {
shape: rectangle
}
n0 -> n1: "kind 추가"
n0 -> n2: "kind 추가"
n0 -> n3: "enum 추가"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="CONCEPT", shape=box, style="rounded,filled"];
n1 [label="Frontend 손 목록", shape=box, style="rounded,filled"];
n2 [label="Backend 손 목록", shape=box, style="rounded,filled"];
n3 [label="Contract enums", shape=box, style="rounded,filled"];
n0 -> n1 [label="kind 추가", style=solid];
n0 -> n2 [label="kind 추가", style=solid];
n0 -> n3 [label="enum 추가", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="record-kind-fanout" name="새 CONCEPT 종류가 세 영역의 손 목록으로 퍼진 구조">
<mxGraphModel dx="680" dy="547" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_concept" value="CONCEPT&lt;br/&gt;새 Record Kind" tooltip="message | Evidence: L316-L331" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="70.0" y="245.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_backend" value="Backend 손 목록&lt;br/&gt;1곳&lt;br/&gt;PublicSql.pathOf" tooltip="component | Evidence: L345-L349" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="416.5" y="60.0" width="150.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_contract" value="Contract enums&lt;br/&gt;3곳&lt;br/&gt;CatalogEntry.kind&lt;br/&gt;ResolvedRelation.targetKind&lt;br/&gt;RelatedEntry.type" tooltip="component | Evidence: L346-L352" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="380.0" y="220.0" width="223.0" height="122.0" as="geometry"/>
</mxCell>
<mxCell id="n_frontend" value="Frontend 손 목록&lt;br/&gt;9곳&lt;br/&gt;gateway · mapper · filter" tooltip="component | Evidence: L335-L345" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="387.0" y="414.0" width="209.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="e_to-backend" value="kind 추가" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_concept" target="n_backend">
<mxGeometry relative="1" as="geometry">
<mxPoint x="342.2" y="183.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_to-contract" value="enum 추가" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_concept" target="n_contract">
<mxGeometry relative="1" as="geometry">
<mxPoint x="300.0" y="253.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_to-frontend" value="kind 추가" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_concept" target="n_frontend">
<mxGeometry relative="1" as="geometry">
<mxPoint x="327.5" y="378.5" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-to-backend",
"type": "arrow",
"x": 220.0,
"y": 104.0,
"width": 196.5,
"height": 159.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1147872718,
"version": 1,
"versionNonce": 125969590,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
159.0
],
[
98.25,
159.0
],
[
98.25,
0.0
],
[
196.5,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-concept",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-backend",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-to-backend",
"type": "text",
"x": 297.25,
"y": 171.5,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 627649163,
"version": 1,
"versionNonce": 1917519641,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "kind 추가",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "kind 추가",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-to-contract",
"type": "arrow",
"x": 220.0,
"y": 281.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1165353134,
"version": 1,
"versionNonce": 1321109342,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-concept",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-contract",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-to-contract",
"type": "text",
"x": 255.0,
"y": 241.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1321731880,
"version": 1,
"versionNonce": 1614676317,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "enum 추가",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "enum 추가",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-to-frontend",
"type": "arrow",
"x": 220.0,
"y": 299.0,
"width": 167.0,
"height": 159.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1693329255,
"version": 1,
"versionNonce": 696160331,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
83.5,
0.0
],
[
83.5,
159.0
],
[
167.0,
159.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-concept",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-frontend",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-to-frontend",
"type": "text",
"x": 282.5,
"y": 366.5,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1043396341,
"version": 1,
"versionNonce": 269712647,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "kind 추가",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "kind 추가",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-concept",
"type": "rectangle",
"x": 70.0,
"y": 245.5,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1783847077,
"version": 1,
"versionNonce": 1835405496,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-concept",
"type": "text",
"x": 80.0,
"y": 255.5,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 956305639,
"version": 1,
"versionNonce": 1674164108,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "CONCEPT\n새 Record Kind",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "CONCEPT\n새 Record Kind",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-backend",
"type": "rectangle",
"x": 416.5,
"y": 60.0,
"width": 150.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 430450037,
"version": 1,
"versionNonce": 288855291,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-backend",
"type": "text",
"x": 426.5,
"y": 70.0,
"width": 130.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 349920174,
"version": 1,
"versionNonce": 257387561,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Backend 손 목록\n1곳\nPublicSql.pathOf",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Backend 손 목록\n1곳\nPublicSql.pathOf",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-contract",
"type": "rectangle",
"x": 380.0,
"y": 220.0,
"width": 223.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 179232308,
"version": 1,
"versionNonce": 606575263,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-contract",
"type": "text",
"x": 390.0,
"y": 230.0,
"width": 203.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 165738200,
"version": 1,
"versionNonce": 285005378,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Contract enums\n3곳\nCatalogEntry.kind\nResolvedRelation.targetKind\nRelatedEntry.type",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Contract enums\n3곳\nCatalogEntry.kind\nResolvedRelation.targetKind\nRelatedEntry.type",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-frontend",
"type": "rectangle",
"x": 387.0,
"y": 414.0,
"width": 209.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 286799291,
"version": 1,
"versionNonce": 1685391574,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-frontend",
"type": "text",
"x": 397.0,
"y": 424.0,
"width": 189.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1548189542,
"version": 1,
"versionNonce": 237285990,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Frontend 손 목록\n9곳\ngateway · mapper · filter",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Frontend 손 목록\n9곳\ngateway · mapper · filter",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "record-kind-fanout",
"spec_version": "1.1",
"spec_sha256": "4edc64969f367edfd400651cd9e0c1787d63847dab3cdc3835ac6eee75fb377e",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "3. 손으로 나열한 목록이 새 종류를 삼킨다",
"line": 311
}
},
"outputs": [
"record-kind-fanout.svg",
"record-kind-fanout.drawio",
"record-kind-fanout.mmd",
"record-kind-fanout.d2",
"record-kind-fanout.dot",
"record-kind-fanout.excalidraw",
"record-kind-fanout.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% 새 CONCEPT 종류가 세 영역의 손 목록으로 퍼진 구조
%% question: CONCEPT 하나를 추가했는데 왜 계약·백엔드·프론트엔드 여러 위치를 동시에 고쳐야 했는가?
flowchart LR
n0["CONCEPT"]
n1["Frontend 손 목록"]
n2["Backend 손 목록"]
n3["Contract enums"]
n0 -->|"kind 추가"| n1
n0 -->|"kind 추가"| n2
n0 -->|"enum 추가"| n3
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="547" viewBox="0 0 680 547" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">새 CONCEPT 종류가 세 영역의 손 목록으로 퍼진 구조</title>
<desc id="diagram-description">왼쪽의 새 CONCEPT가 세 갈래로 퍼진다. 프론트엔드에는 게이트웨이 분기, 매퍼, 필터 같은 손 목록이 아홉 곳 있었고, 백엔드에는 PublicSql.pathOf 한 곳이 빠졌다. 계약에는 CatalogEntry.kind, ResolvedRelation.targetKind, RelatedEntry.type 세 enum 누락이 있었다. 아래 본문 표가 열세 위치를 정확히 나열하고, 그림은 왜 한 종류 변경이 세 영역으로 퍼졌는지만 보여 준다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;record-kind-fanout&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;3. 손으로 나열한 목록이 새 종류를 삼킨다&quot;,&quot;line&quot;:311}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="547" />
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,263.0 318.2,263.0 318.2,104.0 416.5,104.0" data-evidence="345-349" />
<rect class="edge-label-bg" x="309.8" y="169.5" width="64.9" height="22" rx="3" />
<text class="edge-label" x="342.2" y="184.5">kind 추가</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,281.0 300.0,281.0 300.0,281.0 380.0,281.0" data-evidence="346-352" />
<rect class="edge-label-bg" x="267.6" y="239.0" width="64.9" height="22" rx="3" />
<text class="edge-label" x="300.0" y="254.0">enum 추가</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,299.0 303.5,299.0 303.5,458.0 387.0,458.0" data-evidence="330-345" />
<rect class="edge-label-bg" x="295.1" y="364.5" width="64.9" height="22" rx="3" />
<text class="edge-label" x="327.5" y="379.5">kind 추가</text>
<g id="node-concept">
<rect class="node-shape kind-message emphasis-primary role-source" data-evidence="316-331" x="70.0" y="245.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="272.5">CONCEPT</text>
<line class="node-detail-divider" x1="84.0" y1="293.5" x2="206.0" y2="293.5" />
<text class="node-detail" x="86.0" y="310.5">새 Record Kind</text>
</g>
<g id="node-backend">
<rect class="node-shape kind-component emphasis-normal role-service" data-evidence="345-349" x="416.5" y="60.0" width="150.0" height="88.0" rx="7" />
<text class="node-label" x="491.5" y="87.0">Backend 손 목록</text>
<line class="node-detail-divider" x1="430.5" y1="108.0" x2="552.5" y2="108.0" />
<text class="node-detail" x="432.5" y="125.0">1곳</text>
<text class="node-detail" x="432.5" y="141.0">PublicSql.pathOf</text>
</g>
<g id="node-contract">
<rect class="node-shape kind-component emphasis-normal role-sink" data-evidence="346-352" x="380.0" y="220.0" width="223.0" height="122.0" rx="7" />
<text class="node-label" x="491.5" y="247.0">Contract enums</text>
<line class="node-detail-divider" x1="394.0" y1="268.0" x2="589.0" y2="268.0" />
<text class="node-detail" x="396.0" y="285.0">3곳</text>
<text class="node-detail" x="396.0" y="301.0">CatalogEntry.kind</text>
<text class="node-detail" x="396.0" y="317.0">ResolvedRelation.targetKind</text>
<text class="node-detail" x="396.0" y="333.0">RelatedEntry.type</text>
</g>
<g id="node-frontend">
<rect class="node-shape kind-component emphasis-normal role-service" data-evidence="335-345" x="387.0" y="414.0" width="209.0" height="88.0" rx="7" />
<text class="node-label" x="491.5" y="441.0">Frontend 손 목록</text>
<line class="node-detail-divider" x1="401.0" y1="462.0" x2="582.0" y2="462.0" />
<text class="node-detail" x="403.0" y="479.0">9곳</text>
<text class="node-detail" x="403.0" y="495.0">gateway · mapper · filter</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,24 @@
# 라우트 하나가 건드리는 손 목록과 검출 시점
## Alternative text
새 Route가 Route Contract를 거쳐 Runtime 계약, 배포 전 검사, Edge 서빙 세 묶음으로 갈라지는 팬아웃 그림. 각 묶음에는 누락이 처음 드러나는 시점이 적혀 있다.
## Long description
왼쪽의 새 Route가 Route Contract로 들어간 뒤 세 갈래로 퍼진다. Runtime 계약에는 runtime 등록과 메시지 카탈로그가 있다. 배포 전 검사에는 vite chunk 이름 표와 접근성 증거·아티팩트 기준선·gate digest가 묶여 있고 누락은 빌드 매니페스트 또는 배포 직전에 드러난다. Edge 서빙 규칙 누락은 하드 로드나 새로고침 때 배포 뒤 404로 드러난다.
## Elements and evidence
- **새 Route** (request): No additional description. Evidence: L816L821.
- **Route Contract** (component): No additional description. Evidence: L819L831.
- **Runtime 계약** (component): No additional description. Evidence: L824L826.
- **배포 전 검사** (component): No additional description. Evidence: L828L831, L854L877.
- **Edge serving** (component): No additional description. Evidence: L827L827, L834L852.
## Relationships
- **Route Contract → Edge serving:** 서빙 패턴. Evidence: L827L827, L834L852.
- **Route Contract → 배포 전 검사:** 대조. Evidence: L828L831, L854L877.
- **새 Route → Route Contract:** 등록. Evidence: L819L831.
- **Route Contract → Runtime 계약:** 반영. Evidence: L824L826.
@@ -0,0 +1,22 @@
# 라우트 하나가 건드리는 손 목록과 검출 시점
# Question: 새 라우트 하나가 어디까지 퍼지고, 빠뜨린 항목은 어느 시점에 처음 드러나는가?
direction: right
n0: "새 Route" {
shape: rectangle
}
n1: "Route Contract" {
shape: rectangle
}
n2: "Runtime 계약" {
shape: rectangle
}
n3: "배포 전 검사" {
shape: rectangle
}
n4: "Edge serving" {
shape: rectangle
}
n0 -> n1: "등록"
n1 -> n2: "반영"
n1 -> n3: "대조"
n1 -> n4: "서빙 패턴"
@@ -0,0 +1,14 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="새 Route", shape=box, style="rounded,filled"];
n1 [label="Route Contract", shape=box, style="rounded,filled"];
n2 [label="Runtime 계약", shape=box, style="rounded,filled"];
n3 [label="배포 전 검사", shape=box, style="rounded,filled"];
n4 [label="Edge serving", shape=box, style="rounded,filled"];
n0 -> n1 [label="등록", style=solid];
n1 -> n2 [label="반영", style=solid];
n1 -> n3 [label="대조", style=solid];
n1 -> n4 [label="서빙 패턴", style=solid];
}
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="route-fanout" name="라우트 하나가 건드리는 손 목록과 검출 시점">
<mxGraphModel dx="1045" dy="427" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1045" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_route" value="새 Route" tooltip="request | Evidence: L816-L821" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="35.0" y="145.0" width="190.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_route-contract" value="Route Contract&lt;br/&gt;tech-log-route-contract.ts" tooltip="component | Evidence: L819-L831" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="470.0" y="155.0" width="175.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_runtime" value="Runtime 계약&lt;br/&gt;route-runtime-contract&lt;br/&gt;메시지 카탈로그&lt;br/&gt;검출: 실행 경로" tooltip="component | Evidence: L824-L826" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="745.0" y="40.0" width="255.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_predeploy" value="배포 전 검사&lt;br/&gt;vite chunk 표 · 빌드 매니페스트&lt;br/&gt;접근성 증거 · 아티팩트 기준선 · gate digest&lt;br/&gt;검출: 빌드 / 배포 직전" tooltip="component | Evidence: L828-L831, L854-L877" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="745.0" y="165.0" width="255.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_nginx" value="Edge serving&lt;br/&gt;nginx serving contract&lt;br/&gt;검출: 배포 뒤 404" tooltip="component | Evidence: L827-L827, L834-L852" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="745.0" y="290.0" width="255.0" height="92.0" as="geometry"/>
</mxCell>
<mxCell id="e_nginx-edge" value="서빙 패턴" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_route-contract" target="n_nginx">
<mxGeometry relative="1" as="geometry">
<mxPoint x="719.0" y="272.2" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_predeploy-edge" value="대조" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_route-contract" target="n_predeploy">
<mxGeometry relative="1" as="geometry">
<mxPoint x="719.0" y="204.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_register" value="등록" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_route" target="n_route-contract">
<mxGeometry relative="1" as="geometry">
<mxPoint x="371.5" y="183.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_runtime-edge" value="반영" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_route-contract" target="n_runtime">
<mxGeometry relative="1" as="geometry">
<mxPoint x="719.0" y="132.5" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,754 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-nginx-edge",
"type": "arrow",
"x": 645.0,
"y": 208.5,
"width": 100.0,
"height": 127.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1580851045,
"version": 1,
"versionNonce": 1051493990,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
50.0,
0.0
],
[
50.0,
127.5
],
[
100.0,
127.5
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-route-contract",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-nginx-edge",
"type": "text",
"x": 674.0,
"y": 260.25,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1521247797,
"version": 1,
"versionNonce": 951657351,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "서빙 패턴",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "서빙 패턴",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-predeploy-edge",
"type": "arrow",
"x": 645.0,
"y": 190.5,
"width": 100.0,
"height": 27.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1726580064,
"version": 1,
"versionNonce": 253377512,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
50.0,
0.0
],
[
50.0,
27.0
],
[
100.0,
27.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-route-contract",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-predeploy",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-predeploy-edge",
"type": "text",
"x": 674.0,
"y": 192.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 362342019,
"version": 1,
"versionNonce": 1709430833,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "대조",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "대조",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-register",
"type": "arrow",
"x": 225.0,
"y": 177.0,
"width": 245.0,
"height": 13.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 396101324,
"version": 1,
"versionNonce": 525555079,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
122.5,
0.0
],
[
122.5,
13.5
],
[
245.0,
13.5
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-route",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-route-contract",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-register",
"type": "text",
"x": 326.5,
"y": 171.75,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1844405344,
"version": 1,
"versionNonce": 937765626,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "등록",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "등록",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-runtime-edge",
"type": "arrow",
"x": 645.0,
"y": 92.5,
"width": 100.0,
"height": 80.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1322211266,
"version": 1,
"versionNonce": 777996886,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
80.0
],
[
50.0,
80.0
],
[
50.0,
0.0
],
[
100.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-route-contract",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-runtime",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-runtime-edge",
"type": "text",
"x": 674.0,
"y": 120.5,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 942197722,
"version": 1,
"versionNonce": 1561589515,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "반영",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "반영",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-route",
"type": "rectangle",
"x": 35.0,
"y": 145.0,
"width": 190.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 350599865,
"version": 1,
"versionNonce": 259087579,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-route",
"type": "text",
"x": 45.0,
"y": 155.0,
"width": 170.0,
"height": 44.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 560883849,
"version": 1,
"versionNonce": 1395574706,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "새 Route",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "새 Route",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-route-contract",
"type": "rectangle",
"x": 470.0,
"y": 155.0,
"width": 175.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1323795186,
"version": 1,
"versionNonce": 1653377524,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-route-contract",
"type": "text",
"x": 480.0,
"y": 165.0,
"width": 155.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1027984965,
"version": 1,
"versionNonce": 179365678,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Route Contract\ntech-log-route-contract.ts",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Route Contract\ntech-log-route-contract.ts",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-runtime",
"type": "rectangle",
"x": 745.0,
"y": 40.0,
"width": 255.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 84262181,
"version": 1,
"versionNonce": 1920393701,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-runtime",
"type": "text",
"x": 755.0,
"y": 50.0,
"width": 235.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 225724411,
"version": 1,
"versionNonce": 318497432,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Runtime 계약\nroute-runtime-contract\n메시지 카탈로그\n검출: 실행 경로",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Runtime 계약\nroute-runtime-contract\n메시지 카탈로그\n검출: 실행 경로",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-predeploy",
"type": "rectangle",
"x": 745.0,
"y": 165.0,
"width": 255.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1090005214,
"version": 1,
"versionNonce": 409254688,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-predeploy",
"type": "text",
"x": 755.0,
"y": 175.0,
"width": 235.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 401365795,
"version": 1,
"versionNonce": 297641764,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "배포 전 검사\nvite chunk 표 · 빌드 매니페스트\n접근성 증거 · 아티팩트 기준선 · gate digest\n검출: 빌드 / 배포 직전",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "배포 전 검사\nvite chunk 표 · 빌드 매니페스트\n접근성 증거 · 아티팩트 기준선 · gate digest\n검출: 빌드 / 배포 직전",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-nginx",
"type": "rectangle",
"x": 745.0,
"y": 290.0,
"width": 255.0,
"height": 92.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 721013300,
"version": 1,
"versionNonce": 1520646945,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-nginx",
"type": "text",
"x": 755.0,
"y": 300.0,
"width": 235.0,
"height": 72.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 117243520,
"version": 1,
"versionNonce": 936748041,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Edge serving\nnginx serving contract\n검출: 배포 뒤 404",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Edge serving\nnginx serving contract\n검출: 배포 뒤 404",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "route-fanout",
"spec_version": "1.1",
"spec_sha256": "4a14c651c1b5371be0e96be58e2c3dd6be94785bc6fb2349f395c6a7cf5b1224",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "8. 라우트를 하나 더하면 함께 울리는 손 목록",
"line": 814
}
},
"outputs": [
"route-fanout.svg",
"route-fanout.drawio",
"route-fanout.mmd",
"route-fanout.d2",
"route-fanout.dot",
"route-fanout.excalidraw",
"route-fanout.alt.md"
],
"lint_issue_count": 1,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "query-fanout",
"reference_ids": [
"metrics-query-fanout"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% 라우트 하나가 건드리는 손 목록과 검출 시점
%% question: 새 라우트 하나가 어디까지 퍼지고, 빠뜨린 항목은 어느 시점에 처음 드러나는가?
flowchart LR
n0["새 Route"]
n1["Route Contract"]
n2["Runtime 계약"]
n3["배포 전 검사"]
n4["Edge serving"]
n0 -->|"등록"| n1
n1 -->|"반영"| n2
n1 -->|"대조"| n3
n1 -->|"서빙 패턴"| n4
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1045" height="427" viewBox="0 0 1045 427" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">라우트 하나가 건드리는 손 목록과 검출 시점</title>
<desc id="diagram-description">왼쪽의 새 Route가 Route Contract로 들어간 뒤 세 갈래로 퍼진다. Runtime 계약에는 runtime 등록과 메시지 카탈로그가 있다. 배포 전 검사에는 vite chunk 이름 표와 접근성 증거·아티팩트 기준선·gate digest가 묶여 있고 누락은 빌드 매니페스트 또는 배포 직전에 드러난다. Edge 서빙 규칙 누락은 하드 로드나 새로고침 때 배포 뒤 404로 드러난다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;route-fanout&quot;,&quot;profile&quot;:&quot;query-fanout&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;8. 라우트를 하나 더하면 함께 울리는 손 목록&quot;,&quot;line&quot;:814}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1045" height="427" />
<polyline class="edge kind-data style-solid emphasis-normal" points="645.0,208.5 695.0,208.5 695.0,336.0 745.0,336.0" data-evidence="827-827,834-852" />
<rect class="edge-label-bg" x="693.2" y="258.2" width="51.5" height="22" rx="3" />
<text class="edge-label" x="719.0" y="273.2">서빙 패턴</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="645.0,190.5 695.0,190.5 695.0,217.5 745.0,217.5" data-evidence="828-831,854-877" />
<rect class="edge-label-bg" x="697.0" y="190.0" width="44.0" height="22" rx="3" />
<text class="edge-label" x="719.0" y="205.0">대조</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="225.0,177.0 347.5,177.0 347.5,190.5 470.0,190.5" data-evidence="819-831" />
<rect class="edge-label-bg" x="349.5" y="169.8" width="44.0" height="22" rx="3" />
<text class="edge-label" x="371.5" y="184.8">등록</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="645.0,172.5 695.0,172.5 695.0,92.5 745.0,92.5" data-evidence="824-826" />
<rect class="edge-label-bg" x="697.0" y="118.5" width="44.0" height="22" rx="3" />
<text class="edge-label" x="719.0" y="133.5">반영</text>
<g id="node-route">
<rect class="node-shape kind-request emphasis-normal role-query" data-evidence="816-821" x="35.0" y="145.0" width="190.0" height="64.0" rx="7" />
<text class="node-label" x="130.0" y="175.0">새 Route</text>
</g>
<g id="node-route-contract">
<rect class="node-shape kind-component emphasis-primary role-router" data-evidence="819-831" x="470.0" y="155.0" width="175.0" height="71.0" rx="7" />
<text class="node-role" x="557.5" y="174.0">«router»</text>
<text class="node-label" x="557.5" y="201.0">Route Contract</text>
<line class="node-detail-divider" x1="484.0" y1="222.0" x2="631.0" y2="222.0" />
<text class="node-detail" x="486.0" y="239.0">tech-log-route-contract.ts</text>
</g>
<g id="node-runtime">
<rect class="node-shape kind-component emphasis-normal role-store" data-evidence="824-826" x="745.0" y="40.0" width="255.0" height="105.0" rx="7" />
<text class="node-label" x="872.5" y="67.0">Runtime 계약</text>
<line class="node-detail-divider" x1="759.0" y1="88.0" x2="986.0" y2="88.0" />
<text class="node-detail" x="761.0" y="105.0">route-runtime-contract</text>
<text class="node-detail" x="761.0" y="121.0">메시지 카탈로그</text>
<text class="node-detail" x="761.0" y="137.0">검출: 실행 경로</text>
</g>
<g id="node-predeploy">
<rect class="node-shape kind-component emphasis-normal role-store" data-evidence="828-831,854-877" x="745.0" y="165.0" width="255.0" height="105.0" rx="7" />
<text class="node-label" x="872.5" y="192.0">배포 전 검사</text>
<line class="node-detail-divider" x1="759.0" y1="213.0" x2="986.0" y2="213.0" />
<text class="node-detail" x="761.0" y="230.0">vite chunk 표 · 빌드 매니페스트</text>
<text class="node-detail" x="761.0" y="246.0">접근성 증거 · 아티팩트 기준선 · gate digest</text>
<text class="node-detail" x="761.0" y="262.0">검출: 빌드 / 배포 직전</text>
</g>
<g id="node-nginx">
<rect class="node-shape kind-component emphasis-normal role-store" data-evidence="827-827,834-852" x="745.0" y="290.0" width="255.0" height="92.0" rx="7" />
<text class="node-label" x="872.5" y="318.0">Edge serving</text>
<line class="node-detail-divider" x1="759.0" y1="339.0" x2="986.0" y2="339.0" />
<text class="node-detail" x="761.0" y="356.0">nginx serving contract</text>
<text class="node-detail" x="761.0" y="372.0">검출: 배포 뒤 404</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -0,0 +1,22 @@
# summary가 세 경계에서 사라진 경로
## Alternative text
Contract summary에서 flattenRelations, ResolvedRelation, 화면 목록으로 이어지는 흐름. 세 중간 지점에 DROP 1, 칸 없음, DROP 3이 표시되어 있다.
## Long description
왼쪽 Contract에는 summary가 있다. flattenRelations가 그 값을 담지 않아 첫 번째로 끊긴다. 다음 ResolvedRelation 계약에는 summary 칸 자체가 없어 두 번째로 막힌다. 그 칸을 추가한 뒤에도 화면 목록으로 넘길 때 값을 버려 세 번째로 끊겼다. 최종 수정에서는 세 경계를 모두 이어 공개 relation 목록까지 summary가 도착하게 했다.
## Elements and evidence
- **Contract summary** (message): No additional description. Evidence: L540L547.
- **flattenRelations** (component): No additional description. Evidence: L544L547.
- **ResolvedRelation** (component): No additional description. Evidence: L546L552.
- **화면 relation 목록** (component): No additional description. Evidence: L547L552.
## Relationships
- **Contract summary → flattenRelations:** summary. Evidence: L544L545.
- **flattenRelations → ResolvedRelation:** 렌더 모델. Evidence: L545L550.
- **ResolvedRelation → 화면 relation 목록:** 화면 전달. Evidence: L546L552.
@@ -0,0 +1,18 @@
# summary가 세 경계에서 사라진 경로
# Question: 계약과 DB에 있던 summary가 공개 relation 목록까지 오지 못한 세 유실 지점은 어디였는가?
direction: right
n0: "Contract summary" {
shape: rectangle
}
n1: "flattenRelations" {
shape: rectangle
}
n2: "ResolvedRelation" {
shape: rectangle
}
n3: "화면 relation 목록" {
shape: rectangle
}
n0 -> n1: "summary"
n1 -> n2: "렌더 모델"
n2 -> n3: "화면 전달"
@@ -0,0 +1,12 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="Contract summary", shape=box, style="rounded,filled"];
n1 [label="flattenRelations", shape=box, style="rounded,filled"];
n2 [label="ResolvedRelation", shape=box, style="rounded,filled"];
n3 [label="화면 relation 목록", shape=box, style="rounded,filled"];
n0 -> n1 [label="summary", style=solid];
n1 -> n2 [label="렌더 모델", style=solid];
n2 -> n3 [label="화면 전달", style=solid];
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="summary-drop-path" name="summary가 세 경계에서 사라진 경로">
<mxGraphModel dx="1268" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1268" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_contract" value="Contract summary&lt;br/&gt;summary 있음" tooltip="message | Evidence: L540-L547" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="68.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_flatten" value="flattenRelations&lt;br/&gt;DROP #1" tooltip="component | Evidence: L544-L547" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="380.0" y="68.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_resolved-relation" value="ResolvedRelation&lt;br/&gt;summary 칸 없음&lt;br/&gt;additionalProperties: false" tooltip="component | Evidence: L546-L552" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="690.0" y="60.0" width="223.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_screen-list" value="화면 relation 목록&lt;br/&gt;DROP #3" tooltip="component | Evidence: L547-L552" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="1073.0" y="68.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="e_to-flatten" value="summary" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_contract" target="n_flatten">
<mxGeometry relative="1" as="geometry">
<mxPoint x="300.0" y="76.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_to-model" value="렌더 모델" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_flatten" target="n_resolved-relation">
<mxGeometry relative="1" as="geometry">
<mxPoint x="610.0" y="76.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_to-screen" value="화면 전달" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_resolved-relation" target="n_screen-list">
<mxGeometry relative="1" as="geometry">
<mxPoint x="993.0" y="76.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,586 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-to-flatten",
"type": "arrow",
"x": 220.0,
"y": 104.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 141665806,
"version": 1,
"versionNonce": 1232986493,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-contract",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-flatten",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-to-flatten",
"type": "text",
"x": 255.0,
"y": 64.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 871217595,
"version": 1,
"versionNonce": 976098011,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "summary",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "summary",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-to-model",
"type": "arrow",
"x": 530.0,
"y": 104.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1957426780,
"version": 1,
"versionNonce": 962714772,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-flatten",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-resolved-relation",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-to-model",
"type": "text",
"x": 565.0,
"y": 64.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1794075843,
"version": 1,
"versionNonce": 139335072,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "렌더 모델",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "렌더 모델",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-to-screen",
"type": "arrow",
"x": 913.0,
"y": 104.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 864108480,
"version": 1,
"versionNonce": 1322732292,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-resolved-relation",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-screen-list",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-to-screen",
"type": "text",
"x": 948.0,
"y": 64.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1304142647,
"version": 1,
"versionNonce": 1362646482,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "화면 전달",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "화면 전달",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-contract",
"type": "rectangle",
"x": 70.0,
"y": 68.5,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 179232308,
"version": 1,
"versionNonce": 606575263,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-contract",
"type": "text",
"x": 80.0,
"y": 78.5,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 165738200,
"version": 1,
"versionNonce": 285005378,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Contract summary\nsummary 있음",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Contract summary\nsummary 있음",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-flatten",
"type": "rectangle",
"x": 380.0,
"y": 68.5,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 232992741,
"version": 1,
"versionNonce": 46540854,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-flatten",
"type": "text",
"x": 390.0,
"y": 78.5,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1419734197,
"version": 1,
"versionNonce": 1137639440,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "flattenRelations\nDROP #1",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "flattenRelations\nDROP #1",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-resolved-relation",
"type": "rectangle",
"x": 690.0,
"y": 60.0,
"width": 223.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 971199429,
"version": 1,
"versionNonce": 57456686,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-resolved-relation",
"type": "text",
"x": 700.0,
"y": 70.0,
"width": 203.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 541790523,
"version": 1,
"versionNonce": 622968494,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "ResolvedRelation\nsummary 칸 없음\nadditionalProperties: false",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "ResolvedRelation\nsummary 칸 없음\nadditionalProperties: false",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-screen-list",
"type": "rectangle",
"x": 1073.0,
"y": 68.5,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1285194132,
"version": 1,
"versionNonce": 1968297606,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-screen-list",
"type": "text",
"x": 1083.0,
"y": 78.5,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 922636927,
"version": 1,
"versionNonce": 39716581,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "화면 relation 목록\nDROP #3",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "화면 relation 목록\nDROP #3",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "summary-drop-path",
"spec_version": "1.1",
"spec_sha256": "3ee9cea60c33ed818caaf9d14b2b36992295e4a2d2e956d52ac70fae2aab72cb",
"source_context": {
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "heading",
"value": "5. 계약에 자리가 없어 값이 경계에서 사라진다",
"line": 516
}
},
"outputs": [
"summary-drop-path.svg",
"summary-drop-path.drawio",
"summary-drop-path.mmd",
"summary-drop-path.d2",
"summary-drop-path.dot",
"summary-drop-path.excalidraw",
"summary-drop-path.alt.md"
],
"lint_issue_count": 1,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,10 @@
%% summary가 세 경계에서 사라진 경로
%% question: 계약과 DB에 있던 summary가 공개 relation 목록까지 오지 못한 세 유실 지점은 어디였는가?
flowchart LR
n0["Contract summary"]
n1["flattenRelations"]
n2["ResolvedRelation"]
n3["화면 relation 목록"]
n0 -->|"summary"| n1
n1 -->|"렌더 모델"| n2
n2 -->|"화면 전달"| n3
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1268" height="300" viewBox="0 0 1268 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">summary가 세 경계에서 사라진 경로</title>
<desc id="diagram-description">왼쪽 Contract에는 summary가 있다. flattenRelations가 그 값을 담지 않아 첫 번째로 끊긴다. 다음 ResolvedRelation 계약에는 summary 칸 자체가 없어 두 번째로 막힌다. 그 칸을 추가한 뒤에도 화면 목록으로 넘길 때 값을 버려 세 번째로 끊겼다. 최종 수정에서는 세 경계를 모두 이어 공개 relation 목록까지 summary가 도착하게 했다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;summary-drop-path&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;5. 계약에 자리가 없어 값이 경계에서 사라진다&quot;,&quot;line&quot;:516}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1268" height="300" />
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,104.0 300.0,104.0 300.0,104.0 380.0,104.0" data-evidence="544-545" />
<rect class="edge-label-bg" x="267.6" y="62.0" width="64.9" height="22" rx="3" />
<text class="edge-label" x="300.0" y="77.0">summary</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="530.0,104.0 610.0,104.0 610.0,104.0 690.0,104.0" data-evidence="545-550" />
<rect class="edge-label-bg" x="584.2" y="62.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="610.0" y="77.0">렌더 모델</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="913.0,104.0 993.0,104.0 993.0,104.0 1073.0,104.0" data-evidence="546-552" />
<rect class="edge-label-bg" x="967.2" y="62.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="993.0" y="77.0">화면 전달</text>
<g id="node-contract">
<rect class="node-shape kind-message emphasis-normal role-source" data-evidence="540-547" x="70.0" y="68.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="95.5">Contract summary</text>
<line class="node-detail-divider" x1="84.0" y1="116.5" x2="206.0" y2="116.5" />
<text class="node-detail" x="86.0" y="133.5">summary 있음</text>
</g>
<g id="node-flatten">
<rect class="node-shape kind-component emphasis-warning role-service" data-evidence="544-547" x="380.0" y="68.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="455.0" y="95.5">flattenRelations</text>
<line class="node-detail-divider" x1="394.0" y1="116.5" x2="516.0" y2="116.5" />
<text class="node-detail" x="396.0" y="133.5">DROP #1</text>
</g>
<g id="node-resolved-relation">
<rect class="node-shape kind-component emphasis-warning role-service" data-evidence="546-552" x="690.0" y="60.0" width="223.0" height="88.0" rx="7" />
<text class="node-label" x="801.5" y="87.0">ResolvedRelation</text>
<line class="node-detail-divider" x1="704.0" y1="108.0" x2="899.0" y2="108.0" />
<text class="node-detail" x="706.0" y="125.0">summary 칸 없음</text>
<text class="node-detail" x="706.0" y="141.0">additionalProperties: false</text>
</g>
<g id="node-screen-list">
<rect class="node-shape kind-component emphasis-warning role-sink" data-evidence="547-552" x="1073.0" y="68.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="1148.0" y="95.5">화면 relation 목록</text>
<line class="node-detail-divider" x1="1087.0" y1="116.5" x2="1209.0" y2="116.5" />
<text class="node-detail" x="1089.0" y="133.5">DROP #3</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -10,18 +10,18 @@
## Elements and evidence
- **Boundary: 종류별 테이블** (system): No additional description. Evidence: L1071L1073.
- **topic** (database): 주제. 축의 이름을 주제가 정한다. Evidence: L1057L1059, L1067L1068.
- **topic_variant** (database): 축의 값들. Evidence: L1060L1060, L1047L1048.
- **record_variant** (database): 어느 기록이 어느 축에 걸리는지 적는 자리. 외래키를 걸지 못한다. Evidence: L1061L1061, L1071L1073.
- **document** (database): 기록 테이블 하나. Evidence: L1071L1072.
- **open_question** (database): 기록 테이블 하나. Evidence: L1071L1072.
- **project_decision** (database): 기록 테이블 하나. Evidence: L1071L1072.
- **Boundary: 종류별 테이블** (system): No additional description. Evidence: L1382L1384.
- **topic** (database): 주제. 축의 이름을 주제가 정한다. Evidence: L1368L1370, L1377L1379.
- **topic_variant** (database): 축의 값들. Evidence: L1371L1371, L1358L1359.
- **record_variant** (database): 어느 기록이 어느 축에 걸리는지 적는 자리. 외래키를 걸지 못한다. Evidence: L1372L1372, L1382L1384.
- **document** (database): 기록 테이블 하나. Evidence: L1382L1384.
- **open_question** (database): 기록 테이블 하나. Evidence: L1382L1384.
- **project_decision** (database): 기록 테이블 하나. Evidence: L1382L1384.
## Relationships
- **topic → topic_variant:** 1 : N. Evidence: L1057L1060.
- **topic_variant → record_variant:** 축에 건다. Evidence: L1060L1061.
- **record_variant → document:** (kind, id). Evidence: L1061L1073.
- **record_variant → open_question:** (kind, id). Evidence: L1061L1073.
- **record_variant → project_decision:** (kind, id). Evidence: L1061L1073.
- **topic → topic_variant:** 1 : N. Evidence: L1368L1371.
- **topic_variant → record_variant:** 축에 건다. Evidence: L1371L1372.
- **record_variant → document:** (kind, id). Evidence: L1372L1384.
- **record_variant → open_question:** (kind, id). Evidence: L1372L1384.
- **record_variant → project_decision:** (kind, id). Evidence: L1372L1384.
@@ -3,7 +3,7 @@ digraph techviz {
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
subgraph cluster_0 {
label="기록은 종류마다 다른 테이블에 산다";
label="종류별 테이블";
style="rounded,dashed";
color="#66788a";
n3 [label="document", shape=cylinder, style="rounded,filled"];
@@ -8,22 +8,22 @@
<mxCell id="g_record-tables" value="종류별 테이블" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="1130.0" y="35.0" width="210.0" height="408.0" as="geometry"/>
</mxCell>
<mxCell id="n_topic" value="topic&lt;br/&gt;variant_label" tooltip="주제. 축의 이름을 주제가 정한다. | Evidence: L1057-L1059, L1067-L1068" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_topic" value="topic&lt;br/&gt;variant_label" tooltip="주제. 축의 이름을 주제가 정한다. | Evidence: L1368-L1370, L1377-L1379" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="213.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_topic-variant" value="topic_variant&lt;br/&gt;SPA · Mediator · BFF · Forward-Auth" tooltip="축의 값들. | Evidence: L1060-L1060, L1047-L1048" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_topic-variant" value="topic_variant&lt;br/&gt;SPA · Mediator · BFF · Forward-Auth" tooltip="축의 값들. | Evidence: L1371-L1371, L1358-L1359" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="380.0" y="213.5" width="279.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_record-variant" value="record_variant&lt;br/&gt;(kind, id) 쌍 · 외래키 없음" tooltip="어느 기록이 어느 축에 걸리는지 적는 자리. 외래키를 걸지 못한다. | Evidence: L1061-L1061, L1071-L1073" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxCell id="n_record-variant" value="record_variant&lt;br/&gt;(kind, id) 쌍 · 외래키 없음" tooltip="어느 기록이 어느 축에 걸리는지 적는 자리. 외래키를 걸지 못한다. | Evidence: L1372-L1372, L1382-L1384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="819.0" y="213.5" width="181.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_document" value="document" tooltip="기록 테이블 하나. | Evidence: L1071-L1072" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_document" value="document" tooltip="기록 테이블 하나. | Evidence: L1382-L1384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1160.0" y="81.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_open-question" value="open_question" tooltip="기록 테이블 하나. | Evidence: L1071-L1072" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_open-question" value="open_question" tooltip="기록 테이블 하나. | Evidence: L1382-L1384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1160.0" y="217.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_project-decision" value="project_decision" tooltip="기록 테이블 하나. | Evidence: L1071-L1072" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_project-decision" value="project_decision" tooltip="기록 테이블 하나. | Evidence: L1382-L1384" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1160.0" y="353.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_t1" value="1 : N" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_topic" target="n_topic-variant">
@@ -2,14 +2,14 @@
"harness_version": "0.2.0",
"spec_id": "topic-variant-model",
"spec_version": "1.1",
"spec_sha256": "9877bf8caf67db577f45c1576cb30b722b4339578a9880267164f759c1c63df4",
"spec_sha256": "22de32de9a4de3c4b242a01071f8461d27ca4bcbbcdec3c37606db6e2b21cdb9",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "marker",
"value": "topic-variant-model",
"line": 1064
"line": 1375
}
},
"outputs": [
@@ -17,6 +17,7 @@
"topic-variant-model.drawio",
"topic-variant-model.mmd",
"topic-variant-model.d2",
"topic-variant-model.dot",
"topic-variant-model.excalidraw",
"topic-variant-model.alt.md"
],
@@ -2,7 +2,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1385" height="488" viewBox="0 0 1385 488" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">주제 안의 축과 기록을 잇는 자리</title>
<desc id="diagram-description">왼쪽에 topic 이 있고 variant_label 로 축의 이름을 스스로 정한다. 그 오른쪽에 topic_variant 가 있고 SPA, Mediator, BFF, Forward-Auth 같은 축의 값들을 담는다. 그 오른쪽에 record_variant 가 있고 어느 기록이 어느 축에 걸리는지를 종류와 아이디의 쌍으로 적는다. record_variant 는 오른쪽의 document, open_question, project_decision 세 테이블을 가리키는데, 기록이 종류마다 다른 테이블에 살기 때문에 외래키를 걸지 못하고 쌍으로만 가리킨다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;topic-variant-model&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;topic-variant-model&quot;,&quot;line&quot;:1064}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;topic-variant-model&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;topic-variant-model&quot;,&quot;line&quot;:1375}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
@@ -53,49 +53,49 @@
<rect class="group-box" x="1130.0" y="35.0" width="210.0" height="408.0" rx="8" />
<rect class="group-label-bg" x="1144.0" y="25.0" width="90.0" height="22" />
<text class="group-label" x="1154.0" y="40.0">종류별 테이블</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,249.0 300.0,249.0 300.0,249.0 380.0,249.0" data-evidence="1057-1060" />
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,249.0 300.0,249.0 300.0,249.0 380.0,249.0" data-evidence="1368-1371" />
<rect class="edge-label-bg" x="274.2" y="207.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="300.0" y="222.0">1 : N</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="659.0,249.0 739.0,249.0 739.0,249.0 819.0,249.0" data-evidence="1060-1061" />
<polyline class="edge kind-data style-solid emphasis-normal" points="659.0,249.0 739.0,249.0 739.0,249.0 819.0,249.0" data-evidence="1371-1372" />
<rect class="edge-label-bg" x="713.2" y="207.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="739.0" y="222.0">축에 건다</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="1000.0,231.0 1080.0,231.0 1080.0,113.0 1160.0,113.0" data-evidence="1061-1073" />
<polyline class="edge kind-data style-dashed emphasis-normal" points="1000.0,231.0 1080.0,231.0 1080.0,113.0 1160.0,113.0" data-evidence="1372-1384" />
<rect class="edge-label-bg" x="1061.5" y="158.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="1104.0" y="173.0">(kind, id)</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="1000.0,249.0 1080.0,249.0 1080.0,249.0 1160.0,249.0" data-evidence="1061-1073" />
<polyline class="edge kind-data style-dashed emphasis-normal" points="1000.0,249.0 1080.0,249.0 1080.0,249.0 1160.0,249.0" data-evidence="1372-1384" />
<rect class="edge-label-bg" x="1037.5" y="207.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="1080.0" y="222.0">(kind, id)</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="1000.0,267.0 1080.0,267.0 1080.0,385.0 1160.0,385.0" data-evidence="1061-1073" />
<polyline class="edge kind-data style-dashed emphasis-normal" points="1000.0,267.0 1080.0,267.0 1080.0,385.0 1160.0,385.0" data-evidence="1372-1384" />
<rect class="edge-label-bg" x="1061.5" y="312.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="1104.0" y="327.0">(kind, id)</text>
<g id="node-topic">
<rect class="node-shape kind-database emphasis-normal role-source" data-evidence="1057-1059,1067-1068" x="70.0" y="213.5" width="150.0" height="71.0" rx="7" />
<rect class="node-shape kind-database emphasis-normal role-source" data-evidence="1368-1370,1377-1379" x="70.0" y="213.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="240.5">topic</text>
<line class="node-detail-divider" x1="84.0" y1="261.5" x2="206.0" y2="261.5" />
<text class="node-detail" x="86.0" y="278.5">variant_label</text>
</g>
<g id="node-topic-variant">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1060-1060,1047-1048" x="380.0" y="213.5" width="279.0" height="71.0" rx="7" />
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1371-1371,1358-1359" x="380.0" y="213.5" width="279.0" height="71.0" rx="7" />
<text class="node-label" x="519.5" y="240.5">topic_variant</text>
<line class="node-detail-divider" x1="394.0" y1="261.5" x2="645.0" y2="261.5" />
<text class="node-detail" x="396.0" y="278.5">SPA · Mediator · BFF · Forward-Auth</text>
</g>
<g id="node-record-variant">
<rect class="node-shape kind-database emphasis-primary role-store" data-evidence="1061-1061,1071-1073" x="819.0" y="213.5" width="181.0" height="71.0" rx="7" />
<rect class="node-shape kind-database emphasis-primary role-store" data-evidence="1372-1372,1382-1384" x="819.0" y="213.5" width="181.0" height="71.0" rx="7" />
<text class="node-label" x="909.5" y="240.5">record_variant</text>
<line class="node-detail-divider" x1="833.0" y1="261.5" x2="986.0" y2="261.5" />
<text class="node-detail" x="835.0" y="278.5">(kind, id) 쌍 · 외래키 없음</text>
</g>
<g id="node-document">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1071-1072" x="1160.0" y="81.0" width="150.0" height="64.0" rx="7" />
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1382-1384" x="1160.0" y="81.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1235.0" y="111.0">document</text>
</g>
<g id="node-open-question">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1071-1072" x="1160.0" y="217.0" width="150.0" height="64.0" rx="7" />
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1382-1384" x="1160.0" y="217.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1235.0" y="247.0">open_question</text>
</g>
<g id="node-project-decision">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1071-1072" x="1160.0" y="353.0" width="150.0" height="64.0" rx="7" />
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="1382-1384" x="1160.0" y="353.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1235.0" y="383.0">project_decision</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -2,7 +2,7 @@
## Alternative text
저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 tech-log-backend·전선·tech-log-frontend 구역으로 나눠 이은 흐름도. 묶음마다 그 안에 든 경계 수가 2·4·1·3·1 로 적혀 있다.
열한 개 경계를 저장·백엔드 조립·전선·프론트엔드 조립·화면 다섯 구간으로 묶고, tech-log-backend·HTTP·tech-log-frontend 소유 구역을 함께 표시한 흐름도.
## Long description
@@ -2,22 +2,22 @@
"harness_version": "0.2.0",
"spec_id": "value-boundaries",
"spec_version": "1.1",
"spec_sha256": "2c99d7aadf7942c550c5d0b8ee2bd11f3347ad33d99b233fe57a44989ef0e2c7",
"spec_sha256": "3e2cb33febfa2cfe2a66f9e16a18b96e5710c2a9ec8ef681a0b5941570682038",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
"document": "docs/TechLog/final/document.md",
"document_sha256": "c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955",
"anchor": {
"kind": "marker",
"value": "value-boundaries",
"line": 82
"line": 85
}
},
"outputs": [
"value-boundaries.svg",
"value-boundaries.drawio",
"value-boundaries.mmd",
"value-boundaries.d2",
"value-boundaries.dot",
"value-boundaries.drawio",
"value-boundaries.excalidraw",
"value-boundaries.alt.md"
],
@@ -2,7 +2,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1535" height="300" viewBox="0 0 1535 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">공개 화면 한 줄까지 값이 지나는 열한 개의 경계 — 다섯 묶음</title>
<desc id="diagram-description">왼쪽에서 오른쪽으로 읽는다. tech-log-backend 구역에 저장 묶음과 백엔드 조립 묶음이 있고 각각 경계 둘과 넷을 담는다. 전선 구역에는 HTTP envelope 하나가 있다. tech-log-frontend 구역에는 프론트엔드 조립 묶음과 화면 컴포넌트가 있고 각각 경계 셋과 하나다. 다 더하면 열한 개이고, 각 경계의 이름은 그림 위 목록에 있다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;value-boundaries&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;value-boundaries&quot;,&quot;line&quot;:82}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;value-boundaries&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/TechLog/final/document.md&quot;,&quot;document_sha256&quot;:&quot;c3a7de37b778fff7b6ea555a3ad7338c91c6fb15d685e7734f89472b4924d955&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;value-boundaries&quot;,&quot;line&quot;:85}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

+58 -14
View File
@@ -108,9 +108,11 @@ PostgreSQL 테이블
```
레지스트리가 없습니다. 공개 Hub 는 소스가 들어간 이미지라 쓸 수 없고, k3s 의 containerd 소켓은
root 전용이라 사용자 셸에서 닿지 않습니다. 그래서 클러스터 안 일회성 Job 을 띄워 tar 를
import 합니다. 배포 단위는 `hyeonworks.com`(prod) 하나이고 서브도메인은 쓰지 않습니다 —
공개는 `/`, API 는 `/api` 입니다.
root 전용이라 사용자 셸에서 닿지 않습니다. 그래서 클러스터 안 일회성 Job 으로 tar 를
import 하는 경로를 씁니다. 이 경로가 성립하려면 Job 이 host 의 containerd 소켓을 명시적으로
mount 하고 그 소켓을 열 수 있는 권한으로 실행되어야 합니다. `kube-system` namespace 나
클러스터 RBAC 권한만으로 host 소켓에 접근되는 것은 아닙니다. 배포 단위는
`hyeonworks.com`(prod) 하나이고 서브도메인은 쓰지 않습니다 — 공개는 `/`, API 는 `/api` 입니다.
---
@@ -1185,11 +1187,33 @@ nginx 도 서빙했지만 **브라우저는 `/favicon.ico` 를 물었고** 404
### 12.8 npm 환경 변수 누출 (운영 아님, 검증 절차)
vitest 를 `npm`/`npx` 로 돌리면 `npm_config_*` 환경 변수가 설정되고
`ci-workflow-generation.test.ts` 가 실패합니다. 이 저장소에서 테스트를 돌릴 때는:
`ci-workflow-generation.test.ts` 가 실패합니다. `tech-log-frontend` 저장소 루트에서 먼저
남아 있는 변수를 확인합니다.
<!-- command-mode: operator -->
```bash
env $(env | grep -i "^npm_config" | cut -d= -f1 | sed 's/^/-u /' | tr '\n' ' ') \
./node_modules/.bin/vitest run …
env | grep -i '^npm_config'
```
출력이 있으면 같은 셸에서 그 변수만 `unset` 한 뒤 다시 확인합니다. 아래 loop 는 현재 환경의
`npm_config_*` 이름만 골라 지웁니다.
<!-- command-mode: operator -->
```bash
while IFS='=' read -r name _; do
if [[ ${name,,} == npm_config_* ]]; then
unset "$name"
fi
done < <(env)
env | grep -i '^npm_config'
```
두 번째 확인에서 아무것도 나오지 않으면 npm/npx 래퍼를 거치지 않고 실행기를 직접 부릅니다.
<!-- command-mode: operator -->
```bash
./node_modules/.bin/vitest run …
```
---
@@ -1504,19 +1528,39 @@ jpa-feed-query-performance 축 이름 「조회 전략」 축 3개
`check:types` 는 tsconfig 여섯 개를 차례로 돌립니다 — app · node · test · recipes ·
web-worker · service-worker. 루트 tsconfig 를 직접 부르는 명령은 그중 어느 것도 지나지 않습니다.
프론트 검증은 `tech-log-frontend` 저장소 루트에서 실행합니다. §12.8의 환경 변수 정리를
먼저 끝낸 뒤 아래 세 줄을 실행합니다. 마지막 vitest 호출이 unit · component · tech-log 기능
테스트를 함께 돕니다.
<!-- command-mode: operator -->
```bash
# 프론트 — 다섯 개를 다 돌린다. npx tsc --noEmit 은 아무것도 검사하지 않는다
npm run check:types
npm run lint
env $(env | grep -i "^npm_config" | cut -d= -f1 | sed 's/^/-u /' | tr '\n' ' ') \
./node_modules/.bin/vitest run tests/unit tests/component tests/features/tech-log
./node_modules/.bin/vitest run tests/unit tests/component tests/features/tech-log
```
# 백엔드 — 커밋한 뒤에 돌린다(산출물 이름에 커밋 해시가 들어간다)
cd src && ./gradlew cleanStaleTraceableJars build
세 명령이 모두 0으로 끝나야 프론트 검증이 끝난 것입니다. `npx tsc --noEmit` 성공은 이 저장소의
타입 검증을 대신하지 않습니다.
# 설계 패키지
python3 scripts/check-openapi.py && python3 scripts/check-consistency.py \
&& python3 scripts/check-contract-parity.py
백엔드는 `tech-log-backend` 저장소 루트에서 먼저 작업 트리를 확인합니다. 빌드 산출물 이름에
커밋 해시가 들어가므로 `git status --short` 출력이 남아 있다면 먼저 그 변경이 의도한 것인지
확인하고 커밋 상태를 정리합니다.
<!-- command-mode: operator -->
```bash
git status --short
cd src
./gradlew cleanStaleTraceableJars build
```
`src` 로 이동한 같은 위치에서 설계 패키지의 세 검사를 각각 실행합니다. 한 줄로 묶지 않아 어느
검사가 실패했는지 그대로 남깁니다.
<!-- command-mode: operator -->
```bash
python3 scripts/check-openapi.py
python3 scripts/check-consistency.py
python3 scripts/check-contract-parity.py
```
---
@@ -28,7 +28,7 @@ source:
## 문제
주제 화면의 네 줄(SPA·Mediator·BFF·Forward-Auth)은 링크로 그려져 있었다. 눌러도 아무 일이 없었다.
주제 화면의 네 줄(SPA(Single-Page Application)·Mediator·BFF(Backend for Frontend)·Forward-Auth)은 링크로 그려져 있었다. 눌러도 아무 일이 없었다.
처음에 /topics/{주제}/{축} 이라 적어 두었는데 그런 화면이 없었다.
@@ -55,7 +55,7 @@ topic (주제)
## 기록은 여러 축에 걸린다
한 기록이 여러 축에 걸릴 수 있다. PKCE 는 SPA 와 BFF 양쪽에 관계된다.
한 기록이 여러 축에 걸릴 수 있다. PKCE(Proof Key for Code Exchange)는 SPA(Single-Page Application)와 BFF(Backend for Frontend) 양쪽에 걸린다.
아무 축에도 걸리지 않은 기록은 그 주제의 공통 기록으로 읽는다. 「공통」이라는 축을 따로 만들지 않는 이유는, 만들면 그 축이 비교 화면에 한 줄로 서서 다른 축들과 견주는 것처럼 보이기 때문이다.
@@ -38,7 +38,7 @@ source:
## 판단 이유
주제를 넷으로 쪼개면 네 구조가 함께 쓰는 기록을 어디에 둘지 애매해진다. PKCE·CSRF·Authorization Code 그런 기록이다. 어느 한 주제에 넣으면 나머지 셋에서 그 기록에 닿을 수 없고, 넷에 복사하면 같은 글이 넷이 된다.
주제를 넷으로 쪼개면 네 구조가 함께 쓰는 기록을 어디에 둘지 애매해진다. PKCE(Proof Key for Code Exchange)와 CSRF(Cross-Site Request Forgery) 방어, Authorization Code 흐름이 그런 기록이다. 어느 한 주제에 넣으면 나머지 셋에서 그 기록에 닿을 수 없고, 넷에 복사하면 같은 글이 넷이 된다.
비교도 어려워진다. 네 주제가 나란히 서면 그것이 같은 질문의 네 답이라는 것을 화면이 말하지 못하고, 독자는 목록에서 넷을 각각 열어 봐야 한다.
@@ -106,6 +106,8 @@ tech-log-frontend : 344dadb · 805d400 · 8c5dbe1
## 확인하지 못한 것
이 결함이 났던 당시의 before/after 브라우저 캡처는 저장돼 있지 않다. 현재 화면을 당시 상태처럼 다시 꾸며 과거 증거로 쓰지 않는다.
CSS module 을 쓰는 화면은 전역 규칙이 닿지 않아 따로 고쳤다. 「지금 집중하는 것」 탭과 주제 탭의 표시 방식이 아직 다르다 — 한쪽은 파란 밑줄이고 한쪽은 알약이다. 주제 탭 밑줄을 그쪽에서 베껴 왔다가 뺐기 때문이다.
<!-- body:end -->
@@ -88,6 +88,8 @@ tech-log-frontend : 68538f2
## 확인하지 못한 것
이 결함이 났던 당시의 before/after 브라우저 캡처는 저장돼 있지 않다. 현재 화면을 당시 상태처럼 다시 꾸며 과거 증거로 쓰지 않는다.
이 검사가 보는 것은 CSS 선언이다. 실제로 그려진 크기는 아니다 — 다른 규칙이 덮으면 선언이 같아도 결과가 갈릴 수 있다.
<!-- body:end -->
@@ -84,7 +84,7 @@ tech-log-frontend : 15e6ea8 이후
축 CRUD 를 더한 커밋에서 가드를 둘 넣었다. 공개 계약은 전수 대조한다 — 계약이 선언한 연산이 기여 목록에 전부 있는지 본다.
관리 계약 86 operation 이라 전수 대조가 무겁다. 대신 「한 종류만 빠진 항목」을 본다. 깨진 것이 늘 그 모양이었기 때문이다.
관리 계약에는 86 operation 이 있다. 가드 범위를 좁힌 근거는 그 숫자 자체가 아니라 당시 반복해서 깨진 형태가 「한 종류만 빠진 항목」이었다는 점이다. 그래서 이 가드는 그 패턴을 보며, 연산 전체 누락까지 전수 대조한다고 말하지 않는다.
## 확인하지 못한 것
@@ -54,28 +54,18 @@ source:
모델 생성은 스키마와 속성만 본다. 구현이 없어도 모델은 멀쩡히 만들어진다.
### 6. 전수 대조가 무거우면 깨지는 모양으로 좁힌
### 6. 관찰된 실패 모양으로 검사 범위를 좁힐 때는 한계를 적는
관리 계약 86 operation 이라 전수 대조가 무겁다. 이 저장소는 대신 「한 종류만 빠진 항목」을 보게 했다 — 깨진 것이 늘 그 모양이었기 때문이다. 좁힌 기준은 무엇을 보지 않는지도 함께 적는다.
관리 계약에는 86 operation 이 있다. 검사 범위를 좁힌 이유는 그 숫자 자체가 아니라 당시 반복해서 깨진 형태가 「한 종류만 빠진 항목」이었기 때문이다. 그래서 관리 계약 쪽 가드는 그 패턴을 보며, 전체 계약과 구현의 동등성을 증명한다고 말하지 않는다.
## 적용 조건
- 계약이 한 저장소에 있고 두 저장소가 그것을 반입해 각자 구현하는 구조. 연산을 더하거나 지우는 변경에서 이 대조를 돌린다.
이 대조가 필요한 때는 계약이 한 저장소에 있고 두 저장소가 그것을 반입해 각자 구현하는 구조에서 연산을 더하거나 지울 때다. 화면이 「데이터가 없습니다」를 그리는데 저장소에는 값이 있을 때도 먼저 본다. 구현이 없어서 404가 난 경우와 정말 0건인 경우가 화면에서는 같은 빈 상태로 보이기 때문이다.
- 화면이 「데이터가 없습니다」를 그리는데 저장소에이 있을 때 이 대조를 먼저 본다. 구현이 없어서 404 인 것과 정말 0건인 것이 화면에서 같아 보인다.
실제 검증에서는 매핑 하나를 떼었을 때 대조 검사가 그 연산 하나를 정확히 짚는 것을 확인한 뒤 커밋했다. 두 목록 조회에 컨트롤러가 없던 때에는 홈 편집기가 「이 프로젝트에 열린 질문이 없습니다」를 그렸지만 실제로이 있었고 공개 사이트에도 나오고 있었다. 화면 쪽에서는 기여 목록에 등록하지 않은 연산 넷을 만났고, 둘은 옆 분기로 떨어져 다른 기록을 다뤘으며 둘은 빈 목록이 됐다.
## 예외
- 계약과 구현이 같은 저장소에 있고 같은 빌드를 지나면 컴파일러가 이 대조를 대신한다.
계약과 구현이 같은 저장소에 있어도 컴파일러가 대신할 수 있는 범위는 실제로 같은 타입이나 생성 산출물에 연결된 곳뿐이다. 문자열 라우트나 별도 컨트롤러 등록처럼 타입 시스템 밖에 있는 항목은 별도 대조가 필요하다. 봉투 규약을 따르지 않는 연산을 경로 대조에서 빼야 한다면, 제외 이유는 명시 목록에 남긴다.
- 연산이 봉투 규약을 따르지 않으면 경로 대조에서 뺀다. 다만 뺀 이유를 목록에 적는다.
## 예시
- 매핑 하나를 떼어 보고 대조 검사가 그 연산 하나를 정확히 짚는 것을 확인한 뒤 커밋했다.
- 두 목록 조회에 컨트롤러가 없어 홈 편집기가 「이 프로젝트에 열린 질문이 없습니다」를 그렸다. 실제로는 넷이 있었고 공개 사이트에도 나오고 있었다.
- 옛 연산 51개를 명시하지 않았다면 대조 결과가 51건의 실패로 나와 아무도 읽지 않았을 것이다.
- 기여 목록에 등록하지 않은 연산 넷을 만났다. 둘은 옆 분기로 떨어져 다른 기록을 다뤘고 둘은 빈 목록이 됐다.
연산 51개는 의도적으로 구현하지 않는 목록으로 분리해 대조 결과의 잡음을 줄였다. 이 면제 목록은 「구현이 빠진 것」과 「구현하지 않기로 한 것」을 구분하기 위한 것이며, 목록 밖의 누락까지 면제하지 않는다.
@@ -74,11 +74,13 @@ tech-log-frontend : 6e784ed · fd73bc8 · 3bb724b
거절만 잡는 처리로는 부족하다. 던지는 경로도 함께 잡아야 한 칸의 실패가 화면 전체로 번지지 않는다.
## 동기적으로 던질 수 있나
## 언제 동기적으로 던질 수 있나
게이트웨이 호출이 비동기 함수여도 그 안의 첫 줄이 동기적으로 실행된다. 인자를 검증하거나 연산을 고르는 코드가 거기 있고, 등록되지 않은 연산을 고르면 거기서 바로 던진다.
`async function` 본문에서 던진 예외는 호출자에게 rejected Promise 로 전달된다. 이 경우에는 `Promise.all` 의 rejection 경로로 들어간다.
그래서 「비동기 함수를 불렀으니 거절로 온다」는 전제가 성립하지 않는다.
별도로 봐야 하는 것은 Promise 를 반환하는 API처럼 보이지만 실제 구현이 일반 함수이고, Promise 를 만들기 전에 인자 검증이나 연산 선택 같은 동기 코드가 실행되는 경우다. 그 코드가 throw 하면 함수 호출 자체가 동기적으로 실패해 배열이 완성되지 않고 `Promise.all` 에 도달하지 못한다.
따라서 이 사건에서 확인할 기준은 「비동기 작업인가」가 아니라 「호출이 Promise 를 반환하기 전에 동기 throw 할 수 있는가」다.
## 탭에도 같은 판단을
@@ -9,6 +9,9 @@ status: 게시 전
lastVerifiedOn: 2026-09-04
evidence:
- ../../../final/evidence/raw/guards/kind-tables-now.txt
assets:
- key: record-kind-fanout
file: ../../../final/assets/diagrams/record-kind-fanout/record-kind-fanout.svg
sourceRevision: tech-log@2026-09-02
source:
- final/document.md#§3.1
@@ -90,6 +93,10 @@ CONCEPT 을 더해도 이 코드는 컴파일된다. 마지막 가지가 나머
## 열세 곳
열세 위치를 다시 카드로 늘어놓지 않고, `CONCEPT` 하나가 계약·백엔드·프론트엔드의 손 목록으로 퍼진 구조만 묶어서 본다. 정확한 열세 위치는 바로 아래 표가 맡는다.
![새 CONCEPT 종류 하나가 계약과 백엔드와 프론트엔드의 손 목록으로 퍼지는 구조](../../../final/assets/diagrams/record-kind-fanout/record-kind-fanout.svg)
| # | 어디 | 증상 | 커밋 |
|---|---|---|---|
| 1 | 게이트웨이의 문서 삭제 분기 | 개념을 지우면 "질문을 찾을 수 없습니다" | `dec86bd` |
@@ -15,7 +15,7 @@ source:
# nginx 가 모르는 라우트는 새로고침에서 404 다
/studio/releases 가 평문 404 를 돌려줬다. 라우트는 있고 청크도 빌드됐고 SPA 내부 이동으로는 화면에 닿는데, 하드 로드와 새로고침은 거기까지 가지 못한다. nginx 설정이 손으로 유지하는 배열에서 나오고 있었다.
/studio/releases 가 평문 404 를 돌려줬다. 라우트는 있고 청크도 빌드됐고 SPA(Single-Page Application) 내부 이동으로는 화면에 닿는데, 하드 로드와 새로고침은 거기까지 가지 못한다. nginx 설정이 손으로 유지하는 배열에서 나오고 있었다.
## 관계
@@ -7,6 +7,9 @@ topicName: 라우트 하나가 울리는 손 목록
project: TechLog
status: 게시 전
lastVerifiedOn: 2026-09-04
assets:
- key: route-fanout
file: ../../../final/assets/diagrams/route-fanout/route-fanout.svg
sourceRevision: tech-log@2026-09-02
source:
- final/document.md#§8.1
@@ -45,7 +48,7 @@ source:
## 검증 환경
tech-log-frontend : 048c1b2 · 197db74 · fe6b56a
CI : FE-GATE-009 — 라우트마다 수동 접근성 증거 1개
CI : 프론트엔드 게이트 `FE-GATE-009` — 라우트마다 수동 접근성 증거 1개
확인 방식 : 라우트를 더한 커밋 넷에서 기준값이 어떻게 움직였는지 대조
## 재현 조건
@@ -60,6 +63,10 @@ CI : FE-GATE-009 — 라우트마다 수동 접근성 증거 1개
## 여덟 곳
라우트 계약 하나에서 런타임·빌드·배포 검사가 갈라지고, 빠뜨린 곳에 따라 처음 드러나는 시점도 달라진다.
![라우트 계약에서 런타임·빌드·배포 검사가 갈라지고 실패가 서로 다른 시점에 드러나는 흐름](../../../final/assets/diagrams/route-fanout/route-fanout.svg)
개념 라우트를 더한 커밋이 그 목록을 남겼다.
```text
@@ -48,7 +48,7 @@ source:
라우트를 더할 때마다 서빙 패턴이 함께 움직인다. 이 비용은 라우트 계약에서 유도해 없앴다 — 손으로 배열을 고치지 않는다.
등록되지 않은 주소는 SPA 에 닿지 못한다. 라우트를 더하고 프론트를 배포하기 전까지 그 경로는 엣지에서 404 이고, 그래서 새 라우트는 프론트를 먼저 배포한다.
등록되지 않은 주소는 SPA(Single-Page Application)에 닿지 못한다. 라우트를 더하고 프론트를 배포하기 전까지 그 경로는 엣지에서 404 이고, 그래서 새 라우트는 프론트를 먼저 배포한다.
배포 뒤 감사에서 200 을 받은 35개 주소는 실제로 화면이 그려지는 주소다. catch-all 을 번역했다면 그 수는 아무 주소나 세어도 나왔을 것이다.
@@ -28,7 +28,7 @@ CI 게이트가 설치된 라우트마다 수동 접근성 증거 파일을 하
## 사실
FE-GATE-009 는 설치된 라우트마다 증거 파일 하나를 요구하고, 그 집합이 정확히 일치하지 않으면 거절한다.
`FE-GATE-009` 프론트엔드 게이트는 설치된 라우트마다 증거 파일 하나를 요구하고, 그 집합이 정확히 일치하지 않으면 거절한다.
정확한 일치를 요구하는 이유는 빠뜨림이 통과가 되지 않게 하려는 것이다. 파일이 더 많아도 더 적어도 거절한다.
@@ -56,7 +56,7 @@ review:a11y-manual 스크립트는 그래서 실패하는 것이 지금은 정
## 제약
FE-GATE-009 는 라우트 집합과 증거 집합이 정확히 일치하기를 요구한다. 이 규칙은 바꾸지 않는다 — 빠뜨림이 통과가 되면 게이트가 아니다.
프론트엔드 게이트 `FE-GATE-009`는 라우트 집합과 증거 집합이 정확히 일치하기를 요구한다. 이 규칙은 바꾸지 않는다 — 빠뜨림이 통과가 되면 게이트가 아니다.
수동 접근성 증거는 사람이 만든다. 자동 검사로 대신하지 않는다.
@@ -40,7 +40,7 @@ source:
### 2. 빌드가 아는 목록을 서빙 계약의 근거로 쓰지 않는다
번들된 픽스처에 우연히 들어 있던 경로를 열거하면 그 목록이 빌드 시점에 얼어붙는다. location = 은 정확히 일치하는 경로만 잡으므로, 빌드 이후에 게시된 기록은 SPA 에 묻기도 전에 엣지에서 404 가 된다.
번들된 픽스처에 우연히 들어 있던 경로를 열거하면 그 목록이 빌드 시점에 얼어붙는다. location = 은 정확히 일치하는 경로만 잡으므로, 빌드 이후에 게시된 기록은 SPA(Single-Page Application)에 묻기도 전에 엣지에서 404 가 된다.
### 3. 유도할 수 없는 목록에는 대조 검사를 둔다
@@ -35,25 +35,17 @@ source:
고정 문구를 두는 이유는 예외의 원문 메시지에 저장소 제약 이름이나 SQL 조각이 섞일 수 있어서다. 그 판단은 서버 쪽 클래스의 javadoc 에 적혀 있다.
참조 검사는 다섯 표를 하나의 존재 검사로 묶는다.
sql
SELECT 1 FROM document_relation WHERE target_document_id = :id
UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id
UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id
UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id
UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
참조 검사는 다섯 표를 하나의 존재 검사로 묶는다. 실제 SQL은 이 기록의 근거인 `final/document.md` §16.1에 정상 SQL 코드 블록으로 남겨 두었다.
같은 어댑터의 질문 삭제는 참조가 둘뿐이라 같은 문제가 덜하다 — project_question_link 와 home_focus_config.open_question_id 다.
실제 사례에서 관계를 다 지워도 삭제가 안 됐다. 남아 있던 것은 프로젝트 링크 한 행이었고, 그 링크는 「관계」 편집기가 아니라 문서의 Project 필드가 만든다.
실제 사례에서 Studio의 「관계」 편집기로 만든 연결을 다 지워도 삭제가 안 됐다. 남아 있던 것은 `project_document_link` 한 행이었고, 그 링크는 「관계」 편집기가 아니라 문서의 Project 필드가 만든다.
사용자는 Project 필드를 「미지정」으로 바꾸고 저장한 뒤 삭제했다.
## 가정
문구가 「another record」라고 하니 사용자가 관계를 먼저 찾는다고 보고 있다. 실제 사례가 하나이고, 다른 사용자가 같은 순서로 움직이는지는 확인하지 않았다.
문구가 「another record」라고 하니 사용자가 Studio의 「관계」 편집기를 먼저 확인한다고 보고 있다. 실제 사례가 하나이고, 다른 사용자가 같은 순서로 움직이는지는 확인하지 않았다.
다섯 참조를 종류별로 갈라도 응답 시간이 문제가 되지 않는다고 보고 있다. 하나의 존재 검사를 다섯 개로 나누는 비용은 재지 않았다.
@@ -84,7 +76,7 @@ UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
문구가 코드마다 하나이므로 코드를 나누면 문구도 갈린다. 대신 계약의 열거형이 늘고 반입한 두 저장소가 함께 움직인다.
**막는 참조를 목록으로 돌려준다**
어느 기록이 걸었는지까지 보인다. 관계는 이름을 보일 수 있지만 프로젝트 링크와 주제 대표 기록은 다른 화면이라 이름만으로는 어디를 고칠지 알기 어렵다.
어느 기록이 걸었는지까지 보인다. 문서 relation은 연결된 기록 이름을 보일 수 있지만 프로젝트 링크와 주제 대표 기록은 다른 화면이라 이름만으로는 어디를 고칠지 알기 어렵다.
**문구만 고쳐 프로젝트 연결을 함께 언급한다**
가장 싸다. 다섯 중 어느 것인지는 여전히 말하지 못하고, 사용자가 확인할 화면이 셋으로 늘어난다.
@@ -23,7 +23,7 @@ source:
- **배포 전에 사람이 돌려야 하는 것과 그 함정**
이 사건 뒤에 목록으로 굳혔다.
- **컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다**
같은 배포에서 드러난 다른 사건이다.
같은 배포에서 드러난 다른 사건이다. 여기서 SPA는 Single-Page Application을 뜻한다.
## 문제
@@ -15,7 +15,7 @@ source:
# 컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다
컨테이너는 healthy 로 올라왔는데 SPA 가 부팅되지 않았다. nginx 가 설정 파일 하나를 읽지 못해 그 파일만 403 을 돌려줬다. 빌드가 그 파일을 0600 으로 쓰고 있었다.
컨테이너는 healthy 로 올라왔는데 SPA(Single-Page Application)가 부팅되지 않았다. nginx 가 설정 파일 하나를 읽지 못해 그 파일만 403 을 돌려줬다. 빌드가 그 파일을 0600 으로 쓰고 있었다.
## 관계
@@ -68,7 +68,7 @@ SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다.
빌드가 그 설정 파일을 0600 으로 쓴다. 파일을 만든 사용자만 읽을 수 있고, nginx 를 돌리는 사용자는 다른 사용자다.
증상이 404 가 아니라 403 이라는 것이 원인을 좁혔다. 404 면 파일이 없는 것이고 403 이면 파일은 있는데 읽지 못하는 것이므로, 이미지에 파일이 들어갔는지부터 확인할 필요가 없었다.
증상이 404 가 아니라 403 이라는 것은 이 nginx 구성에서 파일 접근 권한을 먼저 의심할 단서였다. 다만 HTTP 403 자체가 「파일은 존재하지만 읽지 못한다」를 보장하지는 않는다. nginx 의 deny 규칙이나 앞단 인증·인가에서도 403 이 날 수 있다. 이 사건은 이미지 안의 파일 mode 가 0600 인 것을 확인하면서 권한 문제로 확정했다.
이미지가 권한을 정규화하도록 고쳤다. 빌드 단계에서 쓰는 권한을 바꾸는 대신 이미지가 마지막에 정리하게 한 것은, 빌드 도구가 그 권한을 왜 그렇게 쓰는지가 이 저장소 밖의 사정이기 때문이다.
@@ -22,7 +22,7 @@ source:
- **배포 인자를 빠뜨려 배포본이 존재하지 않는 주소를 불렀다**
이 경로에서 빌드 인자가 어떻게 새는지가 그 기록에 있다.
- **컨테이너는 healthy 였고 SPA 가 부팅에 필요한 파일 하나만 403 이었다**
이미지 안의 권한이 배포에서 드러난 사건이다.
이미지 안의 권한이 배포에서 드러난 사건이다. 여기서 SPA는 Single-Page Application을 뜻한다.
- **배포 전에 사람이 돌려야 하는 것과 그 함정**
이 경로에서 사람이 기억해야 하는 것들이 그 기준에 있다.
@@ -32,12 +32,17 @@ source:
## 레지스트리를 쓰지 않는다
이 블록은 복사해 실행하는 runbook이 아니라 배포 단계의 순서만 보여 주는 reference schematic이다.
```text
로컬 docker build → docker save | gzip → scp dh-server:/tmp/deploy.tar.gz
kube-system 의 containerd import Job → kubectl set image
로컬 이미지 빌드
이미지 tar 압축
→ 서버로 전송
→ 일회성 containerd import Job
→ 배포 이미지 교체
```
공개 Hub 는 소스가 들어간 이미지라 쓸 수 없다. k3s 의 containerd 소켓은 root 전용이라 사용자 셸에서 닿지 않는다. 그래서 클러스터 안 일회성 Job 을 띄워 tar 를 import 한다 — Job 은 클러스터 권한으로 도므로 그 소켓에 닿는다.
공개 Hub 는 소스가 들어간 이미지라 쓸 수 없다. k3s 의 containerd 소켓은 root 전용이라 사용자 셸에서 닿지 않는다. 그래서 클러스터 안 일회성 Job 으로 tar 를 import 하는 경로를 쓴다. 다만 Job 이 `kube-system` 에 있거나 클러스터 RBAC 권한을 가진다는 이유만으로 host 소켓에 접근할 수 있는 것은 아니다. 이 경로에는 host 의 containerd 소켓을 명시적으로 mount 하고 그 소켓을 열 수 있는 권한으로 실행한다는 전제가 필요하다.
배포 단위는 `hyeonworks.com` 하나이고 서브도메인을 쓰지 않는다. 공개는 `/`, API 는 `/api` 다.
@@ -7,6 +7,9 @@ topicName: 테스트가 지나지 않는 이음매
project: TechLog
status: 게시 전
lastVerifiedOn: 2026-09-04
assets:
- key: composition-root-seam
file: ../../../final/assets/diagrams/composition-root-seam/composition-root-seam.svg
sourceRevision: tech-log@2026-09-02
source:
- final/document.md#§7.4
@@ -80,6 +83,10 @@ tech-log-frontend : 03986da · 7600711
## 스텁이 이음매를 덮지 않는다
화면 테스트는 게이트웨이에서, 게이트웨이 테스트는 실행기에서 스텁으로 끊겼다. 실제 런타임에서만 이어지는 합성 루트의 credential 결정은 두 테스트 경로 사이에 비어 있었다.
![화면 테스트와 게이트웨이 테스트가 각각 스텁에서 멈추고 실제 런타임만 합성 루트의 credential 결정을 지나는 구조](../../../final/assets/diagrams/composition-root-seam/composition-root-seam.svg)
> 이 결함은 공개 소스가 HTTP 가 된 뒤에야 나타날 수 있었다. 이번 주까지 그 경로는 브라우저에서 한 번도 돌지 않았다. **스위트가 잡지 못한 이유는 게이트웨이와 화면을 검사할 뿐 합성 루트의 credential 결정은 검사하지 않기 때문이다 — 그 이음매에는 테스트가 없고, 이것이 그 대가다.**
게이트웨이 테스트는 실행기를 스텁으로 바꾸고 화면 테스트는 게이트웨이를 스텁으로 바꾼다. 둘 다 자기 층은 검사하지만 그 사이에서 credential 을 정하는 코드는 어느 쪽에도 들어가지 않는다.
+26 -10
View File
@@ -32,7 +32,7 @@
}
],
"sourceRevision": "tech-log@2026-09-02",
"generatedAt": "2026-09-07",
"generatedAt": "2026-09-18",
"candidateScope": {
"document": "final/document.md",
"sections": [
@@ -125,8 +125,12 @@
"file": "hand-listed-kinds/case/case-one-new-kind-fell-through-thirteen-places.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"assets": [
"record-kind-fanout"
],
"assetFiles": [
"record-kind-fanout"
],
"evidenceFiles": [
"../../../final/evidence/raw/guards/kind-tables-now.txt"
]
@@ -388,8 +392,12 @@
"file": "values-lost-between-boundaries/case/case-a-summary-vanished-at-three-boundaries.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"assets": [
"summary-drop-path"
],
"assetFiles": [
"summary-drop-path"
],
"evidenceFiles": []
},
{
@@ -703,8 +711,12 @@
"file": "seams-no-test-crosses/case/case-the-composition-root-had-no-test.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"assets": [
"composition-root-seam"
],
"assetFiles": [
"composition-root-seam"
],
"evidenceFiles": []
},
{
@@ -830,8 +842,12 @@
"file": "one-route-many-hand-kept-lists/case/case-eight-places-a-single-route-touches.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"assets": [
"route-fanout"
],
"assetFiles": [
"route-fanout"
],
"evidenceFiles": []
}
],
@@ -3026,5 +3042,5 @@
"unlisted": 0,
"candidates": 91
},
"ssotSha256": "974abab805e33daa531fa23fde85309c0de17becf6c5e460ef5ff00d057e20c9"
"ssotSha256": "ce1a912be009678ebea0099501f63c96dfa11cd7f873c93f14fdddb2c46e3911"
}
@@ -23,7 +23,7 @@ source:
- **계약은 앵커라고 적었고 만드는 쪽은 경로를 만들었다**
같은 앵커 구조에서 난 주소 쪽 사건이다.
- **관계의 요약이 경계 세 곳을 지나며 사라졌다**
같은 시기에 계약의 빈칸으로 난 다른 사건이다.
같은 시기에 계약의 빈칸으로 난 다른 사건이다. 여기서 요약은 공개 계약의 `ResolvedRelation.summary`다.
## 문제
@@ -7,6 +7,9 @@ topicName: 값이 경계에서 사라진다
project: TechLog
status: 게시 전
lastVerifiedOn: 2026-09-04
assets:
- key: summary-drop-path
file: ../../../final/assets/diagrams/summary-drop-path/summary-drop-path.svg
sourceRevision: tech-log@2026-09-02
source:
- final/document.md#§5.2
@@ -15,7 +18,7 @@ source:
# 관계의 요약이 경계 세 곳을 지나며 사라졌다
관계 목록의 라벨을 고쳤는데 요약은 여전히 비어 있었다. 한 경계를 고치고 확인했더니 다음 경계가 버리고 있었고, 그것을 고치니 그다음이 버렸다. 세 번째는 계약에 담을 칸 자체가 없었다.
공개 relation 목록의 라벨을 고쳤는데 `ResolvedRelation.summary` 여전히 비어 있었다. 한 경계를 고치고 확인했더니 다음 경계가 버리고 있었고, 그것을 고치니 그다음이 버렸다. 세 번째는 계약에 담을 칸 자체가 없었다.
## 관계
@@ -28,7 +31,7 @@ source:
## 문제
관계 목록은 한 줄에 대상의 종류와 작성자가 쓴 이유와 대상의 요약을 보인다. 라벨은 고쳤는데 요약 칸이 계속 비어 있었다.
공개 relation 목록은 한 줄에 대상의 종류와 작성자가 쓴 이유와 대상의 요약을 보인다. 라벨은 고쳤는데 요약 칸이 계속 비어 있었다.
계약에는 요약이 있었다. DB 에도 값이 있었다. 화면까지 오지 못했다.
@@ -63,6 +66,10 @@ tech-log-backend : 92679f5 이후
## 세 번 버려졌다
이 그림은 열한 경계 전체가 아니라 이번 사고에서 `summary`가 실제로 끊긴 세 지점만 좁혀 본다.
![summary가 계약에서 flattenRelations, ResolvedRelation, 화면 목록을 지나며 세 번 끊긴 경로](../../../final/assets/diagrams/summary-drop-path/summary-drop-path.svg)
관계 목록의 라벨을 고치고 화면을 봤을 때 요약은 여전히 비어 있었다. 값이 지나는 경계를 하나씩 따라가니 세 곳에서 버려지고 있었다.
```text
@@ -25,7 +25,7 @@ source:
- **공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다**
이 경계 중 두 곳에서 값이 사라진 사건이다.
- **관계의 요약이 경계 세 곳을 지나며 사라졌다**
한 값이 연달아 세 경계에서 버려진 사건이다.
한 값이 연달아 세 경계에서 버려진 사건이다. 여기서 관계 요약은 `ResolvedRelation.summary`를 뜻한다.
- **한 경계를 고쳤으면 값의 여정 끝에서 확인한다**
이 경계 수가 그 규칙의 근거다.
@@ -51,7 +51,7 @@ PostgreSQL 테이블
└─ 화면 컴포넌트
```
![저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 세 저장소 구역으로 나눠 이은 흐름도](../../../final/assets/diagrams/value-boundaries/value-boundaries.svg)
![열한 개 경계를 저장·백엔드 조립·전선·프론트엔드 조립·화면 다섯 구간으로 묶은 흐름도](../../../final/assets/diagrams/value-boundaries/value-boundaries.svg)
저장 쪽에 둘, 백엔드 조립에 넷, 전선에 하나, 프론트엔드 조립에 셋, 화면에 하나다. 저장소 경계로 보면 백엔드가 여섯, 전선이 하나, 프론트엔드가 넷이다.
@@ -15,7 +15,7 @@ source:
# Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다
Studio 편집기에서는 값이 다 보이는데 공개 화면만 비어 있면, 두 화면이 같은 DB 를 보고 있으므로 그 사이의 계약에 칸이 없다. 이 저장소에서 같은 신호가 여덟 번 같은 원인을 가리켰다.
Studio 편집기에서는 값이 다 보이는데 공개 화면만 비어 있면, 작성과 공개 사이의 계약 경계를 먼저 확인할 만한 강한 신호다. 이 저장소에서 같은 신호가 여덟 번 계약 누락을 가리켰지만, 저장 구조가 종류별로 다른 경우에는 조회 로직이 원인일 수 있다.
## 관계
@@ -36,7 +36,7 @@ Studio 편집기에서는 값이 다 보이는데 공개 화면만 비어 있으
### 1. Studio 에서는 보이고 공개 쪽만 비면 그 사이의 계약을 먼저 본다
두 화면이 같은 데이터베이스를 보는데 한쪽만 비면, 다른 것은 그 사이에 놓인 계약이다. 작성 쪽은 작성 계약을 지나고 조회 쪽은 조회 계약을 지난다. 이 저장소에서 같은 신호가 여덟 번 같은 원인을 가리켰다.
두 화면이 같은 데이터베이스를 보는데 한쪽만 비면, 먼저 두 화면 사이의 계약 차이를 확인한다. 작성 쪽은 작성 계약을 지나고 조회 쪽은 조회 계약을 지난다. 이 저장소에서 같은 신호가 여덟 번 계약 누락을 가리켰다. 이름과 계약이 맞는데도 값이 비면 종류별 저장 구조와 조회 로직으로 범위를 옮긴다.
### 2. 화면이 그리는 칸을 먼저 적고 응답에 있는지 하나씩 맞춘다
@@ -21,7 +21,7 @@ source:
## 관계
- **관계의 요약이 경계 세 곳을 지나며 사라졌다**
한 경계를 고치고 판단해 두 번 틀린 사건이다.
한 경계를 고치고 판단해 두 번 틀린 사건이다. 여기서 요약은 공개 계약의 `ResolvedRelation.summary`다.
- **공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개**
왜 중간 확인이 부족한지가 그 개념에 있다.
- **TypeScript 가 검사를 놓아 주는 네 곳**
@@ -79,7 +79,7 @@ tsconfig : 루트가 project references 만 나열
| web-worker | 웹 워커 |
| service-worker | 서비스 워커 |
여섯을 따로 두는 이유는 각각 다른 런타임 타입 정의를 쓰기 때문이다. 워커는 DOM 을 갖지 않고 node 는 브라우저 전역을 갖지 않는다.
여섯을 따로 두는 이유는 각각 다른 런타임 타입 정의를 쓰기 때문이다. 워커는 DOM(Document Object Model, 문서 객체 모델)을 갖지 않고 node 는 브라우저 전역을 갖지 않는다.
## 통과가 무엇을 뜻했나
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,23 +1,23 @@
{
"version": "1.1",
"id": "redis-admission-stages",
"title": "명령 입장의 단일 지점",
"question": "명령은 어디서 걸러지는가?",
"type": "dependency",
"title": "의도된 guarded path와 실제 direct gateway 우회",
"question": "Redis 명령은 guard를 통과하는가, semantic adapter에서 gateway로 우회하는가?",
"type": "flow",
"direction": "LR",
"audience": [
"이 저장소의 구조를 읽는 사람"
"백엔드 엔지니어"
],
"summary": "카탈로그를 통과하면 실행이고 미분류나 BLOCKED 이면 fail-closed 로 거절된다.",
"alt": "CommandPolicyGuard 에서 카탈로그 통과는 실행으로 미분류와 BLOCKED 는 거절로 갈린다.",
"long_description": "SSOT 는 이 구조의 바닥이 카탈로그가 미분류 명령을 fail-closed 로 거부하는 것이라고 적는다.",
"summary": "guarded command path에서는 CommandPolicyGuard가 admission을 담당하지만, 현재 semantic adapter 다섯은 gateway를 직접 호출해 이 경로를 우회한다.",
"alt": "의도된 command path는 CommandPolicyGuard를 거쳐 실행 또는 fail-closed 거절로 갈리고, 별도의 semantic adapter 경로는 RedisCommandGateway를 직접 호출해 guard를 우회하는 흐름도",
"long_description": "CommandPolicyGuard는 guarded command path의 admission 지점이다. 그러나 현재 semantic adapter 다섯은 RedisLease에서 gateway를 직접 얻어 호출하므로 catalog, permit, slot, budget, translation, observation 단계가 이 경로에 적용되지 않는다.",
"source_context": {
"document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md",
"document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
"document": "docs/clean-architecture-backend-template/final/document.md",
"document_sha256": "7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8",
"anchor": {
"kind": "line",
"value": 13463,
"line": 13463
"value": 13469,
"line": 13469
}
},
"composition": {
@@ -26,20 +26,33 @@
"reference_ids": [
"payment-event-flow"
],
"rationale": "한 지점이 모든 명령을 두 결과로 가른다는 것이 논지다.",
"focus_node": "guard"
"rationale": "의도된 guarded path와 실제 bypass path를 한 화면에서 대비해야 전체 runtime의 single admission으로 오해하지 않는다.",
"focus_node": "bypass"
},
"groups": [],
"nodes": [
{
"id": "guard",
"label": "CommandPolicyGuard",
"id": "typed-entry",
"label": "guarded command path",
"kind": "service",
"role": "source",
"evidence": [
{
"start_line": 13463,
"end_line": 13490
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false
},
{
"id": "guard",
"label": "CommandPolicyGuard",
"kind": "service",
"role": "service",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false,
@@ -47,69 +60,150 @@
},
{
"id": "run",
"label": "실행",
"kind": "service",
"label": "승인 후 실행",
"kind": "result",
"role": "sink",
"evidence": [
{
"start_line": 13463,
"end_line": 13490
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false,
"details": [
"카탈로그 통과"
]
"assumption": false
},
{
"id": "deny",
"label": "fail-closed 거절",
"kind": "result",
"role": "sink",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false
},
{
"id": "semantic",
"label": "semantic adapters ×5",
"kind": "service",
"role": "source",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false
},
{
"id": "gateway",
"label": "RedisCommandGateway 직접 호출",
"kind": "service",
"role": "sink",
"evidence": [
{
"start_line": 13463,
"end_line": 13497
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false
},
{
"id": "bypass",
"label": "guard stages 우회",
"kind": "result",
"role": "sink",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false,
"emphasis": "primary",
"details": [
"미분류 · BLOCKED"
"catalog · permit · slot · budget",
"translation · observation"
]
}
],
"edges": [
{
"id": "a",
"id": "entry",
"from": "typed-entry",
"to": "guard",
"label": "admission",
"kind": "request",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false
},
{
"id": "pass",
"from": "guard",
"to": "run",
"label": "통과",
"kind": "request",
"kind": "data",
"evidence": [
{
"start_line": 13463,
"end_line": 13490
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false,
"emphasis": "primary"
"assumption": false
},
{
"id": "b",
"id": "reject",
"from": "guard",
"to": "deny",
"label": "거절",
"kind": "data",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false
},
{
"id": "direct",
"from": "semantic",
"to": "gateway",
"label": "lease.gateway()",
"kind": "request",
"evidence": [
{
"start_line": 13463,
"end_line": 13497
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false,
"style": "dashed"
},
{
"id": "skips",
"from": "gateway",
"to": "bypass",
"label": "guard 미경유",
"kind": "data",
"evidence": [
{
"start_line": 13469,
"end_line": 13503
}
],
"assumption": false,
"style": "dashed",
"emphasis": "primary"
}
],
"legend": [],
"metadata": {}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,140 +1,146 @@
{
"version": "1.1",
"id": "rls-three-preconditions",
"title": "격리가 성립하는 세 조건",
"question": "RLS 격리는 무엇이 동시에 참이어야 성립하는가?",
"type": "sequence",
"direction": "LR",
"title": "PostgreSQL RLS 적용 여부를 가르는 분기",
"question": "현재 role과 table에서 RLS policy가 실제로 적용되는가?",
"type": "data-flow",
"direction": "TB",
"audience": [
"이 저장소의 구조를 읽는 사람"
"백엔드 엔지니어"
],
"summary": "ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 런타임 롤 셋이 동시에 참이어야 격리가 성립한다.",
"alt": "격리 판정이 ENABLE RLS 와 FORCE RLS 와 BYPASSRLS 없는 롤을 차례로 확인하는 순서.",
"long_description": "SSOT 는 RlsPolicyVerifier.requireEnforced 가 런타임 롤의 BYPASSRLS 를 확인하고 current_schema() 의 실제 테이블을 순회하며 tenant-scoped 목록에 든 것만 검사한다고 적는다.",
"summary": "RLS 활성 여부, 우회 role, owner와 FORCE RLS, applicable policy 유무를 차례로 구분한다.",
"alt": "RLS 비활성은 policy 미적용으로, superuser와 BYPASSRLS는 우회로, owner는 FORCE 여부로 갈리고, policy 대상인데 applicable policy가 없으면 default deny가 되는 흐름도",
"long_description": "PostgreSQL RLS를 세 개의 동시 전제로 보지 않는다. RLS가 활성화된 뒤 superuser 또는 BYPASSRLS인지, table owner인지와 FORCE RLS 여부를 확인한다. policy 대상 role에 applicable policy가 없으면 default deny이고, policy가 있으면 USING과 WITH CHECK를 평가한다.",
"source_context": {
"document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md",
"document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d",
"document": "docs/clean-architecture-backend-template/final/document.md",
"document_sha256": "7c986b30b6ef3c12060b6749ee60d53e37d6994493d2703419732c9cab6077d8",
"anchor": {
"kind": "line",
"value": 7398,
"line": 7398
"value": 7400,
"line": 7400
}
},
"composition": {
"profile": "sequence",
"profile": "two-zone-pipeline",
"diagram_only": true,
"reference_ids": [
"payment-approval-sequence"
"localization-pipeline"
],
"rationale": "세 조건이 차례로 확인되어야 격리가 성립한다는 순서가 논지다.",
"focus_node": "verifier"
"rationale": "정책 적용 여부를 결정하는 검사 경로와 각 단계에서 빠져나가는 결과를 짧은 파이프라인으로 보여 준다.",
"focus_node": "policy"
},
"groups": [],
"nodes": [
"groups": [
{
"id": "start",
"label": "격리 판정",
"kind": "service",
"role": "participant",
"id": "role",
"label": "policy 적용 대상 판정",
"evidence": [
{
"start_line": 7396,
"end_line": 7420
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false
},
{
"id": "verifier",
"label": "RlsPolicyVerifier",
"kind": "service",
"role": "participant",
"id": "policy-zone",
"label": "policy 존재와 평가",
"evidence": [
{
"start_line": 7396,
"end_line": 7420
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false
}
],
"nodes": [
{
"id": "rls",
"label": "RLS 활성 여부",
"kind": "process",
"role": "stage",
"evidence": [
{
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false,
"emphasis": "primary"
"group": "role",
"details": [
"no → policy 미적용"
]
},
{
"id": "db",
"label": "데이터베이스",
"kind": "database",
"role": "participant",
"id": "subject",
"label": "policy 적용 대상",
"kind": "process",
"role": "stage",
"evidence": [
{
"start_line": 7396,
"end_line": 7426
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false
"assumption": false,
"group": "role",
"details": [
"superuser / BYPASSRLS → 우회",
"owner + FORCE off → 우회",
"non-owner 또는 owner + FORCE on → 대상"
]
},
{
"id": "policy",
"label": "applicable policy",
"kind": "process",
"role": "stage",
"evidence": [
{
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false,
"group": "policy-zone",
"emphasis": "primary",
"details": [
"none → default deny",
"exists → USING / WITH CHECK 평가"
]
}
],
"edges": [
{
"id": "m1",
"from": "start",
"to": "verifier",
"label": "검증 요청",
"id": "e1",
"from": "rls",
"to": "subject",
"label": "RLS on",
"kind": "request",
"evidence": [
{
"start_line": 7396,
"end_line": 7420
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false,
"order": 1
"assumption": false
},
{
"id": "m2",
"from": "verifier",
"to": "db",
"label": "ENABLE RLS 확인",
"id": "e2",
"from": "subject",
"to": "policy",
"label": "적용",
"kind": "request",
"evidence": [
{
"start_line": 7396,
"end_line": 7420
"start_line": 7400,
"end_line": 7432
}
],
"assumption": false,
"order": 2
},
{
"id": "m3",
"from": "verifier",
"to": "db",
"label": "FORCE RLS 확인",
"kind": "request",
"evidence": [
{
"start_line": 7396,
"end_line": 7420
}
],
"assumption": false,
"order": 3
},
{
"id": "m4",
"from": "verifier",
"to": "db",
"label": "BYPASSRLS 없음 확인",
"kind": "request",
"evidence": [
{
"start_line": 7396,
"end_line": 7426
}
],
"assumption": false,
"order": 4,
"emphasis": "primary"
}
],
"legend": [],
"metadata": {}
}
}
@@ -1,20 +1,27 @@
# 명령 입장의 단일 지점
# 의도된 guarded path와 실제 direct gateway 우회
## Alternative text
CommandPolicyGuard 에서 카탈로그 통과는 실행으로 미분류와 BLOCKED 는 거절로 갈린다.
의도된 command path는 CommandPolicyGuard를 거쳐 실행 또는 fail-closed 거절로 갈리고, 별도의 semantic adapter 경로는 RedisCommandGateway를 직접 호출해 guard를 우회하는 흐름도
## Long description
SSOT 는 이 구조의 바닥이 카탈로그가 미분류 명령을 fail-closed 로 거부하는 것이라고 적는다.
CommandPolicyGuard는 guarded command path의 admission 지점이다. 그러나 현재 semantic adapter 다섯은 RedisLease에서 gateway를 직접 얻어 호출하므로 catalog, permit, slot, budget, translation, observation 단계가 이 경로에 적용되지 않는다.
## Elements and evidence
- **CommandPolicyGuard** (service): No additional description. Evidence: L13463L13490.
- **실행** (service): No additional description. Evidence: L13463L13490.
- **fail-closed 거절** (service): No additional description. Evidence: L13463L13497.
- **guarded command path** (service): No additional description. Evidence: L13469L13503.
- **CommandPolicyGuard** (service): No additional description. Evidence: L13469L13503.
- **승인 후 실행** (result): No additional description. Evidence: L13469L13503.
- **fail-closed 거절** (result): No additional description. Evidence: L13469L13503.
- **semantic adapters ×5** (service): No additional description. Evidence: L13469L13503.
- **RedisCommandGateway 직접 호출** (service): No additional description. Evidence: L13469L13503.
- **guard stages 우회** (result): No additional description. Evidence: L13469L13503.
## Relationships
- **CommandPolicyGuard → 실행:** 통과. Evidence: L13463L13490.
- **CommandPolicyGuard → fail-closed 거절:** 거절. Evidence: L13463L13497.
- **semantic adapters ×5 → RedisCommandGateway 직접 호출:** lease.gateway(). Evidence: L13469L13503.
- **guarded command path → CommandPolicyGuard:** admission. Evidence: L13469L13503.
- **CommandPolicyGuard → 승인 후 실행:** 통과. Evidence: L13469L13503.
- **CommandPolicyGuard → fail-closed 거절:** 거절. Evidence: L13469L13503.
- **RedisCommandGateway 직접 호출 → guard stages 우회:** guard 미경유. Evidence: L13469L13503.
@@ -1,14 +1,29 @@
# 명령 입장의 단일 지점
# Question: 명령은 어디서 걸러지는가?
# 의도된 guarded path와 실제 direct gateway 우회
# Question: Redis 명령은 guard를 통과하는가, semantic adapter에서 gateway로 우회하는가?
direction: right
n0: "CommandPolicyGuard" {
n0: "guarded command path" {
shape: rectangle
}
n1: "실행" {
n1: "CommandPolicyGuard" {
shape: rectangle
}
n2: "fail-closed 거절" {
n2: "승인 후 실행" {
shape: rectangle
}
n0 -> n1: "통과"
n0 -> n2: "거절"
n3: "fail-closed 거절" {
shape: rectangle
}
n4: "semantic adapters ×5" {
shape: rectangle
}
n5: "RedisCommandGateway 직접 호출" {
shape: rectangle
}
n6: "guard stages 우회" {
shape: rectangle
}
n0 -> n1: "admission"
n1 -> n2: "통과"
n1 -> n3: "거절"
n4 -> n5: "lease.gateway()"
n5 -> n6: "guard 미경유"

Some files were not shown because too many files have changed in this diff Show More