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
@@ -28,6 +28,18 @@ const RULES = [
{ id: 'wrap-up', sev: ERR, re: /(이\s*(관찰|결과|측정)은[^.\n]{0,40}(보여준|드러낸|말해\s*준)|이는[^.\n]{0,30}보여준다)/g,
msg: '방금 보여 준 것을 다시 선언합니다. 지우세요.' },
// 마무리가 되풀이로 끝나는 것을 본다. 17386 은 「지금까지 ~ 소개했습니다」로 열고 회고로
// 닫으므로 그 자체는 defect 가 아니다. 뒤에 남은 일이 오는지는 사람이 본다 — 그래서 경고다.
{ id: 'closing-recap', sev: WARN,
re: /(지금까지|여기까지)[^.\n]{0,80}(살펴봤|살펴보았|알아봤|알아보았|소개했|정리했|다뤘|다루었)/g,
msg: '앞 내용을 다시 늘어놓았습니다. 이 뒤에 남은 일이나 감수한 것이 오면 두고, 이것으로 끝나면 지우세요.' },
// 검사기의 종결어미 수를 채우려고 끼워 넣는 물음. 「왜 ~할까?」·「어떤 ~할까?」처럼 그 절이
// 실제로 답하는 물음은 참고 글도 쓴다(22396). 잡는 것은 답이 예·아니오뿐인 수사 의문이다.
{ id: 'rhetorical-question', sev: ERR,
re: /[^\n?]{4,60}([가-힣]\s*걸까\?|지\s*않을까\?|[가-힣]\s*게\s*아닐까\?|[가-힣]\s*것일까\?)/g,
msg: '답이 예·아니오뿐인 물음을 끼워 넣었습니다. 종결어미 수를 채우려고 넣은 문장이면 지우고, 답할 물음이면 무엇을 묻는지 적으세요.' },
{ id: 'nominalized', sev: ERR, re: /(채워진\s*목록\s*수|준비한\s*SQL\s*문장|획득한[^.\n]{0,10}객체\s*수|[가-힣]+에\s*대한\s*(측정|비교|확인|분석))/g,
msg: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' },
@@ -104,18 +116,29 @@ function positiveChecks(text, lines, docMode, rulesMode) {
}
// 3. 종결어미가 한 가지뿐인가
//
// 한다체 문서는 `한다`·`였다`·`아니다`·`없다`·명사 종결이 전부 다른 어미다. 이것을 한 덩어리로
// 세면 잘 쓴 한다체 글이 단조롭다고 잡히고, 고치려는 사람은 물음표 문장을 끼워 넣게 된다.
// 실제로 그렇게 됐다. 그래서 어미를 잘게 센다.
const kinds = new Set();
for (const st of sentences) {
if (/(습니다|았습니다|었습니다)[.!]?$/.test(st)) kinds.add('습니다');
if (/입니다[.!]?$/.test(st)) kinds.add('입니다');
if (/(했다|이다|였다|된다|한다)[.!]?$/.test(st)) kinds.add('한다');
if (/(겠습니다|하겠습니다|보겠습니다)[.!]?$/.test(st)) kinds.add('겠습니다');
if (/(한다|된다|만든다|넘긴다|받는다)[.!]?$/.test(st)) kinds.add('한다');
if (/(했다|였다|됐다|되었다|았다|었다)[.!]?$/.test(st)) kinds.add('했다');
if (/(아니다|없다|같다|다르다|이다)[.!]?$/.test(st)) kinds.add('이다');
if (/(못한다|않는다|않았다|못했다)[.!]?$/.test(st)) kinds.add('부정');
if (/(뿐이다|때문이다|까지다|것이다)[.!]?$/.test(st)) kinds.add('설명');
if (/[가-힣A-Za-z0-9`)\]]$/.test(st.replace(/[.!]$/, ''))) kinds.add('명사');
if (/까요\??$/.test(st) || /\?$/.test(st)) kinds.add('물음');
if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(st)) kinds.add('청유');
}
if (!rulesMode && sentences.length >= 8 && kinds.size <= 1) {
out.push({ id: 'monotone-endings', sev: ERR,
msg: `문장 ${sentences.length}개가 모두 같은 종결어미입니다. 예고(~살펴보겠습니다)·물음(~할까요?)·권유(~봅시다)를 섞습니다.` });
msg: `문장 ${sentences.length}개가 모두 같은 종결어미입니다. 문장이 하는 일이 다르면 어미도 달라집니다 — `
+ `확인한 것은 ~였다, 지금 그러한 것은 ~한다, 아닌 것은 ~아니다, 이유는 ~때문이다. `
+ `물음이나 권유를 끼워 넣어 수를 채우지 마세요.` });
}
// 4. 독자를 데리고 다니는 문장
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// 글이 얼마나 채워져 있는지 잰다. 문체가 아니라 밀도를 본다.
//
// node density.mjs 초안.md [...] 기준값과 나란히 보여 준다
// node density.mjs --baseline dir/*.md 기준값을 다시 잰다
//
// 기준값은 ../examples 의 여섯 편에서 잰 것이다. 맞히려고 문단을 넣지 않는다 —
// 낮게 나오면 설명이 빠진 자리를 찾으라는 뜻이지 분량을 늘리라는 뜻이 아니다.
import { readFileSync } from "node:fs";
import { basename } from "node:path";
// examples 의 여섯 편에서 잰 값. 23625 는 본문 일부만 받아서 낱말 수 기준에서 뺐다.
const BASE = {
words: [1277, 2871], sentWords: [14.2, 18.3], code: [1, 13],
sections: [4, 9], tableLines: [0, 15],
};
// 수치 개수는 장르가 가른다. 잰 것을 쓰는 글과 구조를 쓰는 글의 기준이 다르다.
// 측정 글 13569(13) · 23625(18) · 20161(24) · 22396(36)
// 구조 글 17386(2) · 7835(5)
const NUMBERS = { 측정: [13, 36], 구조: [2, 12] };
const NUM = /\d[\d,.]*\s*(?:ms|초|분|시간|일|년|개월|주|배|%|건|개|줄|번|회|명|자|KB|MB|GB|TB|Gbps|TPS|QPS|만|천|억)/g;
function measure(text) {
// frontmatter 와 주석은 글이 아니다
let t = text.replace(/^---\n.*?\n---\n/s, "").replace(/<!--.*?-->/gs, "");
const marked = t.match(/<!-- body:start -->([\s\S]*?)<!-- body:end -->/);
if (marked) t = marked[1];
const code = (t.match(/^```/gm) || []).length / 2;
const tableLines = (t.match(/^\s*\|/gm) || []).length;
const sections = (t.match(/^## /gm) || []).length;
const prose = t.replace(/```[\s\S]*?```/g, "").replace(/^\s*\|.*$/gm, "");
const words = prose.split(/\s+/).filter(Boolean).length;
const sentences = prose.split(/(?<=[.!?다])\s+/).filter((s) => s.trim().length > 10);
const numbers = (t.match(NUM) || []).length;
const proseParas = prose.split(/\n\s*\n/).filter((p) => p.trim().length > 40).length;
return { words, sentWords: +(words / Math.max(sentences.length, 1)).toFixed(1),
code, numbers, sections, tableLines, proseParas };
}
const args = process.argv.slice(2);
// 잰 것을 쓴 글인가 구조를 쓴 글인가. Case 는 측정, Concept 은 `--구조` 로 부른다.
const genre = args.includes("--구조") ? "구조" : "측정";
const files = args.filter((a) => !a.startsWith("--"));
if (args.includes("--baseline")) {
const all = files.map((f) => measure(readFileSync(f, "utf8")));
const range = (k) => [Math.min(...all.map((m) => m[k])), Math.max(...all.map((m) => m[k]))];
for (const k of [...Object.keys(BASE), "numbers"]) console.log(` ${k}: [${range(k)}]`);
process.exit(0);
}
if (!files.length) {
console.error("쓰는 법: node density.mjs [--구조] 초안.md");
process.exit(2);
}
const LABEL = { words: "낱말", sentWords: "문장 평균 낱말", code: "코드블록",
numbers: "수치", sections: "## 절", tableLines: "표 줄" };
let bad = 0;
for (const file of files) {
const m = measure(readFileSync(file, "utf8"));
console.log(`\n${basename(file)} (${genre} 글 기준)`);
for (const [k, [lo, hi]] of Object.entries({ ...BASE, numbers: NUMBERS[genre] })) {
const v = m[k];
const ok = v >= lo && v <= hi;
const note = ok ? "" : v < lo ? " ← 모자람" : " ← 넘침";
if (!ok && k !== "words" && k !== "code") bad++;
console.log(` ${LABEL[k].padEnd(16)} ${String(v).padStart(6)} 기준 ${lo}~${hi}${note}`);
}
if (m.tableLines > m.proseParas)
console.log(` ! 표 ${m.tableLines}줄이 산문 ${m.proseParas}문단보다 많다 — 표가 설명을 대신하고 있다`);
if (m.numbers < NUMBERS[genre][0])
console.log(` ! 수치가 ${m.numbers}개다 — 자료에 있는 크기를 「여러」·「대부분」으로 뭉갠 자리를 찾는다`);
if (m.sections > 9)
console.log(` ! 절이 ${m.sections}개다 — 문단 두 개짜리 절을 앞뒤와 합친다`);
}
process.exit(bad ? 1 : 0);