feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
@@ -33,7 +33,7 @@
"groups": [
{
"id": "record-tables",
"label": "기록은 종류마다 다른 테이블에 산다",
"label": "종류별 테이블",
"kind": "system",
"role": "zone",
"evidence": [
@@ -53,7 +53,7 @@
"shape": "box",
"role": "source",
"details": [
"variant_label 로 축 이름을 정한다"
"variant_label"
],
"description": "주제. 축의 이름을 주제가 정한다.",
"evidence": [
@@ -10,7 +10,7 @@
## Elements and evidence
- **Boundary: 기록은 종류마다 다른 테이블에 산다** (system): No additional description. Evidence: L1071L1073.
- **Boundary: 종류별 테이블** (system): No additional description. Evidence: L1071L1073.
- **topic** (database): 주제. 축의 이름을 주제가 정한다. Evidence: L1057L1059, L1067L1068.
- **topic_variant** (database): 축의 값들. Evidence: L1060L1060, L1047L1048.
- **record_variant** (database): 어느 기록이 어느 축에 걸리는지 적는 자리. 외래키를 걸지 못한다. Evidence: L1061L1061, L1071L1073.
@@ -1,7 +1,7 @@
# 주제 안의 축과 기록을 잇는 자리
# Question: 하나의 질문에 대한 네 답을 무엇으로 담고, 기록은 어떻게 축에 걸리는가?
direction: right
g0: "기록은 종류마다 다른 테이블에 산다" {
g0: "종류별 테이블" {
n3: "document" {
shape: sql_table
}
@@ -1,54 +1,54 @@
<?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="topic-variant-model" name="주제 안의 축과 기록을 잇는 자리">
<mxGraphModel dx="1444" dy="488" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1444" pageHeight="1169" math="0" shadow="0">
<mxGraphModel dx="1385" dy="488" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1385" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<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="1189.0" y="35.0" width="210.0" height="408.0" as="geometry"/>
<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">
<mxGeometry x="70.0" y="213.5" width="209.0" height="71.0" as="geometry"/>
<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">
<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">
<mxGeometry x="439.0" y="213.5" width="279.0" height="71.0" as="geometry"/>
<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">
<mxGeometry x="878.0" y="213.5" width="181.0" height="71.0" as="geometry"/>
<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">
<mxGeometry x="1219.0" y="81.0" width="150.0" height="64.0" as="geometry"/>
<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">
<mxGeometry x="1219.0" y="217.0" width="150.0" height="64.0" as="geometry"/>
<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">
<mxGeometry x="1219.0" y="353.0" width="150.0" height="64.0" as="geometry"/>
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="359.0" y="221.0" as="offset"/>
<mxPoint x="300.0" y="221.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t2" value="축에 건다" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_topic-variant" target="n_record-variant">
<mxGeometry relative="1" as="geometry">
<mxPoint x="798.0" y="221.0" as="offset"/>
<mxPoint x="739.0" y="221.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t3" value="(kind, id)" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_record-variant" target="n_document">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1163.0" y="172.0" as="offset"/>
<mxPoint x="1104.0" y="172.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t4" value="(kind, id)" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_record-variant" target="n_open-question">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1139.0" y="221.0" as="offset"/>
<mxPoint x="1080.0" y="221.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_t5" value="(kind, id)" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_record-variant" target="n_project-decision">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1163.0" y="326.0" as="offset"/>
<mxPoint x="1104.0" y="326.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
@@ -6,7 +6,7 @@
{
"id": "group-record-tables",
"type": "rectangle",
"x": 1189.0,
"x": 1130.0,
"y": 35.0,
"width": 210.0,
"height": 408.0,
@@ -36,9 +36,9 @@
{
"id": "group-label-record-tables",
"type": "text",
"x": 1205.0,
"x": 1146.0,
"y": 41.0,
"width": 171,
"width": 100,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -64,18 +64,18 @@
"locked": false,
"fontSize": 14,
"fontFamily": 5,
"text": "기록은 종류마다 다른 테이블에 산다",
"text": "종류별 테이블",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "기록은 종류마다 다른 테이블에 산다",
"originalText": "종류별 테이블",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-t1",
"type": "arrow",
"x": 279.0,
"x": 220.0,
"y": 249.0,
"width": 160.0,
"height": 0.0,
@@ -135,7 +135,7 @@
{
"id": "edge-label-t1",
"type": "text",
"x": 314.0,
"x": 255.0,
"y": 209.0,
"width": 90,
"height": 24,
@@ -174,7 +174,7 @@
{
"id": "edge-t2",
"type": "arrow",
"x": 718.0,
"x": 659.0,
"y": 249.0,
"width": 160.0,
"height": 0.0,
@@ -234,7 +234,7 @@
{
"id": "edge-label-t2",
"type": "text",
"x": 753.0,
"x": 694.0,
"y": 209.0,
"width": 90,
"height": 24,
@@ -273,7 +273,7 @@
{
"id": "edge-t3",
"type": "arrow",
"x": 1059.0,
"x": 1000.0,
"y": 113.0,
"width": 160.0,
"height": 118.0,
@@ -333,7 +333,7 @@
{
"id": "edge-label-t3",
"type": "text",
"x": 1118.0,
"x": 1059.0,
"y": 160.0,
"width": 90,
"height": 24,
@@ -372,7 +372,7 @@
{
"id": "edge-t4",
"type": "arrow",
"x": 1059.0,
"x": 1000.0,
"y": 249.0,
"width": 160.0,
"height": 0.0,
@@ -432,7 +432,7 @@
{
"id": "edge-label-t4",
"type": "text",
"x": 1094.0,
"x": 1035.0,
"y": 209.0,
"width": 90,
"height": 24,
@@ -471,7 +471,7 @@
{
"id": "edge-t5",
"type": "arrow",
"x": 1059.0,
"x": 1000.0,
"y": 267.0,
"width": 160.0,
"height": 118.0,
@@ -531,7 +531,7 @@
{
"id": "edge-label-t5",
"type": "text",
"x": 1118.0,
"x": 1059.0,
"y": 314.0,
"width": 90,
"height": 24,
@@ -572,7 +572,7 @@
"type": "rectangle",
"x": 70.0,
"y": 213.5,
"width": 209.0,
"width": 150.0,
"height": 71.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -602,7 +602,7 @@
"type": "text",
"x": 80.0,
"y": 223.5,
"width": 189.0,
"width": 130.0,
"height": 51.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -628,18 +628,18 @@
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "topic\nvariant_label 로 축 이름을 정한다",
"text": "topic\nvariant_label",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "topic\nvariant_label 로 축 이름을 정한다",
"originalText": "topic\nvariant_label",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-topic-variant",
"type": "rectangle",
"x": 439.0,
"x": 380.0,
"y": 213.5,
"width": 279.0,
"height": 71.0,
@@ -669,7 +669,7 @@
{
"id": "node-label-topic-variant",
"type": "text",
"x": 449.0,
"x": 390.0,
"y": 223.5,
"width": 259.0,
"height": 51.0,
@@ -708,7 +708,7 @@
{
"id": "node-record-variant",
"type": "rectangle",
"x": 878.0,
"x": 819.0,
"y": 213.5,
"width": 181.0,
"height": 71.0,
@@ -738,7 +738,7 @@
{
"id": "node-label-record-variant",
"type": "text",
"x": 888.0,
"x": 829.0,
"y": 223.5,
"width": 161.0,
"height": 51.0,
@@ -777,7 +777,7 @@
{
"id": "node-document",
"type": "rectangle",
"x": 1219.0,
"x": 1160.0,
"y": 81.0,
"width": 150.0,
"height": 64.0,
@@ -807,7 +807,7 @@
{
"id": "node-label-document",
"type": "text",
"x": 1229.0,
"x": 1170.0,
"y": 91.0,
"width": 130.0,
"height": 44.0,
@@ -846,7 +846,7 @@
{
"id": "node-open-question",
"type": "rectangle",
"x": 1219.0,
"x": 1160.0,
"y": 217.0,
"width": 150.0,
"height": 64.0,
@@ -876,7 +876,7 @@
{
"id": "node-label-open-question",
"type": "text",
"x": 1229.0,
"x": 1170.0,
"y": 227.0,
"width": 130.0,
"height": 44.0,
@@ -915,7 +915,7 @@
{
"id": "node-project-decision",
"type": "rectangle",
"x": 1219.0,
"x": 1160.0,
"y": 353.0,
"width": 150.0,
"height": 64.0,
@@ -945,7 +945,7 @@
{
"id": "node-label-project-decision",
"type": "text",
"x": 1229.0,
"x": 1170.0,
"y": 363.0,
"width": 130.0,
"height": 44.0,
@@ -2,7 +2,7 @@
"harness_version": "0.2.0",
"spec_id": "topic-variant-model",
"spec_version": "1.1",
"spec_sha256": "c421c2d82e136ffc2312f8203b8b4131cae075cef25dea60169d70126de9894a",
"spec_sha256": "9877bf8caf67db577f45c1576cb30b722b4339578a9880267164f759c1c63df4",
"source_context": {
"document": "document.md",
"document_sha256": "93b9fec4884efa0e6231de07dc27e2b0ac36c9052d3720e28d102d9747ac4f8f",
@@ -14,10 +14,9 @@
},
"outputs": [
"topic-variant-model.svg",
"topic-variant-model.drawio",
"topic-variant-model.mmd",
"topic-variant-model.d2",
"topic-variant-model.dot",
"topic-variant-model.drawio",
"topic-variant-model.excalidraw",
"topic-variant-model.alt.md"
],
@@ -1,7 +1,7 @@
%% 주제 안의 축과 기록을 잇는 자리
%% question: 하나의 질문에 대한 네 답을 무엇으로 담고, 기록은 어떻게 축에 걸리는가?
flowchart LR
subgraph g_record_tables["기록은 종류마다 다른 테이블에 산다"]
subgraph g_record_tables["종류별 테이블"]
n3[("document")]
n4[("open_question")]
n5[("project_decision")]
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1444" height="488" viewBox="0 0 1444 488" role="img" aria-labelledby="diagram-title diagram-description">
<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>
@@ -49,53 +49,53 @@
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1444" height="488" />
<rect class="group-box" x="1189.0" y="35.0" width="210.0" height="408.0" rx="8" />
<rect class="group-label-bg" x="1203.0" y="25.0" width="155.0" height="22" />
<text class="group-label" x="1213.0" y="40.0">기록은 종류마다 다른 테이블에 산다</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="279.0,249.0 359.0,249.0 359.0,249.0 439.0,249.0" data-evidence="1057-1060" />
<rect class="edge-label-bg" x="333.2" y="207.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="359.0" y="222.0">1 : N</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="718.0,249.0 798.0,249.0 798.0,249.0 878.0,249.0" data-evidence="1060-1061" />
<rect class="edge-label-bg" x="772.2" y="207.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="798.0" y="222.0">축에 건다</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="1059.0,231.0 1139.0,231.0 1139.0,113.0 1219.0,113.0" data-evidence="1061-1073" />
<rect class="edge-label-bg" x="1120.5" y="158.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="1163.0" y="173.0">(kind, id)</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="1059.0,249.0 1139.0,249.0 1139.0,249.0 1219.0,249.0" data-evidence="1061-1073" />
<rect class="edge-label-bg" x="1096.5" y="207.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="1139.0" y="222.0">(kind, id)</text>
<polyline class="edge kind-data style-dashed emphasis-normal" points="1059.0,267.0 1139.0,267.0 1139.0,385.0 1219.0,385.0" data-evidence="1061-1073" />
<rect class="edge-label-bg" x="1120.5" y="312.0" width="85.0" height="22" rx="3" />
<text class="edge-label" x="1163.0" y="327.0">(kind, id)</text>
<rect class="canvas" width="1385" height="488" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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="209.0" height="71.0" rx="7" />
<text class="node-label" x="174.5" y="240.5">topic</text>
<line class="node-detail-divider" x1="84.0" y1="261.5" x2="265.0" y2="261.5" />
<text class="node-detail" x="86.0" y="278.5">variant_label 로 축 이름을 정한다</text>
<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" />
<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="439.0" y="213.5" width="279.0" height="71.0" rx="7" />
<text class="node-label" x="578.5" y="240.5">topic_variant</text>
<line class="node-detail-divider" x1="453.0" y1="261.5" x2="704.0" y2="261.5" />
<text class="node-detail" x="455.0" y="278.5">SPA · Mediator · BFF · Forward-Auth</text>
<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" />
<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="878.0" y="213.5" width="181.0" height="71.0" rx="7" />
<text class="node-label" x="968.5" y="240.5">record_variant</text>
<line class="node-detail-divider" x1="892.0" y1="261.5" x2="1045.0" y2="261.5" />
<text class="node-detail" x="894.0" y="278.5">(kind, id) 쌍 · 외래키 없음</text>
<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" />
<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="1219.0" y="81.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1294.0" y="111.0">document</text>
<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" />
<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="1219.0" y="217.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1294.0" y="247.0">open_question</text>
<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" />
<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="1219.0" y="353.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1294.0" y="383.0">project_decision</text>
<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" />
<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

@@ -30,7 +30,7 @@ source:
주제 화면의 네 줄(SPA·Mediator·BFF·Forward-Auth)은 링크로 그려져 있었다. 눌러도 아무 일이 없었다.
처음에 `/topics/{주제}/{축}` 이라 적어 두었는데 그런 화면이 없었다.
처음에 /topics/{주제}/{축} 이라 적어 두었는데 그런 화면이 없었다.
## 결론
@@ -35,14 +35,14 @@ source:
간헐적으로 보인 것은 두 가지 결정적 어긋남이었고, 사용자는 둘 다 만났다.
`인증` : 남는 글자가 없어 빈 문자열 → 폼이 요청 전에 거절
`Redis 캐시``Redis 클러스터` : 둘 다 `redis` → 두 번째가 충돌
인증 : 남는 글자가 없어 빈 문자열 → 폼이 요청 전에 거절
Redis 캐시 와 Redis 클러스터 : 둘 다 redis → 두 번째가 충돌
> 규칙은 간헐적이었던 적이 없다. **보이지 않았을 뿐이다** — slug 생성이 `[a-z0-9]` 만 남기고 나머지를 버려서, 한글 이름은 아무것도 기여하지 못했다.
> 규칙은 간헐적이었던 적이 없다. **보이지 않았을 뿐이다** — slug 생성이 [a-z0-9] 만 남기고 나머지를 버려서, 한글 이름은 아무것도 기여하지 못했다.
한글을 버리지 않고 로마자로 옮긴다. 음절을 초성·중성·종성으로 산술 분해하므로 표가 필요 없고 결정적이다.
`백엔드 아키텍처``baekendeu-akitekcheo`
백엔드 아키텍처 → baekendeu-akitekcheo
국어의 로마자 표기법의 자모 대응만 적용하고 음운 변화 규칙은 일부러 뺐다. slug 는 읽는 것이지 발음하는 것이 아니고, 그 규칙을 넣으면 같은 이름이 문맥에 따라 다른 slug 가 된다.
@@ -54,8 +54,8 @@ tech-log-frontend : 5cffe30 · 7093d84
## 재현 조건
1. 주제 이름을 `인증` 으로 적고 저장한다 — 옛 규칙에서는 빈 slug 가 되어 폼이 거절한다
2. `Redis 캐시``Redis 클러스터` 를 차례로 만든다 — 옛 규칙에서는 두 번째가 충돌한다
1. 주제 이름을 인증 으로 적고 저장한다 — 옛 규칙에서는 빈 slug 가 되어 폼이 거절한다
2. Redis 캐시 와 Redis 클러스터 를 차례로 만든다 — 옛 규칙에서는 두 번째가 충돌한다
3. 새 규칙에서 같은 이름들의 slug 를 확인한다
## 본문
@@ -35,7 +35,7 @@ source:
## 문제
`/references/external-idp-federation-application-boundary` 의 「다음에 읽을 것」 두 번째 항목이 404 였다.
/references/external-idp-federation-application-boundary 의 「다음에 읽을 것」 두 번째 항목이 404 였다.
화면 코드 어디에도 그 주소를 만드는 곳이 없다. 주소는 게시할 때 서버가 만들어 DB 에 저장한 문자열이고, 화면은 그것을 그대로 링크로 그린다.
@@ -76,8 +76,7 @@ tech-log-frontend : fe6b56a
## 주소가 만들어져 저장되고 방문에서 끝난다
:::evidence key="decision-path-404" alt="계약·게시 시점 경로 생성·저장 테이블·조회 시점 경로 생성·방문자·공개 라우트 여섯 참가자 사이의 순서도" caption=" " zoom="true"
:::
![계약·게시 시점 경로 생성·저장 테이블·조회 시점 경로 생성·방문자·공개 라우트 여섯 참가자 사이의 순서도](../../../final/assets/diagrams/decision-path-404/decision-path-404.svg)
계약은 결정의 공개 주소가 목록 위의 앵커라고 규정한다. 게시 시점의 경로 생성기는 그 대신 목록 아래에 slug 를 붙인 경로를 만들어 공개 투영에 저장한다. 조회 시점의 다른 생성기가 저장된 주소를 읽어 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 목록 하나뿐이라 맞는 라우트가 없다.
@@ -112,9 +111,6 @@ tech-log-frontend : fe6b56a
배포 후 사이트 전체를 훑어 서버가 내보내는 주소 26개와 주제·축 9개를 더해 35개 전부 200 인 것을 확인했다.
:::evidence key="dead-link-sweep" alt="서버가 내보내는 주소 35개를 전수로 훑은 감사 출력" caption=" " zoom="false"
:::
같은 방식으로 다시 검사하는 스크립트를 증거와 함께 남겼다. 다음에 라우트를 더하면 그 스크립트를 다시 돌린다.
## 확인하지 못한 것
@@ -7,13 +7,6 @@ topicName: 주제 안의 축
project: TechLog
status: 게시 전
lastVerifiedOn: 2026-09-04
assets:
- key: home-tabs-keycloak
file: ../../../final/evidence/browser/home-tabs-keycloak.png
- key: home-topic-tabs-2
file: ../../../final/evidence/browser/home-topic-tabs-2.png
- key: home-tabs-grouped
file: ../../../final/evidence/browser/home-tabs-grouped.png
evidence:
- ../../../final/evidence/browser/home-tabs-keycloak.png
- ../../../final/evidence/browser/home-topic-tabs.png
@@ -46,11 +39,14 @@ source:
세 단계를 거쳤다.
| 단계 | 무엇 | 왜 바꿨나 |
|---|---|---|
| 1 | 주제 하나만 펼치고 아래 「다른 주제 N개 보기」 한 줄 | 홈이 「무엇을 만들었나」로 시작했다 |
| 2 | 제목 자리를 주제 이름 탭이 대신 (30px/650) | 「다른 주제」 줄은 목록을 다 읽고 나서야 만나는 곳이라 대개 지나쳤다 |
| 3 | 탭을 칩 크기로 낮추고 개수 상한 제거 | 주제가 열 개, 스무 개가 되면 이름만으로 화면이 덮인다 |
1단계 : 주제 하나만 펼치고 아래에 「다른 주제 N개 보기」 한 줄
바꾼 이유 : 홈이 「무엇을 만들었나」로 시작했다
2단계 : 제목 자리를 주제 이름 탭이 대신한다 (30px/650)
바꾼 이유 : 「다른 주제」 줄은 목록을 다 읽고 나서야 만나는 곳이라 대개 지나쳤다
3단계 : 탭을 칩 크기로 낮추고 개수 상한을 없앴다
바꾼 이유 : 주제가 열 개, 스무 개가 되면 이름만으로 화면이 덮인다
3단계에서 요청 구조를 바꿨다. 탭은 목록 호출 하나가 주는 전부이고, 상세는 고른 탭만 그때 받아 캐시한다. 그래서 주제가 몇 개가 되든 홈이 처음 보내는 요청은 목록 하나와 주제 하나로 고정된다.
@@ -79,9 +75,6 @@ tech-log-frontend : 604ded5 → de4cb8b → 3bb724b · 2b2f443
3단계에서 탭을 칩 크기로 낮추고 개수 상한을 없앴다. 주제가 열 개, 스무 개가 되면 이름만으로 화면이 덮이기 때문이다.
:::evidence key="home-tabs-keycloak" alt="탭을 구역 제목 급으로 세운 2단계 화면" caption=" " zoom="true"
:::
## 상한이 왜 있었나
상한은 주제마다 상세를 미리 받느라 둔 것이었다. 상세를 다 받으려면 요청이 주제 수만큼 늘어나므로 그 수를 제한해야 했다.
@@ -100,14 +93,8 @@ tech-log-frontend : 604ded5 → de4cb8b → 3bb724b · 2b2f443
칩으로 낮추니 목록 위에 글자만 떠 있는 것처럼 보였다. 고른 것이 색으로만 달라 누를 수 있는 것으로 읽히지 않았고, 탭 줄과 목록 사이가 선 없이 30px 비어 두 덩어리로 갈렸다.
:::evidence key="home-topic-tabs-2" alt="칩으로 낮춘 직후의 비교 구역 확대" caption=" " zoom="true"
:::
고른 탭에 알약 형태를 주고, 묶음의 윗선을 목록이 아니라 패널이 갖게 해서 탭 줄이 그 선에 바로 얹히게 했다.
:::evidence key="home-tabs-grouped" alt="고른 탭에 형태를 주고 탭 줄을 패널 윗선에 얹은 최종 화면" caption=" " zoom="true"
:::
## 확인하지 못한 것
주제가 스무 개일 때의 화면은 만들어 보지 않았다. 상한을 없앤 근거는 요청 구조이지 그 규모의 측정이 아니다.
@@ -45,8 +45,7 @@ topic (주제)
└─ record_variant 어느 기록이 어느 축에 걸리는지 (kind, id) 쌍
```
:::evidence key="topic-variant-model" alt="topic·topic_variant·record_variant 가 이어지고 record_variant 가 세 테이블을 가리키는 구조도" caption=" " zoom="true"
:::
![topic·topic_variant·record_variant 가 이어지고 record_variant 가 세 테이블을 가리키는 구조도](../../../final/assets/diagrams/topic-variant-model/topic-variant-model.svg)
## 축 이름은 주제가 정한다
@@ -37,13 +37,13 @@ source:
사용자가 원인을 정확히 짚어 주었다 — 「디자인이 안 된 게 아니라 CSS 선택자가 새고 있습니다」.
```css
css
.home-comparison a {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
padding: 27px 2px 28px;
}
```
비교 행을 위한 규칙인데 선택자가 구역 전체라 제목 안의 링크까지 잡았다. 제목이 200px 칸에 갇혀 두 줄로 접히고 행용 padding 까지 물려 h2 높이가 199px 이 됐다.
@@ -35,7 +35,7 @@ source:
### 1. 배치 속성은 그 배치를 쓰는 요소까지 좁혀 적는다
`display: grid|flex`·`grid-template-columns`·`padding` 을 구역 클래스 아래 태그 선택자로 걸지 않는다.
display: grid|flex·grid-template-columns·padding 을 구역 클래스 아래 태그 선택자로 걸지 않는다.
행을 위한 격자를 구역 전체에 걸면 제목 안의 링크도 그 격자가 된다. 첫 칸 너비에 갇혀 접히고 행용 padding 까지 물린다.
@@ -26,7 +26,7 @@ source:
## 문제
관리 계약의 연산은 `tech-log-management-contract-contribution.ts` 에 등록해야 실행 시 부를 수 있다. 계약에서 타입은 생성되므로 등록을 빠뜨려도 컴파일은 통과한다.
관리 계약의 연산은 tech-log-management-contract-contribution.ts 에 등록해야 실행 시 부를 수 있다. 계약에서 타입은 생성되므로 등록을 빠뜨려도 컴파일은 통과한다.
등록되지 않은 연산을 부르면 게이트웨이가 그 연산을 찾지 못하고 옆의 분기로 떨어진다. 그래서 증상이 「없는 연산」이 아니라 「다른 연산이 실행됨」으로 나온다.
@@ -34,9 +34,9 @@ source:
네 번 났고 전부 같은 원인이었다.
`getPublicConcept` : 개념 화면이 질문 조회를 불렀다
`deleteConceptDraft` : 개념 삭제가 질문 삭제를 불렀다
`listStudioQuestions` · `listStudioProjectDecisions` : 홈 편집기가 빈 목록을 그렸다
getPublicConcept : 개념 화면이 질문 조회를 불렀다
deleteConceptDraft : 개념 삭제가 질문 삭제를 불렀다
listStudioQuestions · listStudioProjectDecisions : 홈 편집기가 빈 목록을 그렸다
축(variant) CRUD 네 연산 : 축 화면이 데이터를 받지 못했다
공개 계약은 전수 대조하고, 관리 계약은 「한 종류만 빠진 항목」을 보는 가드를 뒀다. 깨진 것이 늘 그 모양이었다.
@@ -48,13 +48,13 @@ source:
tech-log-backend : 365560e 이후
tech-log-frontend : 계약에서 생성한 타입을 그대로 사용
확인 방식 : 계약이 선언한 연산과 `@RestController` 매핑을 리플렉션으로 대조
확인 방식 : 계약이 선언한 연산과 @RestController 매핑을 리플렉션으로 대조
## 재현 조건
1. 계약에 연산을 하나 선언하고 컨트롤러는 만들지 않는다
2. 프론트에서 그 연산을 부르는 화면을 연다 — 404 가 돌아오고 화면은 빈 목록을 그린다
3. `ContractRouteCoverageTest` 를 돌린다 — 그 연산 하나를 짚는다
3. ContractRouteCoverageTest 를 돌린다 — 그 연산 하나를 짚는다
## 본문
@@ -36,7 +36,7 @@ source:
### 1. 서버 쪽은 매핑을 리플렉션으로 모아 계약의 경로와 전수 대조한다
`@RestController` 들을 훑어 실제 매핑을 모으고 계약이 선언한 경로 전부와 맞춘다. 기대 목록을 손으로 적으면 그 목록이 또 하나의 손 목록이 되므로 계약에서 읽는다.
@RestController 들을 훑어 실제 매핑을 모으고 계약이 선언한 경로 전부와 맞춘다. 기대 목록을 손으로 적으면 그 목록이 또 하나의 손 목록이 되므로 계약에서 읽는다.
### 2. 화면 쪽은 계약이 선언한 연산이 기여 목록에 등록됐는지 본다
@@ -37,7 +37,7 @@ source:
더 미묘한 변종이 하나 더 있었다.
> `Promise.all([gateway.foo()])` 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
> Promise.all([gateway.foo()]) 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
같은 판단을 주제 탭에도 적용했다.
@@ -14,7 +14,7 @@ source:
# 매퍼가 null 을 돌려주고 호출부가 걸러 내, 기록이 조용히 사라졌다
프로젝트 기록 목록에서 Open Question 이 보이지 않았다. 이 목록은 탐색의 지식 목록과 응답 모양이 다른데 그쪽 매퍼를 쓰고 있었다. 그 매퍼는 두 종류가 아니면 `null` 을 돌려주고 호출부가 걸러 내므로, 질문과 개념은 오류도 빈 줄도 남기지 않고 사라진다.
프로젝트 기록 목록에서 Open Question 이 보이지 않았다. 이 목록은 탐색의 지식 목록과 응답 모양이 다른데 그쪽 매퍼를 쓰고 있었다. 그 매퍼는 두 종류가 아니면 null 을 돌려주고 호출부가 걸러 내므로, 질문과 개념은 오류도 빈 줄도 남기지 않고 사라진다.
## 관계
@@ -35,7 +35,7 @@ source:
프로젝트 기록 목록은 탐색의 지식 목록과 응답 모양이 다른데 지식 목록의 매퍼를 그대로 쓰고 있었다.
그 매퍼는 CASE 나 REFERENCE 가 아니면 `null` 을 돌려준다. 호출부가 `filter` 로 걸러 내므로 그 항목은 목록에서 없어진다.
그 매퍼는 CASE 나 REFERENCE 가 아니면 null 을 돌려준다. 호출부가 filter 로 걸러 내므로 그 항목은 목록에서 없어진다.
증상 : 목록이 한 줄 짧아진다
오류 : 없음
@@ -19,7 +19,7 @@ source:
# 개념을 하나 더하자 열세 곳이 그것을 조용히 삼켰다
문서 종류에 CONCEPT 을 더했다. 컴파일도 통과하고 테스트도 통과했는데, 개념을 지우면 「질문을 찾을 수 없습니다」가 나오고 개념 상세 주소는 404 였고 탐색에서 `type=CONCEPT` 은 0건이었다. 그 뒤로도 같은 모양이 계속 나와 열세 번을 셌다. 매번 원인이 같았다 — 다섯 종류를 손으로 적어 둔 곳이 있었고 새 종류가 마지막 `else` 로 떨어졌다.
문서 종류에 CONCEPT 을 더했다. 컴파일도 통과하고 테스트도 통과했는데, 개념을 지우면 「질문을 찾을 수 없습니다」가 나오고 개념 상세 주소는 404 였고 탐색에서 type=CONCEPT 은 0건이었다. 그 뒤로도 같은 모양이 계속 나와 열세 번을 셌다. 매번 원인이 같았다 — 다섯 종류를 손으로 적어 둔 곳이 있었고 새 종류가 마지막 else 로 떨어졌다.
## 관계
@@ -36,7 +36,7 @@ source:
문서 종류는 다섯이다 — CASE, REFERENCE, QUESTION, CONCEPT, PROJECT_DECISION. 새 종류를 하나 더하면 그 종류를 아는 곳이 전부 함께 늘어나야 한다.
실제로는 늘지 않은 곳이 열세 곳 있었고, 그중 어느 곳도 오류를 내지 않았다. 삼항 사슬의 마지막 `else` 와 배열 리터럴의 끝이 모르는 값을 조용히 받아 갔다.
실제로는 늘지 않은 곳이 열세 곳 있었고, 그중 어느 곳도 오류를 내지 않았다. 삼항 사슬의 마지막 else 와 배열 리터럴의 끝이 모르는 값을 조용히 받아 갔다.
## 결론
@@ -57,10 +57,10 @@ tech-log-backend : 8cd8ee3
## 재현 조건
1. `PublicRecord["kind"]` 에 새 값을 하나 더한다
2. `npm run check:types` 를 돌린다 — `Record<Kind, _>` 로 바꾼 곳은 여기서 멈춘다
3. 계약의 enum 에서 CONCEPT 을 빼고 `StudioContractUnionJacksonTest` 를 돌린다 — 빨개진다
4. `PublicSql.pathOf` 에 새 값을 넣지 않고 그 종류를 게시한다 — 컴파일은 통과하고 경로가 null 로 나간다
1. PublicRecord["kind"] 에 새 값을 하나 더한다
2. npm run check:types 를 돌린다 — Record<Kind, _> 로 바꾼 곳은 여기서 멈춘다
3. 계약의 enum 에서 CONCEPT 을 빼고 StudioContractUnionJacksonTest 를 돌린다 — 빨개진다
4. PublicSql.pathOf 에 새 값을 넣지 않고 그 종류를 게시한다 — 컴파일은 통과하고 경로가 null 로 나간다
## 본문
@@ -154,7 +154,4 @@ export const EXPLORE_KIND_PATHS: Record<RecordKind, string> = {
두 곳이 아직 표가 아니다. 공개 경로 생성기는 분기 대상이 문자열이라 컴파일러가 셀 수 있는 값 집합이 없고, 작업본 검증기의 문자열 칸 목록은 사슬로 남아 있다.
:::evidence key="kind-tables-now" alt="현재 코드에서 종류를 나열하는 곳을 조회한 출력" caption=" " zoom="false"
:::
<!-- body:end -->
@@ -15,7 +15,7 @@ source:
# 컴파일러가 빠진 가지를 요구하게 만드는 두 가지 — Record 표와 sealed switch 식
같은 언어 안에서도 어떤 분기는 새 값을 더할 때 컴파일러가 빠진 값을 짚고 어떤 분기는 아무 말도 하지 않는다. 삼항 사슬과 배열 리터럴은 후자이고, `Record<Kind, _>` 와 식으로 쓴 sealed switch 는 전자다.
같은 언어 안에서도 어떤 분기는 새 값을 더할 때 컴파일러가 빠진 값을 짚고 어떤 분기는 아무 말도 하지 않는다. 삼항 사슬과 배열 리터럴은 후자이고, Record<Kind, _> 와 식으로 쓴 sealed switch 는 전자다.
## 관계
@@ -29,7 +29,7 @@ source:
## 사실
공개 경로 생성기는 문서 종류가 아니라 공개 투영의 종류 칸(String)으로 switch 하고 `default -> null` 이 남아 있다.
공개 경로 생성기는 문서 종류가 아니라 공개 투영의 종류 칸(String)으로 switch 하고 default -> null 이 남아 있다.
그 칸은 문서 종류 다섯에 더해 PROJECT 와 RELEASE 도 담는다. 지금 그 switch 가 다루는 값은 여덟이다.
@@ -31,13 +31,13 @@ source:
새 값을 더했을 때 오류 없이 잘못된 값이 나가는 것을 막는다.
이 부류는 컴파일도 테스트도 지나간다. 마지막 `else` 가 모르는 값을 받아 가면 모든 값에 갈 곳이 있고 각 가지가 내놓는 타입도 같으므로, 타입 검사가 물을 것이 남지 않는다. 그래서 배포된 뒤 사용자가 만나기 전까지 아무도 모른다.
이 부류는 컴파일도 테스트도 지나간다. 마지막 else 가 모르는 값을 받아 가면 모든 값에 갈 곳이 있고 각 가지가 내놓는 타입도 같으므로, 타입 검사가 물을 것이 남지 않는다. 그래서 배포된 뒤 사용자가 만나기 전까지 아무도 모른다.
## 규칙
### 1. 유한한 집합의 분기는 값마다 항목을 요구하는 형태로 쓴다
`Record<K, V>` 는 키 집합이 `K` 와 정확히 같은 객체 타입이라, 키가 하나 모자라면 그 리터럴이 그 타입이 아니게 된다. `K` 에 값을 더하면 리터럴을 쓴 곳이 전부 타입 오류가 된다. Java 에서는 sealed 타입을 대상으로 switch 를 식으로 쓴다 — 식은 값을 내놓아야 하므로 모든 경우에 무엇을 반환할지 컴파일러가 요구한다. 문으로 쓴 switch 는 요구하지 않는다.
Record<K, V> 는 키 집합이 K 와 정확히 같은 객체 타입이라, 키가 하나 모자라면 그 리터럴이 그 타입이 아니게 된다. K 에 값을 더하면 리터럴을 쓴 곳이 전부 타입 오류가 된다. Java 에서는 sealed 타입을 대상으로 switch 를 식으로 쓴다 — 식은 값을 내놓아야 하므로 모든 경우에 무엇을 반환할지 컴파일러가 요구한다. 문으로 쓴 switch 는 요구하지 않는다.
### 2. 키가 그 열거형이 아니면 표로 좁히지 말고 그 이유를 코드 옆에 적는다
@@ -15,7 +15,7 @@ source:
# nginx 가 모르는 라우트는 새로고침에서 404 다
`/studio/releases` 가 평문 404 를 돌려줬다. 라우트는 있고 청크도 빌드됐고 SPA 내부 이동으로는 화면에 닿는데, 하드 로드와 새로고침은 거기까지 가지 못한다. nginx 설정이 손으로 유지하는 배열에서 나오고 있었다.
/studio/releases 가 평문 404 를 돌려줬다. 라우트는 있고 청크도 빌드됐고 SPA 내부 이동으로는 화면에 닿는데, 하드 로드와 새로고침은 거기까지 가지 못한다. nginx 설정이 손으로 유지하는 배열에서 나오고 있었다.
## 관계
@@ -36,11 +36,11 @@ SPA 안에서 이동하면 화면이 열린다. 주소창에 그 주소를 직
서빙 계약의 절반은 라우트 레지스트리에서 유도하고 있었고 나머지 절반은 손으로 유지하는 배열이었다.
> 서빙 계약의 공개 절반은 라우트 레지스트리에서 패턴을 유도한다. **Studio 절반은 손으로 유지하는 배열이었고, 손으로 유지하는 배열이 실패하는 방식 그대로 실패했다** — `^/studio/assets$` 위의 주석이 바로 그 버그를 한 번 고친 기록이고, 라우트를 더하니 즉시 반복됐다.
> 서빙 계약의 공개 절반은 라우트 레지스트리에서 패턴을 유도한다. **Studio 절반은 손으로 유지하는 배열이었고, 손으로 유지하는 배열이 실패하는 방식 그대로 실패했다** — ^/studio/assets$ 위의 주석이 바로 그 버그를 한 번 고친 기록이고, 라우트를 더하니 즉시 반복됐다.
공개 절반도 유도라고 하기 어려웠다. 번들된 픽스처에 우연히 들어 있던 공개 경로를 전부 열거하고, 생성된 nginx 가 정확히 그것들을 `location =` 블록으로 게시했다. 빌드 이후에 게시된 기록은 SPA 에 묻기도 전에 엣지에서 404 였다. 경로 27개가 얼어 있었고 28번째는 무엇이든 닿을 수 없었다.
공개 절반도 유도라고 하기 어려웠다. 번들된 픽스처에 우연히 들어 있던 공개 경로를 전부 열거하고, 생성된 nginx 가 정확히 그것들을 location = 블록으로 게시했다. 빌드 이후에 게시된 기록은 SPA 에 묻기도 전에 엣지에서 404 였다. 경로 27개가 얼어 있었고 28번째는 무엇이든 닿을 수 없었다.
지금은 라우트 계약에서 등록된 Public 라우트마다 정규식 하나를 만든다. 파라미터는 한 세그먼트만 잡고 슬래시는 잡지 않으므로 `/cases/a/b` 는 404 로 남는다.
지금은 라우트 계약에서 등록된 Public 라우트마다 정규식 하나를 만든다. 파라미터는 한 세그먼트만 잡고 슬래시는 잡지 않으므로 /cases/a/b 는 404 로 남는다.
## 검증 환경
@@ -40,7 +40,7 @@ source:
그러면 깨진 링크를 상태 코드로 판정하는 쪽이 전부 못 본다. 크롤러도 못 보고, 서버가 내보내는 주소를 전수로 훑는 감사도 못 본다. 그 감사는 이 저장소에서 실제로 결함을 잡은 방법이고, soft 200 이 섞이면 감사가 통과하면서 방문자만 빈 화면을 만난다.
파라미터가 슬래시를 잡지 않게 한 것도 같은 이유다. `/cases/a/b` 가 404 로 남아야 그 주소가 잘못됐다는 것이 드러난다. 슬래시까지 잡으면 세그먼트가 몇 개든 라우트에 걸리고, 라우터가 그것을 「없는 기록」으로 그린다.
파라미터가 슬래시를 잡지 않게 한 것도 같은 이유다. /cases/a/b 가 404 로 남아야 그 주소가 잘못됐다는 것이 드러난다. 슬래시까지 잡으면 세그먼트가 몇 개든 라우트에 걸리고, 라우터가 그것을 「없는 기록」으로 그린다.
대안은 catch-all 을 번역하고 라우터가 404 화면을 그리게 하는 것이었다. 사람에게 보이는 화면은 같지만 기계가 읽는 상태 코드가 달라지므로 고르지 않았다.
@@ -32,9 +32,9 @@ FE-GATE-009 는 설치된 라우트마다 증거 파일 하나를 요구하고,
정확한 일치를 요구하는 이유는 빠뜨림이 통과가 되지 않게 하려는 것이다. 파일이 더 많아도 더 적어도 거절한다.
`artifacts/tests/a11y-manual/*.md` 는 전부 `pending-manual-review` 다.
artifacts/tests/a11y-manual/*.md 는 전부 pending-manual-review 다.
`review:a11y-manual` 스크립트는 그래서 실패하는 것이 지금은 정상이다.
review:a11y-manual 스크립트는 그래서 실패하는 것이 지금은 정상이다.
라우트를 더할 때마다 이 증거 개수가 함께 움직였고, 커밋 넷에서 111 → 117 로 늘었다.
@@ -40,7 +40,7 @@ source:
### 2. 빌드가 아는 목록을 서빙 계약의 근거로 쓰지 않는다
번들된 픽스처에 우연히 들어 있던 경로를 열거하면 그 목록이 빌드 시점에 얼어붙는다. `location =` 은 정확히 일치하는 경로만 잡으므로, 빌드 이후에 게시된 기록은 SPA 에 묻기도 전에 엣지에서 404 가 된다.
번들된 픽스처에 우연히 들어 있던 경로를 열거하면 그 목록이 빌드 시점에 얼어붙는다. location = 은 정확히 일치하는 경로만 잡으므로, 빌드 이후에 게시된 기록은 SPA 에 묻기도 전에 엣지에서 404 가 된다.
### 3. 유도할 수 없는 목록에는 대조 검사를 둔다
@@ -68,7 +68,7 @@ source:
## 예시
`/studio/releases` 가 평문 404 였다. 라우트도 청크도 있었고 서빙 계약의 손 배열에만 없었다.
/studio/releases 가 평문 404 였다. 라우트도 청크도 있었고 서빙 계약의 손 배열에만 없었다.
- 같은 배열을 한 번 고치면서 왜 고쳤는지를 주석으로 남겨 두었는데, 다음 사람이 그 배열에 줄을 더할 때 그 주석을 읽지 않았다.
@@ -30,10 +30,10 @@ source:
홈 화면 하나에 종류 이름이 아홉 개 있었다.
```text
text
최근 기록 목록: CASE · CONCEPT · OPEN QUESTION · REFERENCE
바로 아래 「종류별로 읽기」: 검증 기록 · 동작 원리 · 적용 기준 · 열린 질문
```
두 목록이 같은 다섯 종류를 가리키는데 이름이 겹치지 않는다. 독자는 그 둘이 같은 것이라는 단서를 받지 못한다.
@@ -29,7 +29,7 @@ source:
## 사실
작업본 삭제 실패는 다섯 참조 중 무엇이 막았든 같은 오류 코드로 나간다 — `DOCUMENT_IN_USE`.
작업본 삭제 실패는 다섯 참조 중 무엇이 막았든 같은 오류 코드로 나간다 — DOCUMENT_IN_USE.
클라이언트에 나가는 문구는 코드마다 하나로 고정돼 있다. 그 코드의 문구는 「이 기록을 참조하는 곳이 있어 삭제할 수 없습니다」이다.
@@ -37,15 +37,15 @@ source:
참조 검사는 다섯 표를 하나의 존재 검사로 묶는다.
```sql
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
```
같은 어댑터의 질문 삭제는 참조가 둘뿐이라 같은 문제가 덜하다 — `project_question_link``home_focus_config.open_question_id` 다.
같은 어댑터의 질문 삭제는 참조가 둘뿐이라 같은 문제가 덜하다 — project_question_link 와 home_focus_config.open_question_id 다.
실제 사례에서 관계를 다 지워도 삭제가 안 됐다. 남아 있던 것은 프로젝트 링크 한 행이었고, 그 링크는 「관계」 편집기가 아니라 문서의 Project 필드가 만든다.
@@ -33,11 +33,11 @@ source:
## 결론
프론트 이미지 빌드에 `RUNTIME_API_BASE_URL` 을 넘기지 않으면 기본값이 이미지에 굳는다. 그 기본값이 존재하지 않는 주소다.
프론트 이미지 빌드에 RUNTIME_API_BASE_URL 을 넘기지 않으면 기본값이 이미지에 굳는다. 그 기본값이 존재하지 않는 주소다.
Dockerfile 이 그 경고를 문자 그대로 적어 두고 있는데도 빠뜨렸다.
`kubectl rollout undo` 로 되돌리고 다시 빌드했다.
kubectl rollout undo 로 되돌리고 다시 빌드했다.
## 검증 환경
@@ -40,7 +40,7 @@ healthy 판정은 헬스 엔드포인트를 본다. 그 엔드포인트는 이
이미지가 권한을 정규화하도록 고쳤다.
같은 배포에서 favicon 도 404 였다. `index.html``public/favicon.svg` 를 참조한 적이 없다. 파일은 이미지에 들어 있었고 nginx 도 서빙했지만 브라우저는 `/favicon.ico` 를 물었고 404 를 받아 기본 아이콘으로 떨어졌다.
같은 배포에서 favicon 도 404 였다. index.html 이 public/favicon.svg 를 참조한 적이 없다. 파일은 이미지에 들어 있었고 nginx 도 서빙했지만 브라우저는 /favicon.ico 를 물었고 404 를 받아 기본 아이콘으로 떨어졌다.
## 검증 환경
@@ -29,13 +29,13 @@ source:
이 건은 같은 갈래의 다른 것들과 결이 다르다. 테스트가 아니라 생성기가 값을 버렸다.
파생 스펙을 파서에 넣으면 스키마 15개를 「is not of type `object`」로 거절했다. 그 스키마들은 전부 `type: object` 를 명시하고 있어서 계약 결함처럼 보이지 않았다.
파생 스펙을 파서에 넣으면 스키마 15개를 「is not of type object」로 거절했다. 그 스키마들은 전부 type: object 를 명시하고 있어서 계약 결함처럼 보이지 않았다.
`validateSpec` 을 끄면 생성이 성공한다. 그렇게 만든 모델을 컴파일하면 통과한다.
validateSpec 을 끄면 생성이 성공한다. 그렇게 만든 모델을 컴파일하면 통과한다.
## 결론
거절의 원인은 계약이 아니라 파생 단계였다. 변환들이 같은 `Map` 인스턴스를 여러 property 에 재사용했고 snakeyaml 이 그 지점을 anchor 와 alias 로 덤프했다. 파생 스펙에 alias 가 34곳 있었다.
거절의 원인은 계약이 아니라 파생 단계였다. 변환들이 같은 Map 인스턴스를 여러 property 에 재사용했고 snakeyaml 이 그 지점을 anchor 와 alias 로 덤프했다. 파생 스펙에 alias 가 34곳 있었다.
검증을 끄고 만든 모델에서 사라진 필드 넷 :
LatestEntry.publishedAt
@@ -45,7 +45,7 @@ ReleaseListItem.changeTypes
컴파일이 통과한 이유는 아직 그 필드를 쓰는 코드가 없어서다.
덤프 직전 deep copy 로 노드 identity 를 끊어 alias 를 원천 차단하고, 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀다. `validateSpec` 은 다시 켰다. 생성 모델 대조는 schema 이름에서 property 단위로 강화했다 — 이번 누락을 그 게이트가 통과시켰기 때문이다.
덤프 직전 deep copy 로 노드 identity 를 끊어 alias 를 원천 차단하고, 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀다. validateSpec 은 다시 켰다. 생성 모델 대조는 schema 이름에서 property 단위로 강화했다 — 이번 누락을 그 게이트가 통과시켰기 때문이다.
## 검증 환경
@@ -57,7 +57,7 @@ tech-log-backend : 365560e
## 재현 조건
1. 파생 스펙에서 anchor 와 alias 를 찾는다
2. `validateSpec` 을 켜고 생성한다 — 그 스키마들이 거절된다
2. validateSpec 을 켜고 생성한다 — 그 스키마들이 거절된다
3. 끄고 생성한 뒤 모델의 property 를 계약과 하나씩 맞춘다
## 본문
@@ -14,7 +14,7 @@ source:
# 그 SQL 은 한 번도 실행된 적이 없었다
작업본 삭제가 500 을 돌려줬다. 참조 검사가 없는 컬럼을 조회하고 있었다. 진짜 문제는 그 SQL 이 한 번도 실행된 적이 없다는 것이었다 — 표준 `check` 는 Testcontainers 를 띄우지 않으므로 persistence SQL 을 한 줄도 돌리지 않고 빌드가 통과한다.
작업본 삭제가 500 을 돌려줬다. 참조 검사가 없는 컬럼을 조회하고 있었다. 진짜 문제는 그 SQL 이 한 번도 실행된 적이 없다는 것이었다 — 표준 check 는 Testcontainers 를 띄우지 않으므로 persistence SQL 을 한 줄도 돌리지 않고 빌드가 통과한다.
## 관계
@@ -27,7 +27,7 @@ source:
## 문제
작업본을 지우려 하면 500 이 났다. 참조 검사가 `public_resource_projection.document_id` 를 조회하는데 그런 컬럼이 없다.
작업본을 지우려 하면 500 이 났다. 참조 검사가 public_resource_projection.document_id 를 조회하는데 그런 컬럼이 없다.
이 테이블은 하나로 case·question·project·release 를 모두 담기 때문에 종류와 식별자 두 컬럼으로 기록을 가리킨다.
@@ -37,7 +37,7 @@ source:
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
표준 `check` 는 Testcontainers 를 띄우지 않는다. persistence SQL 은 한 번도 실행되지 않은 채 빌드가 통과하고, 컴파일도 단위 테스트도 컬럼 이름을 검증하지 못한다.
표준 check 는 Testcontainers 를 띄우지 않는다. persistence SQL 은 한 번도 실행되지 않은 채 빌드가 통과하고, 컴파일도 단위 테스트도 컬럼 이름을 검증하지 못한다.
삭제 경로 전용 통합 테스트 태스크를 만들고 실패했던 그 쿼리를 포함해 여덟 시나리오를 실제 PostgreSQL 에서 돌린다.
@@ -51,7 +51,7 @@ DB : 실제 PostgreSQL (Testcontainers)
## 재현 조건
1. 어댑터의 SQL 에서 컬럼 이름을 하나 틀리게 적는다
2. `./gradlew check` 를 돌린다 — 통과한다
2. ./gradlew check 를 돌린다 — 통과한다
3. 삭제 경로 통합 테스트 태스크를 돌린다 — 그 쿼리에서 멈춘다
## 본문
@@ -31,18 +31,18 @@ source:
컴포넌트 스캔이 생성자를 고르지 못하면 컨텍스트가 refresh 에 실패한다. 컨텍스트를 띄우는 테스트가 없으면 그 실패는 배포에서 처음 나타난다.
두 번째 사건은 import 였다. `JdbcProjectRepositoryAdapter` 가 Jackson 2 의 `ObjectMapper` 를 요구했는데 이 빌드는 Jackson 3 이다.
두 번째 사건은 import 였다. JdbcProjectRepositoryAdapter 가 Jackson 2 의 ObjectMapper 를 요구했는데 이 빌드는 Jackson 3 이다.
## 결론
두 건 다 컨텍스트가 뜰 때 처음 드러났다.
첫 번째 : 스캔되는 컴포넌트에 생성자 둘, `@Autowired` 없음
두 번째 : Jackson 2 `ObjectMapper` 를 요구, 이 빌드는 Jackson 3
첫 번째 : 스캔되는 컴포넌트에 생성자 둘, @Autowired 없음
두 번째 : Jackson 2 ObjectMapper 를 요구, 이 빌드는 Jackson 3
두 번째가 컴파일을 통과한 이유는 Jackson 2 타입이 어떤 전이 의존성을 통해 클래스패스에 남아 있어서다. 잘못된 import 가 정상적으로 해석된다.
첫 번째는 ArchUnit 규칙으로 막았다. 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 `@Autowired` 가 붙어야 한다. 규칙이 실제로 잡는지 결함을 되돌려 확인했다.
첫 번째는 ArchUnit 규칙으로 막았다. 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 @Autowired 가 붙어야 한다. 규칙이 실제로 잡는지 결함을 되돌려 확인했다.
## 검증 환경
@@ -53,8 +53,8 @@ Jackson : 이 빌드는 Jackson 3, 클래스패스에 Jackson 2 타입이 전이
## 재현 조건
1. 스캔되는 컴포넌트에 생성자를 둘 만들고 `@Autowired` 를 붙이지 않는다
2. `./gradlew check` 를 돌린다 — 통과한다
1. 스캔되는 컴포넌트에 생성자를 둘 만들고 @Autowired 를 붙이지 않는다
2. ./gradlew check 를 돌린다 — 통과한다
3. ArchUnit D20 규칙을 켠 상태로 돌린다 — 그 컴포넌트를 짚는다
## 본문
@@ -1683,16 +1683,8 @@
"file": "an-axis-inside-a-topic/case/case-the-comparison-band-changed-three-times.md",
"status": "게시 전",
"studioId": "",
"assets": [
"home-tabs-keycloak",
"home-topic-tabs-2",
"home-tabs-grouped"
],
"assetFiles": [
"home-tabs-keycloak.png",
"home-topic-tabs-2.png",
"home-tabs-grouped.png"
],
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/browser/home-tabs-keycloak.png",
"../../../final/evidence/browser/home-topic-tabs.png",
@@ -27,9 +27,9 @@ source:
## 문제
결정은 상세 endpoint 가 없다. 공개 주소가 `/projects/{slug}/decisions#{slug}` 로 목록 위의 앵커다.
결정은 상세 endpoint 가 없다. 공개 주소가 /projects/{slug}/decisions#{slug} 로 목록 위의 앵커다.
상세가 없으면 화면이 그리는 칸이 전부 목록 항목에 있어야 한다. 목록 항목에는 `title`·`summary`·`consequences`·`evidence` 가 빠져 있었다.
상세가 없으면 화면이 그리는 칸이 전부 목록 항목에 있어야 한다. 목록 항목에는 title·summary·consequences·evidence 가 빠져 있었다.
## 결론
@@ -15,7 +15,7 @@ source:
# 공개 Reference 가 통째로 비어 있었다 — 이름이 어긋났고 본문은 다른 테이블에 있었다
Reference 를 게시했더니 Studio 에서는 모든 칸이 보이는데 공개 화면만 통째로 비어 있었다. 원인이 둘 겹쳐 있었다. 게이트웨이가 읽던 칸 이름이 계약에 없는 것들이었고, Reference 의 본문이 `body_markdown` 이 아니라 별도 테이블에 있었다. 타입 검사는 `as` 단언 때문에 아무 말도 하지 않았다.
Reference 를 게시했더니 Studio 에서는 모든 칸이 보이는데 공개 화면만 통째로 비어 있었다. 원인이 둘 겹쳐 있었다. 게이트웨이가 읽던 칸 이름이 계약에 없는 것들이었고, Reference 의 본문이 body_markdown 이 아니라 별도 테이블에 있었다. 타입 검사는 as 단언 때문에 아무 말도 하지 않았다.
## 관계
@@ -24,7 +24,7 @@ Reference 를 게시했더니 Studio 에서는 모든 칸이 보이는데 공개
- **Studio 에서는 보이는데 공개 쪽만 비면 그 사이에 계약이 있다**
이 사건에서 굳힌 진단 규칙이다.
- **TypeScript 가 검사를 놓아 주는 네 곳**
`as` 단언이 어긋남을 가린 것을 그 개념이 설명한다.
as 단언이 어긋남을 가린 것을 그 개념이 설명한다.
## 문제
@@ -40,9 +40,9 @@ DB 에는 작성자가 쓴 값이 그대로 있었다. 두 화면이 같은 데
계약이 주는 이름 : scopeSummary · appliesTo · excludedScope
결과 : 전부 undefined 로 떨어졌고, as string 단언 때문에 타입 검사가 통과했다
Reference 의 본문은 `body_markdown` 이 아니라 `reference_detail` 의 규칙과 예시에 있다. Studio 편집기가 규칙을 제목과 본문으로 나눠 받고 마크다운 본문은 비워 두기 때문이다. 공개 조회는 `body_markdown` 만 보고 빈 문자열을 내보냈다.
Reference 의 본문은 body_markdown 이 아니라 reference_detail 의 규칙과 예시에 있다. Studio 편집기가 규칙을 제목과 본문으로 나눠 받고 마크다운 본문은 비워 두기 때문이다. 공개 조회는 body_markdown 만 보고 빈 문자열을 내보냈다.
고친 뒤에는 값이 아니라 이름을 지키는 테스트를 뒀다. 계약에서 그 칸이 사라지면 `satisfies` 가 먼저 깨진다. 값을 검사하는 테스트로는 이 결함이 잡히지 않는다.
고친 뒤에는 값이 아니라 이름을 지키는 테스트를 뒀다. 계약에서 그 칸이 사라지면 satisfies 가 먼저 깨진다. 값을 검사하는 테스트로는 이 결함이 잡히지 않는다.
## 검증 환경
@@ -69,7 +69,7 @@ tech-log-backend : a5f93b9 이후
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
```
`as` 는 「이 값을 이 타입으로 다루겠다」는 선언이므로, 컴파일러는 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나오고 화면은 빈 문자열을 그린다.
as 는 「이 값을 이 타입으로 다루겠다」는 선언이므로, 컴파일러는 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나오고 화면은 빈 문자열을 그린다.
| 게이트웨이가 읽던 이름 | 계약이 주는 이름 |
|---|---|
@@ -111,6 +111,6 @@ Reference 의 본문은 문서 본문 칸이 아니라 규칙과 예시를 담
## 확인하지 못한 것
`as` 단언을 걷어낸 것은 이 매퍼 하나다. 같은 모양이 다른 매퍼에 남아 있는지 전수로 세지 않았다.
as 단언을 걷어낸 것은 이 매퍼 하나다. 같은 모양이 다른 매퍼에 남아 있는지 전수로 세지 않았다.
<!-- body:end -->
@@ -40,9 +40,9 @@ flattenRelations : 담지 않음 — 1차로 고침
렌더 모델로 변환 : 담을 칸 자체가 없었음
화면 목록으로 전달 : 또 버림
렌더 모델 계약(`ResolvedRelation`)에 요약 칸이 없었고 `additionalProperties: false` 라 실을 수도 없었다. 계약에 `summary` 를 더하고 — 이미 나가 있는 응답을 깨지 않으려고 required 에는 넣지 않고 — 세 경계를 모두 이었다.
렌더 모델 계약(ResolvedRelation)에 요약 칸이 없었고 additionalProperties: false 라 실을 수도 없었다. 계약에 summary 를 더하고 — 이미 나가 있는 응답을 깨지 않으려고 required 에는 넣지 않고 — 세 경계를 모두 이었다.
그 과정에서 한 칸에 뭉쳐 있던 셋을 갈랐다. 대상의 종류는 `label`, 작성자가 쓴 이유는 `note`, 대상의 요약은 `summary` 다.
그 과정에서 한 칸에 뭉쳐 있던 셋을 갈랐다. 대상의 종류는 label, 작성자가 쓴 이유는 note, 대상의 요약은 summary 다.
## 검증 환경
@@ -18,7 +18,7 @@ source:
# 공개 화면 한 줄이 그려지기까지 값이 지나는 경계 열한 개
공개 화면의 한 줄은 PostgreSQL 의 투영 테이블에서 출발해 열한 번 모양을 바꾼 뒤에 그려진다. 그 사이 어느 한 곳이 값을 담지 않아도 오류가 나지 않는다. `undefined` 는 빈 문자열로 그려지고 빈 배열은 「항목이 없습니다」로 그려진다.
공개 화면의 한 줄은 PostgreSQL 의 투영 테이블에서 출발해 열한 번 모양을 바꾼 뒤에 그려진다. 그 사이 어느 한 곳이 값을 담지 않아도 오류가 나지 않는다. undefined 는 빈 문자열로 그려지고 빈 배열은 「항목이 없습니다」로 그려진다.
## 관계
@@ -51,8 +51,7 @@ PostgreSQL 테이블
└─ 화면 컴포넌트
```
:::evidence key="value-boundaries" alt="저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 세 저장소 구역으로 나눠 이은 흐름도" caption=" " zoom="true"
:::
![저장·백엔드 조립·HTTP envelope·프론트엔드 조립·화면 다섯 묶음을 세 저장소 구역으로 나눠 이은 흐름도](../../../final/assets/diagrams/value-boundaries/value-boundaries.svg)
저장 쪽에 둘, 백엔드 조립에 넷, 전선에 하나, 프론트엔드 조립에 셋, 화면에 하나다. 저장소 경계로 보면 백엔드가 여섯, 전선이 하나, 프론트엔드가 넷이다.
@@ -64,7 +63,7 @@ PostgreSQL 테이블
|---|---|---|
| 어댑터 SQL | 실행할 때 컬럼이 있는지 | 컴파일 시점에는 컬럼 이름을 아무도 안 본다 |
| 생성된 DTO | 계약의 스키마 모양 | 그 칸에 값이 담겼는지 |
| 게이트웨이 매퍼 | 계약이 준 타입의 이름 | `as` 단언을 쓰면 그 확인이 사라진다 |
| 게이트웨이 매퍼 | 계약이 준 타입의 이름 | as 단언을 쓰면 그 확인이 사라진다 |
| 포트와 화면 | 두 타입이 맞는지 | 포트와 어댑터가 타입을 따로 들면 한쪽만 늘어난다 |
어댑터 SQL 은 컬럼 이름을 문자열로 적는다. 이름이 틀리면 실행할 때 알게 되고, 그 SQL 을 실제로 돌리는 검사가 없으면 배포 뒤에 알게 된다.
@@ -30,7 +30,7 @@ Studio 편집기에서는 값이 다 보이는데 공개 화면만 비어 있으
데이터베이스에 값이 있는데 화면이 비어 있을 때 어디를 먼저 볼지 정한다.
이 부류는 오류를 내지 않으므로 로그에서 출발하면 아무것도 나오지 않는다. `undefined` 는 빈 문자열로 그려지고 빈 배열은 「항목이 없습니다」로 그려진다.
이 부류는 오류를 내지 않으므로 로그에서 출발하면 아무것도 나오지 않는다. undefined 는 빈 문자열로 그려지고 빈 배열은 「항목이 없습니다」로 그려진다.
## 규칙
@@ -41,7 +41,7 @@ source:
### 2. 타입 검사 통과를 반영의 증거로 쓰지 않는다
메서드 매개변수의 bivariance, `as` 단언, 검사 대상이 없는 tsconfig 가 각각 통과시킨 사례가 있다. 통과는 「코드가 맞다」가 아니라 「검사가 그 질문을 하지 않았다」를 뜻할 수 있다.
메서드 매개변수의 bivariance, as 단언, 검사 대상이 없는 tsconfig 가 각각 통과시킨 사례가 있다. 통과는 「코드가 맞다」가 아니라 「검사가 그 질문을 하지 않았다」를 뜻할 수 있다.
### 3. 게이트웨이를 실제로 불러 어떤 연산이 나가는지 확인한다
@@ -29,7 +29,7 @@ source:
포트는 네 종류를 받는다고 선언돼 있는데 구현은 세 종류만 적혀 있었다. 그 상태로 타입 검사가 통과했다.
CONCEPT 을 넘기면 구현의 삼항 사슬이 마지막 `else` 로 떨어뜨려 질문 삭제 경로를 부른다. 배포된 번들에서 서버 로그에 `DELETE /api/v1/studio/questions/{id} 404` 가 계속 찍혔다.
CONCEPT 을 넘기면 구현의 삼항 사슬이 마지막 else 로 떨어뜨려 질문 삭제 경로를 부른다. 배포된 번들에서 서버 로그에 DELETE /api/v1/studio/questions/{id} 404 가 계속 찍혔다.
## 결론
@@ -40,7 +40,7 @@ TypeScript 에서 메서드 매개변수는 bivariant 다. 구현이 매개변
타입 검사 : 통과
실행 결과 : CONCEPT 이 질문 삭제로 나감
같은 병이 필터 타입에서도 났다. 포트와 정적 어댑터가 타입을 따로 들고 있어, 포트에 필터가 늘어도 어댑터는 모르는 상태가 됐다. `satisfies` 도 같은 이유로 잡지 못했다. 타입을 하나로 합쳐서 고쳤다.
같은 병이 필터 타입에서도 났다. 포트와 정적 어댑터가 타입을 따로 들고 있어, 포트에 필터가 늘어도 어댑터는 모르는 상태가 됐다. satisfies 도 같은 이유로 잡지 못했다. 타입을 하나로 합쳐서 고쳤다.
## 검증 환경
@@ -14,7 +14,7 @@ source:
# npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다
운영에서 릴리즈 목록이 `ReferenceError` 로 비었다. import 하나가 빠져 있었고 다른 변수는 아예 정의된 적이 없었다. `npx tsc --noEmit` 이 통과했기 때문에 그것을 보지 못했다. 루트 tsconfig 는 `"files": []` 에 project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다.
운영에서 릴리즈 목록이 ReferenceError 로 비었다. import 하나가 빠져 있었고 다른 변수는 아예 정의된 적이 없었다. npx tsc --noEmit 이 통과했기 때문에 그것을 보지 못했다. 루트 tsconfig 는 "files": [] 에 project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다.
## 관계
@@ -27,15 +27,15 @@ source:
## 문제
운영에서 릴리즈 목록 화면이 비었고 콘솔에 `ReferenceError` 가 났다. 링크 컴포넌트 import 가 빠졌고, 이동 함수는 정의된 적이 없었다.
운영에서 릴리즈 목록 화면이 비었고 콘솔에 ReferenceError 가 났다. 링크 컴포넌트 import 가 빠졌고, 이동 함수는 정의된 적이 없었다.
타입 검사를 돌렸을 때 통과했다. 그래서 이 오류가 배포까지 갔다.
## 결론
`npx tsc --noEmit` 은 루트 tsconfig 를 읽는다. 그 파일은 `"files": []` 에 project references 만 나열하므로 검사할 파일이 없고, 없는 채로 성공한다.
npx tsc --noEmit 은 루트 tsconfig 를 읽는다. 그 파일은 "files": [] 에 project references 만 나열하므로 검사할 파일이 없고, 없는 채로 성공한다.
실제 검사는 `npm run check:types` 가 한다. 이 명령이 여섯 개 프로젝트를 돌며 검사한다.
실제 검사는 npm run check:types 가 한다. 이 명령이 여섯 개 프로젝트를 돌며 검사한다.
그 명령으로 돌리자 저장소에 남아 있던 다른 오류도 함께 드러났다.
@@ -53,8 +53,8 @@ tsconfig : 루트가 project references 만 나열
## 재현 조건
1. 어느 프로젝트 파일에 정의되지 않은 변수를 하나 넣는다
2. `npx tsc --noEmit` 을 돌린다 — 성공한다
3. `npm run check:types` 를 돌린다 — 그 파일에서 멈춘다
2. npx tsc --noEmit 을 돌린다 — 성공한다
3. npm run check:types 를 돌린다 — 그 파일에서 멈춘다
## 본문
@@ -16,7 +16,7 @@ source:
# TypeScript 가 검사를 놓아 주는 네 자리 — 메서드 매개변수의 bivariance · as 단언 · never 캐스트 · 검사 대상을 갖지 않은 tsconfig
「타입 검사가 통과했으니 반영됐다」는 판단이 이 저장소에서 네 번 틀렸다. 매번 다른 이유였다 — 메서드 매개변수의 bivariance, `as` 단언, `never` 로 받아 캐스팅하는 조립기, 그리고 검사할 파일을 갖지 않은 tsconfig 다.
「타입 검사가 통과했으니 반영됐다」는 판단이 이 저장소에서 네 번 틀렸다. 매번 다른 이유였다 — 메서드 매개변수의 bivariance, as 단언, never 로 받아 캐스팅하는 조립기, 그리고 검사할 파일을 갖지 않은 tsconfig 다.
## 관계
@@ -25,7 +25,7 @@ source:
- **npx tsc --noEmit 이 한 파일도 검사하지 않고 성공했다**
검사 대상이 없는 tsconfig 가 통과시킨 사건이다.
- **공개 Reference 가 통째로 비어 있었다**
`as` 단언이 통과시킨 사건이다.
as 단언이 통과시킨 사건이다.
## 본문
@@ -35,7 +35,7 @@ source:
「타입 검사가 통과했다」가 뜻하는 것은 컴파일러가 물은 질문에 코드가 답했다는 것이다. 이 저장소에서 네 번, 컴파일러가 물었어야 할 질문을 묻지 않았다.
넷은 서로 다른 방식으로 질문을 바꾼다. bivariance 는 「이 구현이 그 포트와 맞는가」를 「두 시그니처가 호환되는가」로, `as` 는 「이 이름이 그 타입에 있는가」를 작성자의 선언으로, `never` 캐스트는 「이 인자를 다 적었는가」를 검사 없음으로, 검사 대상이 없는 tsconfig 는 「이 코드가 컴파일되는가」를 대상 없음으로 바꾼다.
넷은 서로 다른 방식으로 질문을 바꾼다. bivariance 는 「이 구현이 그 포트와 맞는가」를 「두 시그니처가 호환되는가」로, as 는 「이 이름이 그 타입에 있는가」를 작성자의 선언으로, `never` 캐스트는 「이 인자를 다 적었는가」를 검사 없음으로, 검사 대상이 없는 tsconfig 는 「이 코드가 컴파일되는가」를 대상 없음으로 바꾼다.
## 메서드 매개변수는 bivariant 다
@@ -57,7 +57,7 @@ deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string):
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
```
`as` 는 「이 값을 이 타입으로 다루겠다」는 선언이다. 그 이름이 응답 타입에 있는지를 컴파일러가 묻지 않고, 실행하면 `undefined` 가 나온다.
as 는 「이 값을 이 타입으로 다루겠다」는 선언이다. 그 이름이 응답 타입에 있는지를 컴파일러가 묻지 않고, 실행하면 `undefined` 가 나온다.
모양이 다른 경우에는 더 나빠진다. 객체를 배열로 읽고 `.filter` 를 부르면 매핑이 통째로 터지는데, 캐스트가 그 어긋남을 타입 검사에서 가리므로 배포된 뒤에 알게 된다.
@@ -78,7 +78,7 @@ const summary = body.purposeSummary as string; // 계약에 그런 칸이 없
| 무엇이 통과했나 | 무엇이 확인되지 않았나 | 어디서 드러났나 |
|---|---|---|
| bivariance | 구현이 네 번째 값을 다루는가 | 배포본의 서버 로그 404 |
| `as` 단언 | 그 이름이 응답 타입에 있는가 | 공개 화면이 비어 있음 |
| as 단언 | 그 이름이 응답 타입에 있는가 | 공개 화면이 비어 있음 |
| `never` 캐스트 | 새 질의 인자를 조립기가 적었는가 | 페이지 번호가 안 넘어감 |
| 검사 대상 없는 tsconfig | 이 코드가 컴파일되는가 | 운영의 ReferenceError |
@@ -43,7 +43,7 @@ CI 에 묶이지 않은 검증이 남아 있으면 그것을 돌리는 것은
### 2. 타입 검사는 프로젝트를 순회하는 명령으로 돌린다
루트 tsconfig 를 직접 부르는 명령은 한 파일도 검사하지 않고 성공한다. 루트가 `"files": []` 에 project references 만 나열하기 때문이다.
루트 tsconfig 를 직접 부르는 명령은 한 파일도 검사하지 않고 성공한다. 루트가 "files": [] 에 project references 만 나열하기 때문이다.
### 3. 백엔드는 커밋한 뒤에 빌드한다
@@ -51,7 +51,7 @@ CI 에 묶이지 않은 검증이 남아 있으면 그것을 돌리는 것은
### 4. 테스트를 npm 이나 npx 로 감싸 돌리지 않는다
`npm_config_*` 환경 변수가 설정되어 CI 워크플로 생성 테스트가 실패한다. 그 변수를 지우고 실행기를 직접 부른다.
npm_config_* 환경 변수가 설정되어 CI 워크플로 생성 테스트가 실패한다. 그 변수를 지우고 실행기를 직접 부른다.
### 5. 환경 때문에 실패하는 것은 실패로 세지 않되 목록에 적는다
@@ -1,96 +0,0 @@
# Refactoring From Analysis Design
## Goal
Use completed `/shared/document-detail/<project>` analysis as the planning context for bounded refactoring, while keeping current application source as the SSOT and retaining verifiable evidence for every change.
## Core pipeline
1. Only an analysis snapshot whose queue state is `COMPLETE`, whose recorded revision equals the current repository HEAD, and whose working tree is clean may feed refactoring.
2. Findings from `document-detail` become bounded WorkItems. Queue order is controlled by priority, while `type` and `scope` determine execution and verification strategy.
3. Each WorkItem is implemented in an isolated Git worktree/branch, never directly in the analysis source checkout.
4. Verification evidence is retained under `/shared/refactor-detail/<project>/<work-item>/`.
5. An item cannot reach `WAITING_APPROVAL` unless the evidence contract for its type is satisfied.
6. Approved merged refactors cause the analysis queue entry to become `REANALYZE`; human-authored repository changes remain an explicit reanalysis decision.
## Durable layout
```text
/shared/codebase/refactor-queue.yaml
/shared/refactor-detail/<project>/<work-item>/
├── work-item.json
├── plan.md
├── evidence/
│ ├── environment.md
│ ├── baseline/raw/
│ ├── after/raw/
│ └── comparison.md
├── verification/
└── diff/
```
The queue carries ordering/state summaries. `work-item.json` is the detail SSOT for the refactor item. `document-detail` is context; current code is source truth.
## WorkItem fields
Every item records: id, project, analysisRevision, priority, type, scope, target, status, problem, goal, acceptanceCriteria, and evidence references.
Allowed initial type taxonomy:
- `PERFORMANCE`
- `CODE_STRUCTURE`
- `MODULE_STRUCTURE`
- `ARCHITECTURE`
- `DATA_ACCESS`
- `RELIABILITY`
- `CONCURRENCY`
- `TRANSACTION`
- `SECURITY`
- `OPERABILITY`
- `CONFIGURATION`
- `DEPENDENCY`
- `BUILD`
- `TESTABILITY`
- `CLEANUP`
Allowed scopes: `LOCAL`, `MODULE`, `CROSS_MODULE`, `PROJECT`.
Priority determines order (`P0`..`P3`, then queue order). Type/scope never replace priority; they select the verification contract.
## Performance hard gate
A `PERFORMANCE` WorkItem must define its measurement contract before source modification:
- exact measurement command or reproducible procedure;
- environment evidence path;
- dataset/load fixture identifier;
- metrics to compare;
- acceptance criteria.
The baseline must be captured before the refactor. After the change, the same measurement contract must be used. Before `WAITING_APPROVAL`, retained evidence must include:
- raw baseline output;
- raw after output;
- environment record;
- `comparison.md` containing before/after values, delta, conditions, and acceptance result.
If equivalent conditions cannot be reproduced, the item is `BLOCKED`; no improvement claim is allowed.
## Type-directed verification
- `PERFORMANCE`: baseline + after measurement + comparison + functional regression checks.
- `BUILD`: baseline/after build measurement when improvement is claimed, plus build correctness.
- `ARCHITECTURE`, `MODULE_STRUCTURE`, `DEPENDENCY`: dependency graph/architecture rules/build/integration evidence as applicable.
- `DATA_ACCESS`, `TRANSACTION`, `CONCURRENCY`, `RELIABILITY`: representative integration/contract/failure-path evidence; concurrency or failure injection where the claim depends on it.
- `SECURITY`: security regression tests/configuration/negative-path evidence without storing secrets.
- `CODE_STRUCTURE`, `CLEANUP`, `TESTABILITY`, `CONFIGURATION`, `OPERABILITY`: behavior-preserving tests plus references/build/runtime checks appropriate to the item.
All types retain the commands and raw verification outputs used to justify completion.
## Safety
- Never refactor an analysis snapshot that is stale or dirty.
- Never fabricate benchmark output, runtime evidence, or before/after comparisons.
- Never weaken or delete a failing test merely to make a refactor pass.
- Never store secrets in evidence.
- Large goals must be decomposed into reviewable WorkItems; one scheduled execution works on at most one item.
@@ -1,68 +0,0 @@
# Refactoring From Analysis Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Add a durable, type-directed refactoring workspace whose performance items require retained baseline and after evidence.
**Architecture:** `refactor-queue.yaml` orders bounded items, while `/shared/refactor-detail/<project>/<item>/work-item.json` owns detailed item metadata and evidence references. A dedicated verifier enforces type-specific hard gates, especially the performance baseline/after contract.
**Tech Stack:** Markdown/YAML/JSON, Python unittest, existing `/shared` workspace.
**Spec:** `docs/superpowers/specs/2026-08-28-refactoring-from-analysis-design.md`
## Global Constraints
- Application source remains read-only during documentation/refactor planning.
- Performance claims require retained raw baseline and after evidence under equivalent conditions.
- Existing documentation and user files must not be deleted or reset.
- No legacy workspace dependency may be introduced.
---
### Task 1: WorkItem evidence verifier
**Files:**
- Create: `tools/verify_refactor_work_item.py`
- Test: `tools/tests/test_verify_refactor_work_item.py`
- [x] **Step 1: Write failing tests for type validation and performance evidence gates**
- [x] **Step 2: Run tests and observe missing verifier failure**
- [x] **Step 3: Implement the verifier**
- [x] **Step 4: Run verifier tests green**
### Task 2: Refactoring skill and contracts
**Files:**
- Create: `.agents/skills/refactoring-from-analysis/SKILL.md`
- Create: `.agents/skills/refactoring-from-analysis/references/work-item-contract.md`
- Create: `.agents/skills/refactoring-from-analysis/references/type-strategies.md`
- Create: `.agents/skills/refactoring-from-analysis/references/performance-evidence-contract.md`
- Create: `.agents/skills/refactoring-from-analysis/references/evidence-contract.md`
- [x] **Step 1: Encode queue eligibility and one-bounded-item rule**
- [x] **Step 2: Encode type-directed execution and verification**
- [x] **Step 3: Encode performance evidence hard gate**
### Task 3: Durable queue/detail templates
**Files:**
- Create: `/shared/codebase/refactor-queue.yaml`
- Create: `/shared/refactor-detail/README.md`
- Create: `/shared/refactor-detail/_templates/work-item.json`
- Create: `/shared/refactor-detail/_templates/plan.md`
- Create: evidence and verification template directories
- [x] **Step 1: Create queue SSOT**
- [x] **Step 2: Create WorkItem/evidence templates**
### Task 4: Pipeline integration and verification
**Files:**
- Modify: `AGENTS.md`
- Modify: `tools/verify_pipeline.py`
- Modify: `tools/tests/test_verify_pipeline.py`
- [x] **Step 1: Add refactor paths to pipeline verification**
- [x] **Step 2: Add instructions for the new stage**
- [x] **Step 3: Run all verifier and terminal renderer tests**
- [x] **Step 4: Run live workspace verification**
@@ -1,216 +0,0 @@
# Tech Log Document Pipeline Design
## Goal
Build a durable, project-scoped pipeline that turns a codebase into a deeply evidenced analysis document, decomposes that analysis into a root tree of Tech Log records, generates the records and evidence assets, and later performs an editorial pass without depending on any legacy workspace.
## Non-goals
- Do not publish records to Tech Log automatically.
- Do not invent incidents, decisions, measurements, or first-person experiences that are not supported by the codebase, command output, browser evidence, Git history, or explicitly supplied source material.
- Do not make the pipeline depend on any legacy document repository or path.
- Do not treat the root tree as a brainstorming list. Every node must be traceable to analysis evidence.
## Workspace contract
```text
/shared/
├── codebase/
│ └── <project>/
├── document-detail/
│ └── <project>/
│ ├── README.md
│ ├── state.json
│ ├── source-index.md
│ ├── analysis/
│ │ ├── 00-project-overview.md
│ │ └── <module-or-scope>.md
│ ├── final/
│ │ └── document.md
│ ├── root-tree.md
│ ├── notes/
│ ├── checkpoints/
│ └── evidence/
│ ├── raw/
│ ├── terminal/
│ ├── browser/
│ ├── svg/
│ └── meta/
└── Tech-Log-Document/
├── AGENTS.md
├── README.md
├── .agents/skills/
│ ├── writing-tech-log-from-analysis/
│ └── humanizing-korean-tech-writing/
├── tools/terminal-evidence/
├── research/korean-tech-writing/
├── _templates/project/
└── <project>/
├── case/
├── reference/
├── openquestion/
├── decision/
├── assets/
│ ├── raw/
│ ├── terminal/
│ ├── browser/
│ └── svg/
└── _meta/
```
## Pipeline stages
### Stage A — Detailed codebase analysis
Input: `/shared/codebase/<project>`.
Output: `/shared/document-detail/<project>`.
For a small codebase, analysis may converge in one run. For a large codebase, analyze one bounded module or subsystem per run and update `state.json` and `source-index.md`. The final document is a synthesis of completed module analyses, not a fresh rewrite that discards their provenance.
Required analysis properties:
- map project/module/package boundaries and dependency direction;
- trace representative request, state, persistence, messaging, error, and operational paths when present;
- identify implemented behavior separately from declared-but-unwired contracts;
- inspect tests, build rules, configuration, Git history, and runtime behavior when they materially change the interpretation;
- distinguish observed facts, code-derived inference, hypotheses, and external knowledge;
- capture command/browser evidence for claims that benefit from execution verification;
- preserve exact versions, paths, commands, status codes, measurements, and identifiers in evidence.
### Stage B — Root tree derivation
Input: `final/document.md`, module analyses, source index, evidence.
Output: `root-tree.md`.
The root tree is the decomposition contract for all downstream Tech Log records. It groups records by Topic and by kind: CASE, REFERENCE, OPEN QUESTION, DECISION.
A node is not valid merely because its title sounds useful. Each node records:
- slug;
- source anchors into the detailed analysis;
- code/evidence references when relevant;
- why it belongs to that record kind;
- readiness status;
- missing verification, if any;
- relations to sibling nodes.
Allowed readiness values:
- `READY`: enough grounded material exists to author the record;
- `NEEDS_EVIDENCE`: the idea is grounded, but a material claim still needs execution or browser evidence;
- `NEEDS_DECISION`: a Decision title is plausible but no project decision has actually been made;
- `OPEN`: valid Question with unresolved unknowns;
- `BLOCKED`: source material is insufficient or contradictory;
- `REJECTED`: candidate must not become a record.
Only `READY` Case/Reference nodes, actual adopted/proposed project Decision nodes with explicit decision evidence, and legitimate `OPEN` Question nodes may enter Stage C.
### Record classification contract
**CASE** — a concrete incident, implementation experiment, failure, diagnosis, or verification sequence exists. It must have a specific observed problem/condition, evidence, and bounded conclusion. Case is the only record kind that may carry rich body Markdown such as code, tables, diagrams, and images.
**REFERENCE** — a reusable criterion, distinction, or operating/design rule can be extracted from one or more grounded cases or code observations. It must generalize beyond retelling one incident.
**OPEN QUESTION** — a material design or operational uncertainty remains unresolved. It must state known facts, unknowns, constraints, candidate directions when grounded, and the next verification/decision criterion. It must not smuggle in an answer.
**DECISION** — the project has actually selected or proposed a direction. It requires explicit decision evidence and at least one supporting relation. A best-practice recommendation is not a project Decision.
### Stage C — Tech Log record generation
Input: `root-tree.md` plus cited analysis/evidence.
Output: `/shared/Tech-Log-Document/<project>/{case,reference,openquestion,decision}` plus assets.
Generation rules:
- read the local writing skill before authoring;
- generate only root-tree nodes whose status permits generation;
- re-open the cited source anchors instead of relying on the root-tree title alone;
- never invent a technical reason merely because a technology is present;
- never invent first-person experience;
- preserve protected literals exactly: numbers, dates, versions, units, source paths, code, commands, URLs, status codes, identifiers, quoted text;
- Case rich evidence must be backed by actual raw evidence or a diagram whose semantics are derived from grounded sources;
- Reference/Question/Decision fields remain plain text unless the target Tech Log contract changes;
- relation metadata is generated from root-tree relations and source provenance.
### Stage D — Editorial refinement
Input: generated Tech Log record.
Output: same record, content-preserving editorial revision.
The editorial pass must read `humanizing-korean-tech-writing` first. It may alter diction, sentence rhythm, paragraphing, headings, repetition, and awkward connective phrases. It may not delete technical facts for concision, change evidence, change status/decision semantics, manufacture personal experience, or silently broaden/narrow a claim.
Research on Korean engineering writing is stored under `research/korean-tech-writing/` and distilled into the skill. Runtime editing must not depend on a specific external blog being reachable.
## Evidence model
### Raw first
Evidence is always captured in a raw form before presentation assets are produced.
```text
real command / browser observation
evidence/raw/<artifact>
renderer or curated diagram
evidence/terminal | browser | svg
```
### Terminal evidence
A command run is stored with command, cwd, execution time, exit code, and output. A deterministic renderer converts that real output into an SVG terminal card. The renderer must:
- XML-escape all output;
- preserve the original raw output separately;
- redact obvious secret-bearing environment assignments and authorization/token values from the visual output;
- visually mark truncation when the renderer caps lines;
- never fabricate output lines.
### Browser evidence
Use browser automation only when an application can actually be run and the UI/network behavior is relevant. Store screenshots under the project evidence path and record the URL/state/assertion that makes the screenshot evidentiary rather than decorative.
### Diagrams
SVG diagrams may explain architecture, boundaries, sequences, state, or before/after behavior. A diagram is explanatory evidence, not primary proof. Its labels and relationships must be traceable to code or observed behavior.
## State and idempotency
Each project has state files so scheduled runs can resume safely. A run must inspect Git status and existing state before editing. It must not overwrite uncommitted user work.
Detailed-analysis state records at least:
- project path;
- current code revision when Git is available;
- analyzed scopes;
- pending scopes;
- final synthesis status;
- root-tree status;
- evidence tasks.
Tech-Log generation state records at least:
- root-tree revision/hash;
- generated nodes;
- pending nodes;
- editorial status per record;
- last validation result.
## Safety boundaries
- No automatic `git reset`, `git clean`, branch deletion, push, merge, or destructive filesystem operation.
- No automatic production changes.
- Do not display or persist credentials in evidence.
- Do not silently overwrite source code while doing documentation analysis.
- If the codebase changes materially after analysis, mark affected analysis/root-tree records stale before generating new records.
## Independence requirement
The new pipeline must be self-contained. Its instructions, skills, templates, tools, and scheduled prompts must not reference or require any legacy document workspace. Existing historical material may be consulted once during migration, but all durable rules must live under `/shared/document-detail` or `/shared/Tech-Log-Document` afterward.
@@ -1,100 +0,0 @@
# Tech Log Document Pipeline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Build a self-contained codebase-to-Tech-Log documentation pipeline with project-scoped analysis, grounded root-tree decomposition, evidence tooling, and editorial skills.
**Architecture:** `/shared/codebase/<project>` is the source. `/shared/document-detail/<project>` owns deep analysis and the root tree. `/shared/Tech-Log-Document/<project>` owns publishable record drafts and assets. Local skills and templates make all stages independent from historical workspaces.
**Tech Stack:** Markdown, JSON, Python 3 standard library, SVG, shell-based verification.
**Spec:** `docs/superpowers/specs/2026-08-28-tech-log-document-pipeline-design.md`
## Global Constraints
- No runtime dependency on any legacy document workspace.
- Root-tree nodes require source anchors and readiness state.
- No invented incidents, decisions, measurements, first-person experience, or technical selection reasons.
- Raw evidence precedes rendered evidence.
- Terminal SVGs must derive from actual command output and redact obvious credentials.
- Editorial refinement preserves technical facts and evidence semantics.
---
### Task 1: Workspace contract and templates
**Files:**
- Create: `/shared/Tech-Log-Document/AGENTS.md`
- Create: `/shared/Tech-Log-Document/README.md`
- Create: `/shared/document-detail/README.md`
- Create: `/shared/document-detail/_templates/*`
- Create: `/shared/Tech-Log-Document/_templates/project/*`
- [x] Encode directory ownership, stage boundaries, and safety rules.
- [x] Add project analysis state, source-index, final-document, and root-tree templates.
- [x] Add Tech Log project output/state templates.
- [x] Verify all required paths exist.
### Task 2: Root-tree and Tech Log generation skill
**Files:**
- Create: `.agents/skills/writing-tech-log-from-analysis/SKILL.md`
- Create: `.agents/skills/writing-tech-log-from-analysis/references/record-kinds.md`
- Create: `.agents/skills/writing-tech-log-from-analysis/references/root-tree-contract.md`
- Create: `.agents/skills/writing-tech-log-from-analysis/references/body-syntax.md`
- Create: `.agents/skills/writing-tech-log-from-analysis/references/evidence-and-diagrams.md`
- Create: `.agents/skills/writing-tech-log-from-analysis/references/review-checklist.md`
- Create: `.agents/skills/writing-tech-log-from-analysis/templates/*`
- [x] Distill the historical Tech Log format into self-contained references.
- [x] Make source provenance/readiness gates mandatory.
- [x] Encode different output contracts for Case/Reference/Open Question/Decision.
- [x] Add static verification for forbidden legacy-path dependencies and required skill sections.
### Task 3: Korean technical-writing editorial skill
**Files:**
- Create: `.agents/skills/humanizing-korean-tech-writing/SKILL.md`
- Create: `.agents/skills/humanizing-korean-tech-writing/references/editorial-rules.md`
- Create: `.agents/skills/humanizing-korean-tech-writing/references/protected-content.md`
- Create: `.agents/skills/humanizing-korean-tech-writing/references/research-method.md`
- Create: `research/korean-tech-writing/README.md`
- [x] Encode content-preserving editorial scope.
- [x] Carry forward known AI-writing failure patterns without referencing their historical location.
- [x] Define how later public-blog research is distilled into the skill without copying a single writer's voice.
- [x] Verify protected-content and anti-fabrication rules are present.
### Task 4: Terminal evidence renderer via TDD
**Files:**
- Create: `tools/terminal-evidence/tests/test_render_terminal.py`
- Create: `tools/terminal-evidence/render_terminal.py`
- Create: `tools/terminal-evidence/README.md`
- [x] Write tests for XML escaping, metadata, line rendering, redaction, and truncation marker.
- [x] Run tests before implementation and confirm they fail because the renderer is missing.
- [x] Implement the minimal renderer using Python standard library.
- [x] Run tests and confirm they pass.
- [x] Render a sample from real command output and validate the SVG as XML.
### Task 5: Example root-tree contract
**Files:**
- Create: `/shared/document-detail/_examples/backend-clean-architecture/root-tree.md`
- [x] Encode the requested JPA feed topic tree as an explicitly marked structural example.
- [x] Add source/evidence/readiness metadata placeholders that make clear it is not claimed as newly analyzed evidence.
- [x] Verify the example conforms to the root-tree contract.
### Task 6: End-to-end static verification
**Files:**
- Create: `/shared/Tech-Log-Document/tools/verify_pipeline.py`
- Create: `/shared/Tech-Log-Document/tools/tests/test_verify_pipeline.py`
- [x] Write failing tests for required paths and forbidden legacy dependency strings.
- [x] Implement the verifier.
- [x] Run all tests.
- [x] Search the new pipeline for forbidden legacy-path references.
- [x] Print the final directory tree and verification summary.
+2 -1
View File
@@ -1,2 +1,3 @@
그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들.
Studio 에 올릴 표현물은 tech-log-studio/ 아래에 flat SVG 로 둔다.
기록의 assets: file: 도 이 폴더를 가리킨다. Studio 에 올릴 사본을 따로 두지 않는다 —
사본을 두면 정본이 둘이 되고, 사본 쪽에는 ../../.techviz/<이름>/ 이 없어 다시 만들 수 없다.
@@ -1,2 +0,0 @@
Studio 에 올릴 표현물. 기록 frontmatter 의 `assets: file:` 이 가리키는 자리다.
그림의 정본은 ../<이름>/ 과 ../../.techviz/<이름>/ 에 있다.
@@ -1,106 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="809" viewBox="0 0 680 809" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">커밋 요청 뒤의 결과 판정</title>
<desc id="diagram-description">tracker 가 COMMIT_REQUESTED 를 관측한 뒤 provider commit 을 호출한다. 예외가 없으면 Committed 다. RuntimeException 이 오면 먼저 commitAcknowledged 를 보고, 이미 afterCommit 이 왔으면 CommittedWithPostCommitFailure 다. 그렇지 않으면 rolledBack 과 UnexpectedRollbackException 과 replay 후보 여부를 보고, 하나라도 성립하면 DeterminateRollback 이다. 어느 것도 성립하지 않으면 Indeterminate 로 남는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;commit-evidence-phase-machine&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;docs/clean-architecture-backend-template/final/document.md&quot;,&quot;document_sha256&quot;:&quot;74b4986fd8621f6fd61791232777dc0e6c8ef999cbc2343df050bd5921ea03ac&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지&quot;,&quot;line&quot;:352}},&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="809" />
<polyline class="edge kind-data style-solid emphasis-normal" points="202.0,444.0 202.0,492.0 192.0,492.0 192.0,540.0" data-evidence="401-402" />
<rect class="edge-label-bg" x="141.1" y="450.0" width="111.8" height="22" rx="3" />
<text class="edge-label" x="197.0" y="465.0">afterCommit 도착</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="316.0,124.0 316.0,172.0 316.0,172.0 316.0,220.0" data-evidence="397-399" />
<rect class="edge-label-bg" x="284.1" y="158.0" width="111.8" height="22" rx="3" />
<text class="edge-label" x="340.0" y="173.0">commit(status)</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="325.0,284.0 325.0,332.0 426.0,332.0 426.0,380.0" data-evidence="388-388,396-400" />
<rect class="edge-label-bg" x="349.8" y="290.0" width="51.5" height="22" rx="3" />
<text class="edge-label" x="375.5" y="305.0">예외 없음</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="477.0,604.0 477.0,652.0 429.5,652.0 429.5,700.0" data-evidence="409-409" />
<rect class="edge-label-bg" x="417.4" y="610.0" width="71.6" height="22" rx="3" />
<text class="edge-label" x="453.2" y="625.0">판정 근거 없음</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,444.0 220.0,492.0 468.0,492.0 468.0,540.0" data-evidence="403-404" />
<rect class="edge-label-bg" x="322.0" y="450.0" width="44.0" height="22" rx="3" />
<text class="edge-label" x="344.0" y="465.0">미도착</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="459.0,604.0 459.0,652.0 211.0,652.0 211.0,700.0" data-evidence="404-407" />
<rect class="edge-label-bg" x="302.6" y="610.0" width="64.9" height="22" rx="3" />
<text class="edge-label" x="335.0" y="625.0">하나라도 성립</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="307.0,284.0 307.0,332.0 211.0,332.0 211.0,380.0" data-evidence="400-401" />
<rect class="edge-label-bg" x="196.4" y="290.0" width="125.2" height="22" rx="3" />
<text class="edge-label" x="259.0" y="305.0">RuntimeException</text>
<g id="node-commit-requested">
<rect class="node-shape kind-process emphasis-normal role-source" data-evidence="397-397" x="241.0" y="60.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="316.0" y="90.0">COMMIT_REQUESTED</text>
</g>
<g id="node-provider-commit">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="398-400" x="241.0" y="220.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="316.0" y="250.0">provider commit</text>
</g>
<g id="node-ack-check">
<rect class="node-shape kind-process emphasis-normal role-service" data-evidence="401-401" x="131.0" y="380.0" width="160.0" height="64.0" rx="7" />
<text class="node-label" x="211.0" y="410.0">commitAcknowledged</text>
</g>
<g id="node-committed">
<rect class="node-shape kind-result emphasis-normal role-sink" data-evidence="388-388,396-400" x="351.0" y="380.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="426.0" y="410.0">Committed</text>
</g>
<g id="node-post-commit-failure">
<rect class="node-shape kind-result emphasis-normal role-sink" data-evidence="402-402" x="70.0" y="540.0" width="244.0" height="64.0" rx="7" />
<text class="node-label" x="192.0" y="570.0">CommittedWithPostCommitFailure</text>
</g>
<g id="node-rollback-check">
<rect class="node-shape kind-process emphasis-normal role-service" data-evidence="404-406" x="374.0" y="540.0" width="188.0" height="64.0" rx="7" />
<text class="node-label" x="468.0" y="570.0">rolledBack · replay 후보</text>
</g>
<g id="node-determinate-rollback">
<rect class="node-shape kind-result emphasis-normal role-sink" data-evidence="407-407" x="127.5" y="700.0" width="167.0" height="64.0" rx="7" />
<text class="node-label" x="211.0" y="730.0">DeterminateRollback</text>
</g>
<g id="node-indeterminate">
<rect class="node-shape kind-result emphasis-primary role-sink" data-evidence="391-391,409-409" x="354.5" y="700.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="429.5" y="730.0">Indeterminate</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.6 KiB

@@ -16,6 +16,7 @@ source:
- src/app-bootstrap/src/main/resources/META-INF/spring.factories
- final/document.md#a19
- final/document.md#a05
- final/document.md#10-3
---
# 마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다
@@ -13,6 +13,7 @@ source:
- src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java
- final/document.md#a05
- final/document.md#a18
- final/document.md#10-3
---
# 풀이 필요한지는 "JPA가 켜졌나"가 아니라 "커넥션이 필요한 capability가 있나"로 묻는다
@@ -13,6 +13,7 @@ source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java
- src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/streaming/GrpcResumeTokenCodec.java
- final/document.md#a05
- final/document.md#10-4
---
# 커서에 서명하는 이유는 기밀성이 아니라 무결성이다
@@ -16,6 +16,7 @@ source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantId.java
- final/document.md#a02
- final/document.md#a05
- final/document.md#10-4
---
# tenant id는 메트릭 태그가 되지 않는다
@@ -11,8 +11,7 @@ studio: ""
lastVerifiedOn: 2026-08-29
source:
- final/document.md#4-1
- final/document.md#a05 §136
- final/document.md#a05 §139
- final/document.md#a05
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
evidence:
- ../../../final/evidence/raw/050-jpa-commit-ambiguity-probe.txt
@@ -16,7 +16,7 @@ source:
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
assets:
- key: commit-evidence-phase-machine
file: ../../../final/assets/tech-log-studio/commit-evidence-phase-machine.svg
file: ../../../final/assets/commit-evidence-phase-machine/commit-evidence-phase-machine.svg
---
# 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문
@@ -16,6 +16,7 @@ source:
- final/document.md#a05
- final/document.md#a19
- final/document.md#a20
- final/document.md#10-5
---
# 지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다
@@ -15,6 +15,8 @@ source:
- src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/DefaultRetryEligibilityEngine.java
- docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md
- final/document.md#a11
- final/document.md#10-2
- final/document.md#a20-grpc-core-api
---
# 재시도 안전성은 증거에 기반해 판정한다
@@ -15,6 +15,7 @@ source:
- src/grpc/CLAUDE.md
- docs/compatibility/grpc-support-matrix.md
- final/document.md#a20
- final/document.md#2-3
---
# gRPC 플랫폼은 build-only로 두고 애플리케이션 도달 경로를 먼저 정한다
@@ -13,6 +13,7 @@ source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGate.java
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/multitenancy/RlsTenantSessionBinder.java
- final/document.md#a05
- final/document.md#10-3
---
# 클래스패스에 있는 것은 실행 동의가 아니다
@@ -13,6 +13,7 @@ source:
- src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V6__capability_schema_registry_adoption.sql
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaActivation.java
- final/document.md#a05
- final/document.md#4-1
---
# capability는 스키마 적용과 사용 승인을 분리한다
@@ -13,6 +13,7 @@ source:
- src/adapter/outbound/persistence-jpa/src/main/resources/db/migration
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java
- final/document.md#a05
- final/document.md#4-1
---
# Flyway가 스키마를 소유하고 런타임 롤은 DDL 권한을 갖지 않는다
@@ -14,6 +14,7 @@ source:
- src/gradle/jpa-evidence.gradle
- final/document.md#a16
- final/document.md#a19
- final/document.md#10-5
---
# 능력 등급은 코드가 아니라 실행된 증거에서 파생한다
@@ -9,7 +9,7 @@
},
"ssotSha256": "74b4986fd8621f6fd61791232777dc0e6c8ef999cbc2343df050bd5921ea03ac",
"sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
"generatedAt": "2026-09-07",
"generatedAt": "2026-09-08",
"candidateScope": {
"document": "final/document.md",
"sections": [
@@ -852,7 +852,8 @@
"decision-status": "`ADOPTED`",
"source": [
"`final/document.md#10-3`",
"`final/document.md#a05` §14.1"
"`final/document.md#a05` §14.1",
"`final/document.md#a18` §5"
],
"decision-evidence": [
"`.../app-bootstrap/.../persistencejpa/DataSourceRequirement.java`의 `reasons(environment)` 6개 조건과 javadoc"
@@ -3420,7 +3421,7 @@
"readiness": "READY",
"decision-status": "`ADOPTED`",
"source": [
"`final/document.md#4-1`, `#10-3`",
"`final/document.md#4-1`",
"`final/document.md#a05` §8.1, §12.1"
],
"decision-evidence": [
@@ -3826,7 +3827,7 @@
"source": [
"`final/document.md#10-2`",
"`final/document.md#a11` §51",
"`final/document.md#a20` §2.4"
"`final/document.md#a20-grpc-core-api` §1"
],
"decision-evidence": [
"`.../httpclient/.../TransportFailure.java`의 `notSent`/`sentNoResponse` 팩토리와 그 javadoc",
@@ -5207,7 +5208,7 @@
"id": "P1-flyway-owns-the-schema",
"kindCandidate": "DECISION",
"sourceRefs": [
"`final/document.md#4-1`, `#10-3`"
"`final/document.md#4-1`"
],
"summary": "Flyway가 스키마를 소유하고 런타임 롤은 DDL 권한을 갖지 않는다",
"disposition": "PROMOTE",
@@ -12,6 +12,7 @@ decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
- final/document.md#a05
- final/document.md#3-2
---
# inRootWrite는 suspend하지 않고 fail-fast한다
@@ -12,6 +12,7 @@ decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
- final/document.md#a05
- final/document.md#3-2
---
# 트랜잭션 템플릿은 모드별로 미리 만들어 둔다
@@ -13,6 +13,7 @@ source:
- src/gradle/jpa-evidence.gradle
- src/config/jpa/readiness-cards.yaml
- final/document.md#a05
- final/document.md#6-5
---
# 후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다
@@ -14,6 +14,7 @@ source:
- .github/workflows/messaging-certification.yml
- src/build-logic/src/main/groovy/ca.strict-test-lane.gradle
- final/document.md#a19
- final/document.md#6-4
---
# 인증 레인만 Docker 가드를 달지 않는다
@@ -14,6 +14,7 @@ source:
- src/adapter/outbound/persistence-jpa/build.gradle
- final/document.md#a20
- final/document.md#a05
- final/document.md#10-5
---
# 성능 측정은 릴리스 게이트에 넣지 않는다
@@ -136,7 +136,7 @@
"id": "k1-rev",
"from": "k1",
"to": "rev",
"label": "반대 방향으로 재연결",
"label": "재연결",
"kind": "request",
"evidence": [
{
@@ -32,11 +32,11 @@
"nodes": [
{
"id": "attacker",
"label": "밖에서 보낸 위조 헤더",
"label": "외부 위조 헤더",
"kind": "actor",
"role": "source",
"emphasis": "warning",
"description": "앱이 믿는 이름을 그대로 쓴다.",
"description": "밖에서 들어온 요청이 앱이 믿는 헤더 이름을 그대로 쓴다.",
"details": [
"X-Auth-Request-Roles"
],
@@ -126,7 +126,7 @@
"id": "lookup-session",
"from": "request",
"to": "app-session",
"label": "세션 id 로 조회",
"label": "조회",
"kind": "read",
"evidence": [
{
@@ -140,7 +140,7 @@
"id": "lookup-client",
"from": "request",
"to": "authorized-client",
"label": "principal 이름으로 조회",
"label": "조회",
"kind": "read",
"evidence": [
{
@@ -183,4 +183,4 @@
"metadata": {
"rationale": "이름이 비슷한 두 저장 대상을 조회 키로 갈랐다. B-1 과 B-2 의 결과가 이 분기에서 나온다."
}
}
}
@@ -32,11 +32,11 @@
"nodes": [
{
"id": "logout",
"label": "한 앱에서 로그아웃",
"label": "한 앱 로그아웃",
"kind": "actor",
"role": "source",
"emphasis": "primary",
"description": "Keycloak 세션이 끝난다.",
"description": "한 앱에서 로그아웃하면 Keycloak 세션이 끝난다.",
"details": [],
"evidence": [
{
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,341 @@
{
"version": "1.1",
"id": "cpu-io-passthrough-paths",
"title": "게스트가 하드웨어에 닿는 갈래는 셋이고 깊이가 다르다",
"question": "게스트의 CPU 실행·virtio I/O·직접 할당한 장치는 각각 어디까지 내려가고 호스트 유저공간을 지나는가",
"type": "architecture",
"direction": "TB",
"audience": [
"가상화 구조를 처음 읽는 사람",
"이 실험대가 무엇 위에서 도는지 알아야 하는 사람"
],
"summary": "CPU 는 QEMU 와 KVM 사이를 왕복하고, virtio 는 virtqueue 를 누가 소비하느냐에 따라 QEMU 나 커널 vhost 로 가며, 패스스루는 유저공간 드라이버가 IOMMU 보호 아래 장치에 직접 닿는다.",
"alt": "게스트·호스트 유저공간·호스트 커널·하드웨어 네 층을 가로지르는 세 갈래 경로도.",
"long_description": "위에서 아래로 읽는다. 맨 위 QEMU 에서 갈래 셋이 갈린다. 왼쪽 CPU 갈래는 KVM_RUN 으로 커널의 KVM 에 들어가고 KVM 이 VM entry 로 물리 CPU 에 올리며 게스트 코드가 VMX non-root 로 거기서 돈다. 물리 CPU 상자에 VM exit 이 함께 적혀 있고 나온 이유는 KVM 상자의 struct kvm_run 으로 유저공간에 돌아간다. 가운데 패스스루 갈래는 QEMU 가 VFIO 로 장치 fd 를 받고 vfio-pci 가 장치를 묶으며 그 장치의 DMA 가 IOMMU 를 지난다. 오른쪽 virtio 갈래는 virtqueue 가 게스트 드라이버에 닿고 같은 virtqueue 를 커널 vhost 와 나누는 길이 따로 있다. 앞의 둘은 이 실험대가 쓰고 패스스루는 쓰지 않는다.",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "0ae5674723f25dc85d5069529890f07be1a11b768b56901103fa3ea6ac3dcf55",
"anchor": {
"kind": "heading",
"value": "이 층 아래의 구조 — 조사한 것",
"line": 1091
}
},
"composition": {
"profile": "component-flow",
"diagram_only": true,
"reference_ids": [
"payment-event-flow"
],
"rationale": "세 갈래가 각각 방향이 있는 경로다. comparison 은 관계선을 지워도 뜻이 남는 배치라 이 그림에서는 표가 되고, 깊이가 다르다는 주장이 자리로 드러나지 않는다.",
"focus_node": "qemu"
},
"groups": [],
"nodes": [
{
"id": "qemu",
"label": "QEMU",
"kind": "service",
"role": "source",
"shape": "box",
"emphasis": "primary",
"details": [
"/dev/kvm",
"KVM_CREATE_VM · KVM_CREATE_VCPU"
],
"description": "KVM 핸들을 열고 VM 과 vCPU 를 만드는 유저공간 프로세스. vcpu ioctl 은 그 vcpu 를 만든 스레드에서 낸다.",
"evidence": [
{
"start_line": 1102,
"end_line": 1108
}
],
"assumption": false
},
{
"id": "vfio",
"label": "VFIO",
"kind": "service",
"role": "service",
"shape": "box",
"details": [
"vfio-pci",
"IOMMU 그룹",
"/dev/vfio/vfio"
],
"description": "IOMMU 보호 아래 장치 접근을 유저공간에 여는 프레임워크. 소유 단위가 IOMMU 그룹이다.",
"evidence": [
{
"start_line": 1131,
"end_line": 1146
}
],
"assumption": false
},
{
"id": "pci-device",
"label": "물리 PCI 장치",
"kind": "service",
"role": "service",
"shape": "box",
"details": [
"게스트 직접 할당"
],
"description": "호스트 드라이버에서 떼어 vfio-pci 에 묶은 장치.",
"evidence": [
{
"start_line": 1131,
"end_line": 1133
},
{
"start_line": 1144,
"end_line": 1146
}
],
"assumption": false
},
{
"id": "iommu",
"label": "IOMMU",
"kind": "service",
"role": "sink",
"shape": "box",
"details": [
"DMA · 인터럽트 리매핑"
],
"description": "장치가 아무 메모리나 건드리지 못하게 막는 리매핑 장치.",
"evidence": [
{
"start_line": 1144,
"end_line": 1146
}
],
"assumption": false
},
{
"id": "kvm",
"label": "KVM",
"kind": "service",
"role": "service",
"shape": "box",
"details": [
"struct kvm_run",
"KVM_GET_VCPU_MMAP_SIZE"
],
"description": "mmap 한 공유 메모리로 나온 이유를 유저공간에 알리는 커널 쪽.",
"evidence": [
{
"start_line": 1105,
"end_line": 1108
}
],
"assumption": false
},
{
"id": "physical-cpu",
"label": "물리 CPU",
"kind": "service",
"role": "service",
"shape": "box",
"details": [
"VMX root · VMX non-root",
"VM entry · VM exit",
"guest-state 영역"
],
"description": "VM entry 때 guest-state 영역에서 상태를 싣고 VM exit 때 그리로 저장하는 프로세서.",
"evidence": [
{
"start_line": 1111,
"end_line": 1113
}
],
"assumption": false
},
{
"id": "guest-code",
"label": "게스트 코드",
"kind": "service",
"role": "sink",
"shape": "box",
"details": [
"vCPU"
],
"description": "VMX non-root 로 물리 CPU 에서 도는 게스트 명령.",
"evidence": [
{
"start_line": 1111,
"end_line": 1113
}
],
"assumption": false
},
{
"id": "virtio-driver",
"label": "게스트 virtio 드라이버",
"kind": "service",
"role": "sink",
"shape": "box",
"details": [
"virtio-pci · virtio-mmio",
"virtqueue"
],
"description": "게스트가 보는 반가상화 장치의 드라이버. 주고받는 통로가 virtqueue 다.",
"evidence": [
{
"start_line": 1116,
"end_line": 1118
}
],
"assumption": false
},
{
"id": "vhost",
"label": "vhost",
"kind": "service",
"role": "sink",
"shape": "box",
"details": [
"리눅스 커널 구현",
"virtqueue 공유"
],
"description": "virtqueue 를 QEMU 밖과 나누는 커널 구현. vhost-user 규약이 이것을 제어하는 ioctl 인터페이스를 보완한다.",
"evidence": [
{
"start_line": 1125,
"end_line": 1129
}
],
"assumption": false
}
],
"edges": [
{
"id": "qemu-device-fd",
"from": "qemu",
"to": "vfio",
"label": "장치 fd",
"kind": "request",
"style": "solid",
"evidence": [
{
"start_line": 1138,
"end_line": 1141
}
],
"assumption": false
},
{
"id": "vfio-binds-device",
"from": "vfio",
"to": "pci-device",
"label": "vfio-pci 결합",
"kind": "control",
"style": "solid",
"evidence": [
{
"start_line": 1144,
"end_line": 1146
}
],
"assumption": false
},
{
"id": "device-dma",
"from": "pci-device",
"to": "iommu",
"label": "DMA",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 1144,
"end_line": 1146
}
],
"assumption": false
},
{
"id": "qemu-kvm-run",
"from": "qemu",
"to": "kvm",
"label": "KVM_RUN",
"kind": "request",
"style": "solid",
"evidence": [
{
"start_line": 1104,
"end_line": 1106
}
],
"assumption": false
},
{
"id": "kvm-vm-entry",
"from": "kvm",
"to": "physical-cpu",
"label": "VM entry",
"kind": "request",
"style": "solid",
"evidence": [
{
"start_line": 1111,
"end_line": 1112
}
],
"assumption": false
},
{
"id": "cpu-runs-guest",
"from": "physical-cpu",
"to": "guest-code",
"label": "VMX non-root",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 1111,
"end_line": 1112
}
],
"assumption": false
},
{
"id": "qemu-virtqueue",
"from": "qemu",
"to": "virtio-driver",
"label": "virtqueue",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 1116,
"end_line": 1118
},
{
"start_line": 1128,
"end_line": 1128
}
],
"assumption": false
},
{
"id": "qemu-vhost-ioctl",
"from": "qemu",
"to": "vhost",
"label": "제어 ioctl",
"kind": "control",
"style": "dashed",
"evidence": [
{
"start_line": 1125,
"end_line": 1129
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "세 갈래의 깊이 차이는 어느 층까지 선이 내려가느냐로만 보이므로 배치와 관계선이 함께 있어야 한다. 셋 중 패스스루는 이 실험대가 쓰지 않으며 본문이 그 사실을 적는다."
}
}
@@ -32,11 +32,11 @@
"nodes": [
{
"id": "rollback",
"label": "옛 버전으로 되돌리기",
"label": "옛 버전 롤백",
"kind": "process",
"role": "source",
"emphasis": "warning",
"description": "이미지 태그를 되돌린다.",
"description": "이미지 태그를 옛 버전으로 되돌린다.",
"details": [],
"evidence": [
{
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,306 @@
{
"version": "1.1",
"id": "guest-as-host-process",
"title": "게스트 두 대는 호스트에서 qemu 프로세스 두 개다",
"question": "호스트에서 게스트는 무엇으로 존재하고, 게스트가 보는 장치는 누가 만들어 주는가",
"type": "deployment",
"direction": "TB",
"audience": [
"실험대를 운영하는 사람",
"호스트와 게스트의 측정값을 대조하는 사람"
],
"summary": "libvirtd 가 게스트마다 qemu-system-x86_64 를 띄우고, 게스트가 보는 디스크와 네트워크는 그 프로세스가 virtio 로 내주며, 두 프로세스는 machine.slice 아래에서 메모리 상한을 받는다.",
"alt": "호스트 안에 libvirtd·machine.slice·virbr0 와 qemu 프로세스 두 개가 있고, 각 프로세스가 게스트 하나씩을 담는 배치도.",
"long_description": "위에서 아래로 읽는다. 호스트 test-server 경계 안에 libvirtd 가 있고 그 아래에 qemu-system-x86_64 프로세스가 게스트 수만큼 있다. 경계 밖 아래쪽에 게스트 kc-lab-1 과 kc-lab-2 가 각각 따로 있고, 게스트가 보는 디스크와 인터페이스는 자기를 담은 프로세스가 virtio 로 내준다. 프로세스에 적힌 RSS 와 게스트에 적힌 available 은 같은 메모리를 다른 껍질에서 읽은 값이다. machine.slice 와 virbr0 는 이 그림에 넣지 않았고 본문이 맡는다.",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "0ae5674723f25dc85d5069529890f07be1a11b768b56901103fa3ea6ac3dcf55",
"anchor": {
"kind": "heading",
"value": "0층. 가상화 — 「바닥」 아래에 있는 것",
"line": 935
}
},
"composition": {
"profile": "component-flow",
"diagram_only": true,
"reference_ids": [
"payment-event-flow"
],
"rationale": "게스트가 호스트에서 어떻게 존재하는지는 libvirtd 에서 qemu 프로세스로, 다시 게스트 커널로 내려가는 한 방향 경로다. sequence 는 이 절에 시각 순서가 없어서 맞지 않고, orchestrator-workers 는 게스트 둘을 교체 가능한 워커로 그리는데 둘은 할당량도 역할도 다르다.",
"focus_node": "qemu-kc-lab-1"
},
"groups": [
{
"id": "host-zone",
"label": "호스트 test-server",
"kind": "system",
"role": "zone",
"description": "libvirtd 와 게스트 프로세스가 함께 도는 호스트 경계.",
"evidence": [
{
"start_line": 946,
"end_line": 948
},
{
"start_line": 956,
"end_line": 956
}
],
"assumption": false
},
{
"id": "guest-1-zone",
"label": "게스트 kc-lab-1",
"kind": "system",
"role": "zone",
"description": "qemu 프로세스 하나가 담는 Debian 게스트.",
"evidence": [
{
"start_line": 987,
"end_line": 990
},
{
"start_line": 1039,
"end_line": 1039
}
],
"assumption": false
},
{
"id": "guest-2-zone",
"label": "게스트 kc-lab-2",
"kind": "system",
"role": "zone",
"description": "나머지 qemu 프로세스가 담는 Debian 게스트.",
"evidence": [
{
"start_line": 1033,
"end_line": 1033
},
{
"start_line": 1040,
"end_line": 1040
}
],
"assumption": false
}
],
"nodes": [
{
"id": "libvirtd",
"label": "libvirtd",
"kind": "service",
"role": "source",
"group": "host-zone",
"shape": "box",
"details": [
"qemu:///system"
],
"description": "게스트마다 qemu-system-x86_64 를 하나씩 띄우는 호스트 데몬. htop 트리 뷰에서 그 아래에 게스트 프로세스가 달린다.",
"evidence": [
{
"start_line": 946,
"end_line": 948
},
{
"start_line": 956,
"end_line": 956
}
],
"assumption": false
},
{
"id": "qemu-kc-lab-1",
"label": "qemu-system-x86_64",
"kind": "service",
"role": "service",
"group": "host-zone",
"shape": "box",
"emphasis": "primary",
"details": [
"kc-lab-1",
"vCPU 2",
"할당 3584MB",
"RSS 3765MB"
],
"description": "kc-lab-1 게스트 전체가 들어 있는 호스트 프로세스. vCPU 는 이 프로세스의 스레드이고 RSS 는 게스트가 터치한 페이지만큼이다.",
"evidence": [
{
"start_line": 946,
"end_line": 948
},
{
"start_line": 987,
"end_line": 987
},
{
"start_line": 1032,
"end_line": 1032
}
],
"assumption": false
},
{
"id": "qemu-kc-lab-2",
"label": "qemu-system-x86_64",
"kind": "service",
"role": "service",
"group": "host-zone",
"shape": "box",
"details": [
"kc-lab-2",
"할당 2560MB",
"RSS 2633MB"
],
"description": "kc-lab-2 게스트 전체가 들어 있는 호스트 프로세스. A-4 의 virsh destroy 가 끊는 것이 이 프로세스다.",
"evidence": [
{
"start_line": 946,
"end_line": 948
},
{
"start_line": 967,
"end_line": 976
},
{
"start_line": 1033,
"end_line": 1033
}
],
"assumption": false
},
{
"id": "guest-kernel-1",
"label": "Debian 게스트 커널",
"kind": "service",
"role": "sink",
"group": "guest-1-zone",
"shape": "box",
"details": [
"enp1s0 · 192.168.122.11",
"총 3423MB",
"available 1959MB"
],
"description": "virtio 장치만 보는 게스트. free 가 읽는 값이 여기 있다.",
"evidence": [
{
"start_line": 994,
"end_line": 998
},
{
"start_line": 1039,
"end_line": 1039
}
],
"assumption": false
},
{
"id": "guest-kernel-2",
"label": "Debian 게스트 커널",
"kind": "service",
"role": "sink",
"group": "guest-2-zone",
"shape": "box",
"details": [
"enp1s0 · 192.168.122.12",
"총 2480MB",
"available 1899MB"
],
"description": "같은 방식으로 붙은 두 번째 게스트.",
"evidence": [
{
"start_line": 994,
"end_line": 998
},
{
"start_line": 1040,
"end_line": 1040
}
],
"assumption": false
}
],
"edges": [
{
"id": "libvirtd-spawns-1",
"from": "libvirtd",
"to": "qemu-kc-lab-1",
"label": "프로세스 생성",
"kind": "control",
"style": "solid",
"evidence": [
{
"start_line": 946,
"end_line": 948
},
{
"start_line": 956,
"end_line": 956
}
],
"assumption": false
},
{
"id": "libvirtd-spawns-2",
"from": "libvirtd",
"to": "qemu-kc-lab-2",
"label": "프로세스 생성",
"kind": "control",
"style": "solid",
"evidence": [
{
"start_line": 946,
"end_line": 948
},
{
"start_line": 956,
"end_line": 956
}
],
"assumption": false
},
{
"id": "qemu1-virtio",
"from": "qemu-kc-lab-1",
"to": "guest-kernel-1",
"label": "virtio 디스크 · virtio-net",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 987,
"end_line": 990
},
{
"start_line": 994,
"end_line": 998
}
],
"assumption": false
},
{
"id": "qemu2-virtio",
"from": "qemu-kc-lab-2",
"to": "guest-kernel-2",
"label": "virtio 디스크 · virtio-net",
"kind": "data",
"style": "solid",
"evidence": [
{
"start_line": 987,
"end_line": 990
},
{
"start_line": 994,
"end_line": 998
}
],
"assumption": false
}
],
"legend": [],
"metadata": {
"rationale": "배치도로 고른 이유는 이 절이 주장하는 것이 순서가 아니라 담김이기 때문이다 — 게스트 하나가 호스트 프로세스 하나 안에 있고, 게스트가 보는 장치는 그 프로세스가 내준다. 메모리를 읽는 곳 셋과 machine.slice·virbr0 는 본문 표와 문단이 맡는다."
}
}
@@ -90,9 +90,9 @@
"kind": "process",
"role": "target",
"emphasis": "warning",
"description": "클레임 변경이 반영되지 않는 이유.",
"description": "IdP 에서 클레임을 바꿔도 재인증까지 옛 값이 간다.",
"details": [
"재인증까지 옛 값"
"옛 클레임 값"
],
"evidence": [
{
@@ -56,7 +56,9 @@
"emphasis": "primary",
"description": "OFFLINE_USER_SESSION 에 세션 행을 보관한다. 두 노드가 같은 행을 본다.",
"details": [
"offline_flag='0'"
"OFFLINE_USER_SESSION",
"offline_flag='0'",
"두 노드 공용"
],
"evidence": [
{
@@ -98,7 +100,10 @@
}
],
"assumption": false,
"details": []
"details": [
"노드 둘 등록",
"세션 복제 아님"
]
}
],
"edges": [
@@ -163,4 +168,4 @@
"metadata": {
"rationale": "클러스터 형성과 세션 복제를 한 그림에서 분리했다. 발견(JGROUPS_PING)과 공유(OFFLINE_USER_SESSION)가 같은 데이터베이스 안의 다른 테이블이라는 점이 이 절의 오해가 생기는 자리다."
}
}
}
@@ -19,4 +19,4 @@ JGroups 는 한 방향이 막혀도 열린 방향으로 재연결한다. 그래
- **막은 방향 → keycloak-1:** 차단. Evidence: L226L232.
- **keycloak-0 → 막은 방향:** JGroups 메시지. Evidence: L226L232.
- **keycloak-1 → 열린 반대 방향:** 반대 방향으로 재연결. Evidence: L226L232.
- **keycloak-1 → 열린 반대 방향:** 재연결. Evidence: L226L232.
@@ -15,4 +15,4 @@ n3: "열린 반대 방향" {
}
n0 -> n1: "JGroups 메시지"
n1 -> n2: "차단"
n2 -> n3: "반대 방향으로 재연결"
n2 -> n3: "재연결"
@@ -8,5 +8,5 @@ digraph techviz {
n3 [label="열린 반대 방향", shape=box, style="rounded,filled"];
n0 -> n1 [label="JGroups 메시지", style=solid];
n1 -> n2 [label="차단", style=solid];
n2 -> n3 [label="반대 방향으로 재연결", style=solid];
n2 -> n3 [label="재연결", style=solid];
}
@@ -27,7 +27,7 @@
<mxPoint x="169.0" y="179.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_k1-rev" value="반대 방향으로 재연결" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_k1" target="n_rev">
<mxCell id="e_k1-rev" value="재연결" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_k1" target="n_rev">
<mxGeometry relative="1" as="geometry">
<mxPoint x="169.0" y="513.0" as="offset"/>
</mxGeometry>
@@ -292,11 +292,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "반대 방향으로 재연결",
"text": "재연결",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "반대 방향으로 재연결",
"originalText": "재연결",
"autoResize": true,
"lineHeight": 1.25
},
@@ -2,7 +2,7 @@
"harness_version": "0.2.0",
"spec_id": "a5-partition-asymmetry",
"spec_version": "1.1",
"spec_sha256": "9b10dfa9cfb1f24e5b105da13bc8fde85130cc1cca2ddad54cd57c32bc2dfcf5",
"spec_sha256": "d971af080c366e171592b1cfe7e62120eb0f4e0b5193e241e018e9440a44536b",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
@@ -14,10 +14,10 @@
},
"outputs": [
"a5-partition-asymmetry.svg",
"a5-partition-asymmetry.drawio",
"a5-partition-asymmetry.mmd",
"a5-partition-asymmetry.d2",
"a5-partition-asymmetry.dot",
"a5-partition-asymmetry.drawio",
"a5-partition-asymmetry.excalidraw",
"a5-partition-asymmetry.alt.md"
],
@@ -7,4 +7,4 @@ flowchart TB
n3["열린 반대 방향"]
n0 -->|"JGroups 메시지"| n1
n1 -->|"차단"| n2
n2 -->|"반대 방향으로 재연결"| n3
n2 -->|"재연결"| n3
@@ -57,8 +57,8 @@
<rect class="edge-label-bg" x="123.2" y="165.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="169.0" y="180.0">JGroups 메시지</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="145.0,465.0 145.0,513.0 145.0,513.0 145.0,561.0" data-evidence="226-232" />
<rect class="edge-label-bg" x="123.2" y="499.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="169.0" y="514.0">반대 방향으로 재연결</text>
<rect class="edge-label-bg" x="147.0" y="499.0" width="44.0" height="22" rx="3" />
<text class="edge-label" x="169.0" y="514.0">재연결</text>
<g id="node-k0">
<rect class="node-shape kind-service emphasis-primary role-source" data-evidence="226-236" x="70.0" y="60.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="145.0" y="87.0">keycloak-0</text>

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -10,13 +10,13 @@ nginx 는 자기가 proxy_set_header 로 설정한 헤더만 덮어쓴다. 설
## Elements and evidence
- **밖에서 보낸 위조 헤더** (actor): 앱이 믿는 이름을 그대로 쓴다. Evidence: L393L400.
- **외부 위조 헤더** (actor): 밖에서 들어온 요청이 앱이 믿는 헤더 이름을 그대로 쓴다. Evidence: L393L400.
- **nginx** (gateway): 설정하지 않은 이름은 덮어쓰지 않는다. Evidence: L393L400.
- **oauth2-proxy** (gateway): 인증 결과를 헤더로 넣는다. Evidence: L393L400.
- **앱** (service): 헤더를 믿고 인가한다. Evidence: L401L408.
## Relationships
- **밖에서 보낸 위조 헤더 → nginx:** 위조 헤더. Evidence: L393L400.
- **외부 위조 헤더 → nginx:** 위조 헤더. Evidence: L393L400.
- **nginx → oauth2-proxy:** 미삭제 시 통과. Evidence: L393L400.
- **oauth2-proxy → 앱:** 인가 헤더. Evidence: L393L408.
@@ -1,7 +1,7 @@
# 지우지 않으면 통과한다
# Question: Edge 가 넣어주는 인가 헤더를 앱이 믿어도 되는가
direction: down
n0: "밖에서 보낸 위조 헤더" {
n0: "외부 위조 헤더" {
shape: person
}
n1: "nginx" {
@@ -2,7 +2,7 @@ digraph techviz {
graph [rankdir=TB, 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,dashed,filled"];
n0 [label="외부 위조 헤더", shape=box, style="rounded,dashed,filled"];
n1 [label="nginx", shape=diamond, style="rounded,filled"];
n2 [label="oauth2-proxy", shape=diamond, style="rounded,filled"];
n3 [label="앱", shape=box, style="rounded,filled"];
@@ -5,7 +5,7 @@
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_attacker" value="밖에서 보낸 위조 헤더&lt;br/&gt;X-Auth-Request-Roles" tooltip="앱이 믿는 이름을 그대로 쓴다. | Evidence: L393-L400" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxCell id="n_attacker" value="외부 위조 헤더&lt;br/&gt;X-Auth-Request-Roles" tooltip="밖에서 들어온 요청이 앱이 믿는 헤더 이름을 그대로 쓴다. | Evidence: L393-L400" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="82.5" y="60.0" width="170.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_nginx" value="nginx&lt;br/&gt;proxy_set_header ... &quot;&quot;" tooltip="설정하지 않은 이름은 덮어쓰지 않는다. | Evidence: L393-L400" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;rhombus;perimeter=rhombusPerimeter;fillColor=#fff7e8;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
@@ -361,11 +361,11 @@
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "밖에서 보낸 위조 헤더\nX-Auth-Request-Roles",
"text": "외부 위조 헤더\nX-Auth-Request-Roles",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "밖에서 보낸 위조 헤더\nX-Auth-Request-Roles",
"originalText": "외부 위조 헤더\nX-Auth-Request-Roles",
"autoResize": true,
"lineHeight": 1.25
},
@@ -2,7 +2,7 @@
"harness_version": "0.2.0",
"spec_id": "b4-header-trust-boundary",
"spec_version": "1.1",
"spec_sha256": "56747d5f7826ad63f202d8dc4d6a4f1323f12eeaedb4bf989b67d0bfab5f09cd",
"spec_sha256": "ad0e26787a9e508ddce08e196ceeaacf2dfe1ef2caa047537f4355b5e34739a1",
"source_context": {
"document": "docs/keycloak-session-store/final/document.md",
"document_sha256": "1d44cba1905544d92f1d26ae36a8deb64a3db3914d6b488fd30d6ae7f8cfbabe",
@@ -14,10 +14,10 @@
},
"outputs": [
"b4-header-trust-boundary.svg",
"b4-header-trust-boundary.drawio",
"b4-header-trust-boundary.mmd",
"b4-header-trust-boundary.d2",
"b4-header-trust-boundary.dot",
"b4-header-trust-boundary.drawio",
"b4-header-trust-boundary.excalidraw",
"b4-header-trust-boundary.alt.md"
],
@@ -1,7 +1,7 @@
%% 지우지 않으면 통과한다
%% question: Edge 가 넣어주는 인가 헤더를 앱이 믿어도 되는가
flowchart TB
n0(["밖에서 보낸 위조 헤더"])
n0(["외부 위조 헤더"])
n1{"nginx"}
n2{"oauth2-proxy"}
n3["앱"]

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