docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,62 @@
---
kind: REFERENCE
slug: cas-tuple-in-the-where-clause
title: CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:cas-tuple-in-the-where-clause
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
## 목적
행을 읽고 나서 갱신하는 형태를 없애, 리스가 만료된 작업자가 인계받은 작업자의 상태를 덮는 것을 막는다.
## 규칙
1. 소유자 튜플 전체를 where 절에 반복한다
스코프만으로 갱신하지 않는다. 소유자와 토큰과 시도와 상태 리비전을 전부 조건에 넣는다.
2. 갱신 건수가 답이다
한 건이면 이 소유자가 이 리비전에서 여전히 소유자였다는 뜻이고, 0 이면 다른 무언가가 레코드를 움직였다는 뜻이다.
3. 0 건은 예외가 아니라 정상 경로다
경합을 예외 처리로 다루면 그 경로가 테스트되지 않는다. 건수를 값으로 받아 호출자가 해석한다.
4. 전이마다 상태 리비전을 올린다
소유권만으로는 부족하다. 자기가 본 시점까지 맞아야 한다.
5. 같은 가드를 쓰는 문장을 한 자리에 모은다
흩어져 있으면 그중 하나가 조건을 짧게 쓰는 것을 막을 수 없다.
## 적용 조건
여러 작업자가 같은 행을 놓고 경합하는 모든 상태 기계
리스나 청구로 소유권을 표현하는 테이블
## 예외
단일 작업자만 접근하는 것이 구조적으로 보장되는 테이블은 대상이 아니다. 그 보장이 무엇인지 적혀 있어야 한다.
## 예시
멱등성 전이 문장들이 스코프와 토큰과 시도와 청구 연산과 상태 리비전을 전부 조건에 반복한다.
outbox 폴링 전달 어댑터의 완료 문장 셋이 같은 형태다.
네이티브 청구 문장이 JPA 버전 컬럼을 함께 올린다. 그러지 않으면 청구 이전에 로드된 엔티티의 플러시가 청구를 덮는다.
## 관계
- **CAS 튜플과 update count가 답이 되는 구조**
이 규칙이 나온 개념이다.
- **lease가 만료 시각만 기록하고 소유자를 기록하지 않아 terminal state가 되돌려졌다**
이 규칙이 없을 때의 결과다.
- **native claim이 Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다**
튜플을 반복해도 다른 잠금 장치와 어긋날 수 있다는 사례다.
@@ -0,0 +1,58 @@
---
kind: REFERENCE
slug: digest-must-be-length-framed-and-versioned
title: digest는 길이 프레이밍하고 버전을 붙인다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:digest-must-be-length-framed-and-versioned
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# digest는 길이 프레이밍하고 버전을 붙인다
## 목적
다이제스트가 서로 다른 입력에 대해 같은 값을 내거나, 구성이 바뀐 뒤 옛 값과 비교되는 것을 막는다.
## 규칙
1. 무엇을 덮는지가 정책이다
다이제스트가 빠뜨린 입력은 두 개의 다른 대상이 같은 값을 낼 수 있는 입력이다. 그 목록은 구현 세부가 아니라 정책이므로 자기 타입을 갖는다.
2. 결과를 덮는다
누가 언제 했는지만 덮으면 무엇을 했는지가 다른 두 전이가 같아진다.
3. 길이 프레이밍한다
구성 요소가 가변 길이 텍스트이고 그중 하나라도 이 플랫폼이 제약할 수 없는 값이면, 구분자로 이었을 때 서로 다른 목록이 한 문자열로 렌더링될 수 있다.
4. 버전을 붙이고 구성이 바뀌면 올린다
저장된 다이제스트가 구성 경계를 넘어 비교되지 않게 한다.
5. 비교 실패를 조용히 처리하지 않는다
버전이 다르면 같다고도 다르다고도 결론 내리지 않는다.
## 적용 조건
재생 판정이나 중복 판정에 쓰이는 모든 다이제스트
멱등성 키와 요청 지문
## 예외
캐시 키처럼 충돌이 성능 문제일 뿐 정확성 문제가 아닌 경우는 이 규칙이 과하다.
## 예시
전이 다이제스트가 전이 종류와 연산과 소유자와 시도와 리비전만 덮어, 재시도 가능한 실패와 포기한 실패가 같은 값을 냈다. 서로 다른 응답을 담은 두 완료도 마찬가지였다.
소유자 토큰은 이 플랫폼이 형식을 제약하는 값이 아니므로 길이 프레이밍이 필요하다.
## 관계
- **transition digest가 누가와 언제만 덮고 무엇을 덮지 않아 다른 전이를 같다고 보고했다**
이 규칙을 만든 사례다.
- **서명된 커서의 구조와 검증 순서**
같은 계열의 형식 결정을 다룬다.
@@ -0,0 +1,58 @@
---
kind: REFERENCE
slug: expired-claim-and-expired-execution-differ
title: 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:expired-claim-and-expired-execution-differ
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
## 목적
리스 만료를 한 가지로 처리해, 실행을 시작했던 소유자의 작업을 두 번 수행하는 것을 막는다.
## 규칙
1. 만료된 청구는 인계한다
자리를 잡았지만 아직 아무것도 실행하지 않은 소유자를 밀어내도 외부 효과가 없다.
2. 만료된 실행은 조정으로 넘긴다
그 소유자가 무엇을 어디까지 했는지 알 수 없다. 다시 실행하면 그 작업이 두 번 일어날 수 있다.
3. 조정 결과는 별도 값이어야 한다
성공이나 실패로 접으면 그 구별이 사라진다. 결과 타입에 세 번째 변형이 필요하다.
4. 해제도 같은 구별을 따른다
실행이 시작된 청구는 해제할 수 없다. 해제는 아직 실행하지 않은 청구에만 허용한다.
5. 재시도 가능 실패는 인계 대상이다
그 상태는 이미 결과가 확정된 것이므로 새 소유자가 처음부터 시작해도 된다.
## 적용 조건
청구와 실행을 별도 상태로 갖는 모든 상태 기계
멱등성 저장소와 인박스와 아웃박스
## 예외
실행이 외부 효과를 남기지 않는 것이 구조적으로 보장되면 두 상태를 같이 다뤄도 된다. 그 보장을 적어 둔다.
## 예시
청구 결정 트리가 만료된 청구는 재설정하고 만료된 실행은 복구 필요로 답한다. 복구 필요 결과는 시도 번호를 함께 들고 간다.
해제 경로는 이미 실행이 시작된 경우 실행 시작됨으로 답하고 해제하지 않는다.
## 관계
- **만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다**
이 규칙을 만든 사례다.
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
세 번째 규칙이 기대는 상위 규칙이다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: read-the-clock-after-the-lock
title: 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:read-the-clock-after-the-lock
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
## 목적
애플리케이션 시계로 리스 만료를 판단하거나 잠그기 전의 시각으로 판단해, 서로 다른 노드가 같은 행에 대해 다른 답을 내는 것을 막는다.
## 규칙
1. 시각은 데이터베이스에서 읽는다
여러 노드의 시계는 서로 다르다. 리스 만료 판정의 기준 시각이 노드마다 다르면 두 노드가 동시에 소유자가 될 수 있다.
2. 행을 잠근 다음에 읽는다
잠그기 전의 시각으로 판단하면 잠금을 기다리는 동안 리스가 만료될 수 있다.
3. 판정과 갱신을 한 문장 안에 둔다
시각 비교를 where 절에 넣으면 판정과 갱신 사이에 시간이 흐르지 않는다.
4. 만료 시각을 계산할 때도 같은 시계를 쓴다
읽은 시각과 쓰는 시각의 출처가 다르면 리스 길이가 의도와 달라진다.
## 적용 조건
리스와 청구와 예약처럼 시각이 소유권을 정하는 모든 상태 기계
여러 인스턴스가 같은 테이블을 폴링하는 구조
## 예외
단일 인스턴스만 접근하고 그 보장이 구조적인 경우는 애플리케이션 시계로 충분하다. 그 보장을 적어 둔다.
## 예시
청구 결정 트리가 데이터베이스에서 읽은 현재 시각으로 리스 만료를 판정한다.
리스를 발행 타임아웃보다 길게 두는 것은 확률을 낮출 뿐이고, GC 정지나 스케줄러 지연을 데이터 제약으로 바꾸지 않는다.
## 관계
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 문장 안에서 함께 쓰이는 규칙이다.
- **fenced lease — 만료 시각만으로는 부족한 이유**
시각만으로 부족한 이유를 다룬 개념이다.