init: llm-wiki-haness 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:21:35 +09:00
parent 42bf3db4fd
commit 6c53ded9cb
2436 changed files with 194486 additions and 1 deletions
@@ -0,0 +1,615 @@
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
|---|---|---|---|
| `raw/branch-notes/experiment-nplus1-highlight-feed.md` | READ_FULL | L1-L233 | N+1 실험·측정 계약. Keycloak은 예정 형제 주제로만 언급되며 Keycloak 결정 없음. |
| `raw/branch-notes/feature-authentication-authorization-contract.md` | READ_FULL | L1-L341 | application authz PEP, raw role→permission registry, Keycloak role claim 입력 경계. |
| `raw/branch-notes/feature-boundary-validation-mapping-contract.md` | READ_FULL | L1-L407 | validation/mapping 경계 계약. Keycloak은 sample seed 설명에만 등장하며 Keycloak 결정 없음. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md` | READ_FULL | L1-L228 | SPA account-link UX, First Broker Login, link/unlink, CIAL, linked-account read path. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md` | READ_FULL | L1-L279 | `sub` federation key, local-account collision, re-auth, unlink lockout, Sync Mode. |
| `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md` | READ_FULL | L1-L274 | SPA Direct/BFF 선택, token holder, session, OAuth 2.1 조건. |
| `raw/branch-notes/feature-keycloak-docker-compose-stack.md` | READ_FULL | L1-L287 | Keycloak/Postgres/app/nginx Compose topology, health, import, issuer/network. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md` | READ_FULL | L1-L325 | P1B Google brokering, P1A 불변식, account matching, child-owner 위임. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md` | READ_FULL | L1-L316 | P1A header-trust ForwardAuth, oauth2-proxy/nginx/Traefik, cookie session. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md` | READ_FULL | L1-L193 | P2A→P2B login-path zero-change, token claim baseline, account-link exclusion. |
## 1. `experiment-nplus1-highlight-feed.md`
**0-finding justification:** Keycloak 일관성 관점에서 3개 항목을 확인했다.
1. Keycloak ownership — 해당 없음: 예정 형제 주제로만 언급됨(`:25`).
2. 인증 흐름 — 해당 없음: feed 측정은 HTTP 인증과 무관하다고 경계를 둠(`:108`).
3. Decision Evidence Map — D-01~D-10은 N+1·CQRS 실험 결정이며 Keycloak 설정을 소유하지 않음(`:64-77`).
따라서 이 파일에 대한 Keycloak inconsistency finding은 0건이다. 문서 전체 품질 판정은 본 lane 범위가 아니다.
## 2. `feature-authentication-authorization-contract.md`
### L1-F01: D3의 `ROLE_*` 입력과 실제 raw-role registry 계약이 다르다
- **Source file:** `raw/branch-notes/feature-authentication-authorization-contract.md`
- **Source quote:**
> AuthorizationPort 가 `ROLE_*` authorities → role→permission registry 확장 → 요구 permission 포함 판정. 매핑 source = app-side config 기본
- **Counterpart/current quote:**
> ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.
- **Source lines:** `raw/branch-notes/feature-authentication-authorization-contract.md:124`, `raw/branch-notes/feature-authentication-authorization-contract.md:169`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY`; 실패 영향은 `INFERENCE`.
- **Claim:** D3는 registry 입력을 `ROLE_*` authority로 정의하지만 구현 명세는 prefix 없는 raw role을 canonical 입력으로 정의한다.
- **Real-world assumptions:**
1. 구현이 `:169`의 raw-role contract를 따른다.
- **Falsifies if:** adapter가 registry 호출 전에 raw role을 `ROLE_*`로 변환한다.
- **Verify:** `AuthorizationAdapter` 입력과 `RolePermissionProperties` key를 함께 확인한다.
2. 운영자가 D3를 보고 config key를 작성한다.
- **Falsifies if:** config schema가 raw key만 허용하고 startup에서 prefix를 거부한다.
- **Verify:** `application.yml` binding test에 `admin``ROLE_ADMIN` 케이스를 추가한다.
- **Concrete failure mode:** 문서대로 `ROLE_ADMIN`을 registry key로 넣고 runtime이 `admin`을 조회하면 fail-closed로 permission 0개가 되어 정당한 요청이 403으로 거부된다.
- **Falsification condition:** `ROLE_*`는 Spring adapter 내부의 중간 표현일 뿐이며 D3가 raw-role 변환 경계를 명시하도록 이미 갱신돼 있다면 finding은 무효다.
- **Required action:** D3를 “Keycloak raw role → adapter에서 Spring authority 파생, authz registry는 raw role consume”으로 고치고 security-baseline owner 결정을 pointer로 연결한다.
- **Why this action:** as-built contract와 config vocabulary를 하나로 만들어 prefix drift를 없앤다.
- **Alternatives considered:**
1. **Raw role canonical** — 현재 코드와 맞고 문서만 수정한다.
2. **`ROLE_*` canonical** — Spring layer에는 단순하지만 registry/config migration이 필요하다.
3. **Permission claim 직접 consume** — IdP 중앙집중형에 맞지만 Keycloak 결합이 커진다.
4. **명시적 principal DTO에서 raw role/authority 둘 다 보존** — 경계가 선명하지만 모델이 늘어난다.
- **Counterargument:** D3의 `ROLE_*`가 단지 security-baseline 출력의 설명이고 실제 registry 입력은 `:169`에서 명확하다고 볼 수 있다. 사용자 확인 방법은 D3만 읽은 신규 운영자가 config key를 올바르게 작성할 수 있는지 문서 리뷰로 재현하는 것이다.
- **Required action verification:** raw-role/authority 용어를 grep하고 D3·§3·config example이 같은 vocabulary를 쓰는지 확인한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'AuthorizationPort 가 `ROLE_*` authorities → role→permission registry 확장 → 요구 permission 포함 판정. 매핑 source = app-side config 기본' 'raw/branch-notes/feature-authentication-authorization-contract.md'
124:AuthorizationPort 가 `ROLE_*` authorities → role→permission registry 확장 → 요구 permission 포함 판정. 매핑 source = app-side config 기본
$ grep -nFo -- 'ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.' 'raw/branch-notes/feature-authentication-authorization-contract.md'
169:ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.
```
**Single-finding justification:** authN/authZ ownership, permission model, failure mapping, method-security enforcement, Keycloak role input의 5개 항목을 검토했다. lane 내부에서 양립하지 않는 항목은 role vocabulary 1건이다. security-baseline의 실제 owner 결정은 unseen이므로 cross-lane 후보로 분리했다.
## 3. `feature-boundary-validation-mapping-contract.md`
**0-finding justification:** Keycloak 일관성 관점에서 3개 항목을 확인했다.
1. In/Out scope와 decisions는 validation/mapping 책임만 다룸(`:60-101`).
2. Decision Evidence Map D1~D15는 mapper·validation·ArchUnit 계약임(`:258-278`).
3. Keycloak은 sample seed 설명에만 등장함(`:350`).
따라서 Keycloak 설계·흐름 inconsistency finding은 0건이다.
## 4. `feature-keycloak-account-linking-spa-ux.md`
### L1-F02: unlink 안전정책의 owner 요약이 승격 전 상태에 머문다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md`
- **Source quote:**
> orphan 거부 메커니즘은 owner 브랜치 D3(역시 `needs-confirmation`)에서 추적
- **Counterpart/current quote:**
> Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제**
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:119`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:152`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY`.
- **Claim:** owner D3가 engine-source-backed 결정으로 승격됐지만 SPA UX의 D4 요약은 여전히 `needs-confirmation`으로 남아 있다.
- **Real-world assumptions:**
1. 배포 버전에서도 owner가 인용한 unlink guard가 유지된다.
- **Falsifies if:** 배포 release tag에서 guard가 제거·변경됐다.
- **Verify:** 배포 tag의 `LinkedAccountsResource`와 dev unlink 응답을 확인한다.
- **Concrete failure mode:** SPA UX 설계자가 server guard를 모른 채 중복 lockout API를 만들거나, 반대로 상태를 미확정으로 보고 UX 계약을 계속 보류한다.
- **Falsification condition:** 배포 버전 검증이 실패해 owner D3가 다시 `needs-confirmation`으로 내려가면 현재 요약은 stale하지 않다.
- **Required action:** D4·§Unlink·Claims의 요약을 `[[...sub-vs-email]] D3 — server guard confirmed; release-tag verification remains` 한 줄로 갱신한다.
- **Why this action:** evidence strength와 deployment-specific risk를 동시에 보존한다.
- **Alternatives considered:**
1. **Pointer + current summary** — 읽기 편하고 drift 전파가 필요하다.
2. **Pointer only** — drift가 적지만 UX 문맥이 약해진다.
3. **Version-pinned summary** — 정확하지만 Keycloak upgrade마다 갱신 비용이 든다.
- **Counterargument:** owner 근거가 `main` branch이므로 배포판에는 여전히 불확실성이 있다. 다만 이는 `UNSUPPORTED`가 아니라 “source-backed, release-tag 확인 필요” 상태다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'orphan 거부 메커니즘은 owner 브랜치 D3(역시 `needs-confirmation`)에서 추적' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
119:orphan 거부 메커니즘은 owner 브랜치 D3(역시 `needs-confirmation`)에서 추적
$ grep -nFo -- 'Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제**' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
152:Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제**
```
### L1-F03: delegated `email_verified` 정책이 active owned decision으로도 남아 있다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md`
- **Source quote:**
> **`email_verified=true`만 link 허용**
- **Counterpart/current quote:**
> `email_verified` 는 Keycloak *linking gate* 가 아니라 Trust Email(계정 생성 시 verified 표시) 설정
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:102`, `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:117`
- **Severity:** Medium
- **Classification:** `FACT — RESTATED_FOREIGN_DECISION + ambiguous mechanism`.
- **Claim:** active Decisions는 이 branch가 link gate를 소유하는 것처럼 단정하지만, DEM은 owner 위임과 Trust Email/custom-step 구분을 기록한다.
- **Real-world assumptions:**
1. 구현자가 `:102`를 Keycloak 기본 `Trust Email` 설정으로 해석한다.
- **Falsifies if:** owner D2가 custom First Broker Login step을 구현하고 참조자가 이를 명시한다.
- **Verify:** unseen idp-mappers D2와 realm export의 authenticator 구성을 확인한다.
- **Concrete failure mode:** `Trust Email=true`만으로 linking을 제한했다고 오인해 Confirm Link/re-auth 통제가 빠지고, threat model과 실제 flow가 달라진다.
- **Falsification condition:** owner D2가 `email_verified` custom gate를 실제 소유·구현하고 `:102`가 그 pointer 요약으로만 읽히도록 고쳐졌다면 finding은 해소된다.
- **Required action:** `:102`의 owned decision을 제거하고 owner D2 pointer로 치환하며, Trust Email과 custom linking gate를 별개 용어로 적는다.
- **Why this action:** Reference-Only를 지키면서 설정 lever 혼동을 막는다.
- **Alternatives considered:**
1. **Trust Email만 사용** — 계정 생성 verified 표시에 적합하지만 linking proof는 아니다.
2. **Custom `email_verified` gate** — 정책은 강하지만 SPI/flow 구현이 필요할 수 있다.
3. **Confirm Link + secret proof** — account ownership을 검증하지만 UX 마찰이 있다.
4. **Collision linking 비활성화** — 단순하지만 기존 local account 연결을 제공하지 않는다.
- **Counterargument:** `:102`가 native setting이 아니라 상위 정책을 말한 것이라면 행동 자체는 유지할 수 있다. 그래도 owner와 mechanism을 함께 명시해야 오독을 막을 수 있다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '**`email_verified=true`만 link 허용**' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
102:**`email_verified=true`만 link 허용**
$ grep -nFo -- '`email_verified` 는 Keycloak *linking gate* 가 아니라 Trust Email(계정 생성 시 verified 표시) 설정' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
117:`email_verified` 는 Keycloak *linking gate* 가 아니라 Trust Email(계정 생성 시 verified 표시) 설정
```
### L1-F04: SPA UX가 password 확인을 고정 흐름으로 말하지만 lane owner 후보는 email verification 기본을 기록한다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md`
- **Source quote:**
> 또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' authenticator가 비밀번호 확인을 요구합니다.
- **Counterpart/current quote:**
> SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:34`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:151`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION`; authoritative flow owner는 unseen이므로 `NEEDS_CROSS_LANE_AUDIT`.
- **Claim:** 동일 email collision UX의 기본 verification method가 password와 email 두 가지로 서술된다.
- **Real-world assumptions:**
1. realm에 SMTP가 설정돼 있고 built-in first-broker-login flow를 사용한다.
- **Falsifies if:** Email authenticator가 disabled이거나 SMTP가 없다.
- **Verify:** First Broker Login execution config와 realm SMTP 설정을 확인한다.
2. SPA UX 문장이 사용자 안내·테스트 기대값으로 사용된다.
- **Falsifies if:** 문서가 security-first profile 한정이라고 표시돼 있다.
- **Verify:** UX copy와 E2E expected step을 비교한다.
- **Concrete failure mode:** 문서는 password 화면을 기대하지만 실제 realm은 email verification을 보여 테스트·사용자 안내가 어긋나거나, security-first 의도와 달리 secret proof가 생략된다.
- **Falsification condition:** first-broker owner D2가 Email authenticator disable을 고정하고 realm export가 이를 보장한다면 SPA 문장은 성립한다.
- **Required action:** first-broker owner에서 `email-default``password-step-up` profile 중 하나를 선택하고, SPA UX는 그 D-row를 pointer+1줄로만 참조한다.
- **Why this action:** UX, threat model, realm execution config를 같은 선택 조건에 묶는다.
- **Alternatives considered:**
1. **Email verification 기본** — SMTP 운영이 가능하고 마찰을 낮출 때.
2. **Password re-auth 강제** — 기존 local secret 소유 증명이 필요할 때.
3. **조건부 step-up** — 위험도에 따라 email/password를 나눌 때.
4. **기존-account linking 미제공** — duplicate account 비용을 감수할 때.
- **Counterargument:** 이 문장이 이미 security-first profile을 전제한다고 해석할 수 있다. 그러나 선택 조건이 본문에 없고 default flow 설명처럼 쓰였다는 점은 남는다.
- **Verification command and observed output:**
```text
$ grep -nFo -- "또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' authenticator가 비밀번호 확인을 요구합니다." 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
34:또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' authenticator가 비밀번호 확인을 요구합니다.
$ grep -nFo -- 'SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
151:SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행
```
## 5. `feature-keycloak-account-linking-sub-vs-email.md`
### L1-F05: active Sync Mode decision의 takeover 근거가 current DEM과 반대다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md`
- **Source quote:**
> Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.
- **Counterpart/current quote:**
> **본 branch(takeover)와 직교** — Sync Mode 는 이미 링크된 계정의 attribute(email/name) 최신성(FORCE) vs 안정성(IMPORT) trade-off 이며 link key(sub)를 안 바꾸므로 takeover 무관.
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:137`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:154`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY + RESTATED_FOREIGN_DECISION`.
- **Claim:** Decisions는 IMPORT가 Scenario B를 방어한다고 말하지만 current D5는 Sync Mode와 takeover가 직교하며 값 owner도 sibling이라고 정정한다.
- **Real-world assumptions:**
1. 운영자가 takeover 방어를 이유로 IMPORT를 선택한다.
- **Falsifies if:** IMPORT는 attribute ownership 정책으로 별도 채택된다.
- **Verify:** realm export와 ADR의 선택 근거를 확인한다.
- **Concrete failure mode:** 보안상 필요하지 않은 IMPORT가 고정돼 Google-authoritative profile freshness 요구를 막거나, FORCE를 선택하면 takeover가 생긴다는 잘못된 경보를 만든다.
- **Falsification condition:** IMPORT가 로컬 attribute 편집 보존이라는 별도 요구로 선택되고 active Decisions의 이유가 갱신되면 finding은 사라진다.
- **Required action:** `:137`을 historical decision으로 이동하고 active D5는 google-claim owner D3 pointer와 freshness/stability 조건만 남긴다.
- **Why this action:** federation key 보안과 attribute synchronization을 독립 축으로 유지한다.
- **Alternatives considered:**
1. **IMPORT** — 로컬 편집을 보존할 때.
2. **FORCE** — Google을 attribute authority로 둘 때.
3. **INHERIT/LEGACY** — realm default를 따르지만 upgrade drift를 감수한다.
4. **Owner-only delegation** — 이 노트는 takeover 영향만 “직교”로 기록한다.
- **Counterargument:** email 충돌의 운영 영향을 줄이기 위해 IMPORT가 여전히 유효할 수 있다. 다만 그 이유는 takeover 방지가 아니라 attribute stability다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
137:Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.
$ grep -nFo -- '**본 branch(takeover)와 직교** — Sync Mode 는 이미 링크된 계정의 attribute(email/name) 최신성(FORCE) vs 안정성(IMPORT) trade-off 이며 link key(sub)를 안 바꾸므로 takeover 무관.' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
154:**본 branch(takeover)와 직교** — Sync Mode 는 이미 링크된 계정의 attribute(email/name) 최신성(FORCE) vs 안정성(IMPORT) trade-off 이며 link key(sub)를 안 바꾸므로 takeover 무관.
```
### L1-F06: persistent federation key와 local-account collision locator가 `sub-only` 하나로 합쳐져 있다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md`
- **Source quote:**
> federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
- **Counterpart/current quote:**
> OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch)
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:150`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:172`
- **Severity:** High
- **Classification:** `FACT — ambiguous semantics / incompatible scope assumption`; 실패 영향은 `INFERENCE`.
- **Claim:** 이미 연결된 federated identity의 안정 key와, 아직 연결되지 않은 local account 후보를 찾는 locator가 구분되지 않아 D1을 구현하려면 out-of-scope custom authenticator가 필요하다는 결론에 이른다.
- **Real-world assumptions:**
1. 기존 local account에는 아직 Google `sub` mapping이 없다.
- **Falsifies if:** 외부 directory에 local-user↔Google-sub 사전 매핑이 존재한다.
- **Verify:** first-login 전 `FEDERATED_IDENTITY`와 별도 mapping source를 확인한다.
2. OOTB flow가 email/username으로 후보를 찾고 별도 verification을 수행한다.
- **Falsifies if:** 배포판에 sub-based collision authenticator가 있다.
- **Verify:** owner flow와 authenticator source를 확인한다.
- **Concrete failure mode:** “sub-only collision detection”을 그대로 구현하면 기존 local account를 찾지 못해 duplicate user가 생기거나, custom authenticator가 제외돼 D1이 실행 불가능한 정책으로 남는다.
- **Falsification condition:** D1의 “link key”가 오직 persistent federated identity lookup만 의미하고 email candidate discovery+re-auth를 허용한다고 명시하면 모순은 용어 문제로 축소된다.
- **Required action:** 결정을 두 개로 분리한다: `(IdP alias, external sub)`는 persistent federation key, email/username은 existing-account 후보 locator일 수 있으나 link 승인 proof는 아니다.
- **Why this action:** stable identity와 initial discovery의 서로 다른 위협 모델을 분리한다.
- **Alternatives considered:**
1. **Email candidate + re-auth** — OOTB 활용, email은 proof가 아님.
2. **기존 계정 로그인 후 CIAL** — 안정적이나 사용자가 두 단계 수행한다.
3. **사전 directory mapping** — 기업 환경에 맞지만 동기화 운영이 필요하다.
4. **항상 신규 계정 생성** — 단순하지만 account fragmentation을 감수한다.
- **Counterargument:** “primary key”가 DB 저장 key만 뜻했다면 D1 자체는 맞다. 이 경우 custom authenticator 요구를 D1에서 분리하고 용어만 바로잡으면 된다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
150:federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
$ grep -nFo -- 'OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch)' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
172:OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch)
```
## 6. `feature-keycloak-bff-vs-spa-direct.md`
### L1-F07: 요약 매트릭스가 OAuth 2.1의 조건부 BFF 권고를 일반 권고처럼 표시한다
- **Source file:** `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md`
- **Source quote:**
> | OAuth 2.1 draft 권고 | (허용) | ✅ (browser-based app) |
- **Counterpart/current quote:**
> public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님
- **Source lines:** `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md:130`, `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md:165`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY`.
- **Claim:** 상단 matrix는 browser-based app이면 BFF가 표준 우위인 것처럼 보이지만 D5는 client credentials 사용 시에만 해당한다고 제한한다.
- **Real-world assumptions:**
1. 독자가 matrix를 architecture choice의 요약 SSOT로 사용한다.
- **Falsifies if:** D5 조건을 반드시 함께 읽도록 표가 연결돼 있다.
- **Verify:** matrix만 떼어 ADR 입력으로 사용해도 조건이 보이는지 확인한다.
- **Concrete failure mode:** public SPA+PKCE가 요구에 맞는데도 “표준 권고”를 이유로 stateful BFF를 선택해 session store·CSRF 통제가 불필요하게 추가된다.
- **Falsification condition:** 조직 보안 정책이 표준과 별개로 BFF를 요구하면 선택은 유지할 수 있지만 표준 근거의 일반화는 여전히 수정 대상이다.
- **Required action:** matrix cell을 “client credentials가 필요한 browser app에서 BFF”로 좁히고 D5 pointer를 붙인다.
- **Why this action:** 표준 조건과 프로젝트 risk preference를 분리한다.
- **Alternatives considered:**
1. **Public SPA + PKCE** — stateless와 multi-client가 중요할 때.
2. **Confidential BFF** — browser token exposure를 줄이고 server session을 운영할 때.
3. **Edge ForwardAuth** — 여러 backend가 공통 ingress 인증을 쓸 때.
4. **Token-mediating backend** — session과 API fan-out을 절충할 때.
- **Counterargument:** 금융·의료 등 내부 정책이 BFF를 요구할 수 있다. 그 경우 근거는 내부 risk decision으로 적어야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '| OAuth 2.1 draft 권고 | (허용) | ✅ (browser-based app) |' 'raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md'
130:| OAuth 2.1 draft 권고 | (허용) | ✅ (browser-based app) |
$ grep -nFo -- 'public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님' 'raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md'
165:public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님
```
**Single-finding justification:** token holder, session state, Spring client type, mobile split, OAuth 2.1 조건의 5개 항목을 검토했다. lane 내부의 명시적 mismatch는 OAuth 2.1 matrix 요약 1건이다. P2A/token-storage/refresh/audience owner 내용은 unseen이라 cross-lane으로 분리했다.
## 7. `feature-keycloak-docker-compose-stack.md`
### L1-F08: bridge-network 기본값과 app의 `localhost` issuer URL은 함께 동작하지 않는다
- **Source file:** `raw/branch-notes/feature-keycloak-docker-compose-stack.md`
- **Source quote:**
> Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/<realm>`
- **Counterpart/current quote:**
> **target env = 단일 EC2 Linux 를 기본 가정하므로 기본값은 `extra_hosts: [host.docker.internal:host-gateway]`** (backend 가 `host.docker.internal:8080` 로 JWKS/issuer 도달).
- **Source lines:** `raw/branch-notes/feature-keycloak-docker-compose-stack.md:138`, `raw/branch-notes/feature-keycloak-docker-compose-stack.md:207`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION`.
- **Claim:** app container는 `host.docker.internal`로 host-published Keycloak에 접근한다고 선택했지만 Resource Server URL은 app container 자신을 가리키는 `localhost`로 남아 있다.
- **Real-world assumptions:**
1. app과 Keycloak이 별도 bridge containers로 실행된다.
- **Falsifies if:** app이 host process이거나 `network_mode: host`를 쓴다.
- **Verify:** rendered Compose의 network mode를 확인한다.
2. Resource Server가 startup에 `issuer-uri` discovery를 수행한다.
- **Falsifies if:** reachable `jwk-set-uri`를 별도로 두고 issuer validation만 localhost로 한다.
- **Verify:** Spring security property와 startup log의 discovery URL을 확인한다.
- **Concrete failure mode:** app이 자기 container의 `localhost:8080`으로 discovery/JWKS를 호출해 connection refused로 기동 실패하거나 첫 JWT 검증에서 실패한다.
- **Falsification condition:** 실제 default가 `network_mode: host`이고 port-table 무효화를 수용한다면 localhost는 도달 가능하다.
- **Required action:** public issuer와 internal retrieval URL을 분리하거나, browser와 containers가 함께 resolve하는 hostname을 채택한다.
- **Why this action:** token `iss` 일치와 network reachability를 동시에 만족시킨다.
- **Alternatives considered:**
1. **Host network + localhost issuer** — Linux 한정, `ports:` 의미가 사라진다.
2. **Public `issuer-uri` + internal `jwk-set-uri`** — issuer는 유지하고 JWKS만 reachable URL로 가져온다.
3. **공통 DNS hostname** — browser/app/Keycloak이 같은 host를 resolve하도록 한다.
4. **app을 host에서 실행** — 학습은 단순하지만 4-service Compose 목표가 바뀐다.
- **Counterargument:** 이 노트는 iss mismatch 실험을 의도하므로 실패 구성이 교육용일 수 있다. 그러나 P3A “정상 기동 stack”과 “함정 시연” profile을 분리해야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/<realm>`' 'raw/branch-notes/feature-keycloak-docker-compose-stack.md'
138:Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/<realm>`
$ grep -nFo -- '**target env = 단일 EC2 Linux 를 기본 가정하므로 기본값은 `extra_hosts: [host.docker.internal:host-gateway]`** (backend 가 `host.docker.internal:8080` 로 JWKS/issuer 도달).' 'raw/branch-notes/feature-keycloak-docker-compose-stack.md'
207:**target env = 단일 EC2 Linux 를 기본 가정하므로 기본값은 `extra_hosts: [host.docker.internal:host-gateway]`** (backend 가 `host.docker.internal:8080` 로 JWKS/issuer 도달).
```
**Single-finding justification:** service topology, DB env, health port, realm import, secret mapping, issuer/network의 6개 항목을 검토했다. 다른 항목은 선택 조건·risk가 함께 적혀 있으며, 함께 선택된 default끼리 양립하지 않는 것은 issuer reachability 1건이다.
## 8. `feature-keycloak-edge-forwardauth-google-federation.md`
### L1-F09: parent D5와 child D1이 같은 `sub` mapping 결정을 각각 소유한다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md`
- **Source quote:**
> 사용자 매핑 식별자 = Google `sub` (영구 식별자), email 은 primary key 금지
- **Ownership quote:**
> 이 브랜치의 D1~D5 가 이들의 설정 SSOT.
- **Counterpart quote:**
> federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:195`, `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:253`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:150`
- **Severity:** High
- **Classification:** `FACT — DUAL_OWNERSHIP + RESTATED_FOREIGN_DECISION`.
- **Claim:** 동일 concern인 Google `sub` federation key를 parent D5와 child D1이 모두 owner로 선언한다.
- **Real-world assumptions:**
1. 두 D-row가 같은 persistent mapping key를 뜻한다.
- **Falsifies if:** parent는 pattern requirement, child는 별도 implementation mechanism을 소유한다고 concern을 분리한다.
- **Verify:** concern 이름과 change authority를 owner map에서 비교한다.
- **Concrete failure mode:** child가 candidate-discovery와 `sub` semantics를 정정해도 parent D5가 그대로 남아 참조자가 서로 다른 결론을 가져간다.
- **Falsification condition:** parent D5가 “requirement only”라는 별도 concern ID를 갖고 child D1만 concrete key policy owner임을 명시하면 dual ownership이 해소된다.
- **Required action:** 한 문서만 D-row owner로 남긴다. 권장 구조는 parent가 P1B topology만 소유하고 D5를 child D1 pointer+1줄 요약으로 바꾸는 것이다.
- **Why this action:** child의 위협 모델·evidence 변화가 한 owner에서 전파되게 한다.
- **Alternatives considered:**
1. **Child owner, parent pointer** — 현재 문서 분해와 가장 잘 맞는다.
2. **Parent owner, child 분석-only** — hub가 무거워지지만 owner는 하나다.
3. **Child를 parent로 병합** — drift는 줄지만 문서 깊이가 커진다.
4. **Requirement/mechanism concern 분리** — 두 owner를 유지하되 동일 결정이 아니도록 명명한다.
- **Counterargument:** parent D5가 requirement이고 child D1이 상세라고 볼 수 있다. 그러나 현재 두 문구는 같은 key와 email 금지를 결정하며 경계가 명시되지 않았다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '사용자 매핑 식별자 = Google `sub` (영구 식별자), email 은 primary key 금지' 'raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md'
195:사용자 매핑 식별자 = Google `sub` (영구 식별자), email 은 primary key 금지
$ grep -nFo -- '이 브랜치의 D1~D5 가 이들의 설정 SSOT.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md'
253:이 브랜치의 D1~D5 가 이들의 설정 SSOT.
$ grep -nFo -- 'federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
150:federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
```
### L1-F10: P1B가 “P1A와 동일”이라면서 backend trust를 JWT signature로 바꾼다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md`
- **Source quote:**
> | Backend의 token 검증 | Keycloak signature | 동일 (Google token은 backend까지 안 옴) |
- **Counterpart quote:**
> **백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:145`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:112`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION`.
- **Claim:** P1A는 header-only trust를 정의하지만 P1B 비교표는 P1A와 P1B 모두 backend가 Keycloak signature를 검증한다고 요약한다.
- **Real-world assumptions:**
1. P1B가 P1A에 Google leg만 추가하는 변형이다.
- **Falsifies if:** P1B가 defense-in-depth JWT validation을 추가하는 별도 variant다.
- **Verify:** backend dependency/config diff를 확인한다.
2. backend가 한 trust contract를 따라 구현된다.
- **Falsifies if:** route별로 header-only와 bearer validation을 명시적으로 나눈다.
- **Verify:** ingress header/token forwarding과 SecurityFilterChain을 대조한다.
- **Concrete failure mode:** 구현팀 일부는 ingress header만 신뢰하고 다른 일부는 bearer token을 요구해, 같은 보호 route에서 401 또는 spoofing 방어 수준 차이가 발생한다.
- **Falsification condition:** P1B가 새 variant임을 명시하고 “P1A와 동일” 문구를 제거하면 contradiction이 아니다.
- **Required action:** P1 contract를 header-only, JWT-only, 또는 header+JWT 중 하나로 고정하고 P1B는 그 contract를 그대로 참조한다.
- **Why this action:** Google federation 추가와 backend trust 변경을 독립 decision으로 만든다.
- **Alternatives considered:**
1. **Header-only** — backend 코드가 적지만 ingress isolation이 load-bearing이다.
2. **JWT-only** — backend가 self-contained validation을 수행한다.
3. **Header + JWT AND 검증** — 방어가 늘지만 운영·코드 비용도 늘어난다.
4. **Route별 variant** — 유연하지만 routing contract가 필요하다.
- **Counterargument:** oauth2-proxy가 access token을 선택적으로 넘기므로 backend마다 다르게 쓸 수 있다. 그렇다면 “동일 패턴”이 아니라 두 variant를 명명해야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '| Backend의 token 검증 | Keycloak signature | 동일 (Google token은 backend까지 안 옴) |' 'raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md'
145:| Backend의 token 검증 | Keycloak signature | 동일 (Google token은 backend까지 안 옴) |
$ grep -nFo -- '**백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
112:**백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**
```
## 9. `feature-keycloak-edge-forwardauth-no-google.md`
### L1-F11: oauth2-proxy와 Traefik ForwardAuth를 대안으로 놓지만 구현안에서는 함께 사용한다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md`
- **Source quote:**
> oauth2-proxy vs Traefik ForwardAuth plugin 선택 기준 정리.
- **Counterpart quote:**
> oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:167`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:227`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION / incompatible alternatives`.
- **Claim:** D1은 oauth2-proxy와 Traefik ForwardAuth를 교체 가능한 단일 축으로 취급하지만 Traefik middleware는 실제로 oauth2-proxy auth endpoint를 호출한다.
- **Real-world assumptions:**
1. “Traefik ForwardAuth”는 middleware 자체를 뜻하며 OIDC session provider가 아니다.
- **Falsifies if:** 문서가 별도 all-in-one `traefik-forward-auth` 제품을 뜻한다.
- **Verify:** image/name/config와 `forwardAuth.address` target을 확인한다.
- **Concrete failure mode:** “Traefik 선택”만으로 OIDC callback/session 기능이 생긴다고 보고 oauth2-proxy 같은 auth service를 배치하지 않아 로그인 흐름이 성립하지 않는다.
- **Falsification condition:** 정확한 auth provider 제품을 명명하고 Traefik middleware와의 역할을 분리하면 finding은 해소된다.
- **Required action:** 선택 축을 `(Ingress: nginx|Traefik)``(Auth service: oauth2-proxy|다른 OIDC agent)`의 2차원으로 다시 작성한다.
- **Why this action:** middleware와 identity-aware auth service의 책임을 분리한다.
- **Alternatives considered:**
1. **Nginx + oauth2-proxy**`auth_request` 방식.
2. **Traefik + oauth2-proxy**`forwardAuth.address` 방식.
3. **Traefik + 별도 ForwardAuth service** — 대체 provider를 명시한다.
4. **oauth2-proxy reverse-proxy mode** — 별도 ingress subrequest 없이 upstream을 직접 proxy한다.
- **Counterargument:** D1이 “Ingress-Nginx stack과 Traefik stack”을 줄여 쓴 표현일 수 있다. 그러나 현재 제목과 표는 component 대 component 비교로 읽힌다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'oauth2-proxy vs Traefik ForwardAuth plugin 선택 기준 정리.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
167:oauth2-proxy vs Traefik ForwardAuth plugin 선택 기준 정리.
$ grep -nFo -- 'oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
227:oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행
```
### L1-F12: “브라우저에 access token 없음”과 “access token을 cookie에 저장”이 같은 threat model에 섞여 있다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md`
- **Source quote:**
> **토큰 클라이언트 노출 없음**: `access_token`을 브라우저에 두지 않음 → XSS로 토큰이 탈취될 surface 제거. BFF-like.
- **Counterpart quote:**
> **세션 쿠키 크기**: access_token까지 쿠키에 저장하면 nginx의 기본 4kb 헤더 한도를 넘어 분할 쿠키 처리 로직 필요.
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:120`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:127`
- **Severity:** Medium
- **Classification:** `FACT — CONTRADICTION / incompatible token-storage assumptions`.
- **Claim:** server-side session과 encrypted cookie session을 구분하지 않아 browser token possession과 JS raw-token readability가 같은 의미로 쓰인다.
- **Real-world assumptions:**
1. oauth2-proxy가 cookie-backed session에 token material을 넣을 수 있다.
- **Falsifies if:** chosen config가 Redis/server-side store만 사용하고 cookie에는 opaque ID만 둔다.
- **Verify:** oauth2-proxy session-store type과 cookie payload 크기를 확인한다.
- **Concrete failure mode:** 보안 설계가 server-side BFF 수준의 token custody를 가정하지만 실제로는 browser-held encrypted cookie가 bearer-equivalent session이 되어 replay·size·rotation 위험이 남는다.
- **Falsification condition:** cookie가 opaque session identifier뿐이고 token은 server store에만 있으면 `:120`의 보관 경계가 성립한다.
- **Required action:** session mode를 결정하고 “raw token JS 접근”, “encrypted cookie 보유”, “server-side token 보관”을 별도 행으로 기술한다.
- **Why this action:** XSS exfiltration, cookie replay, header-size 문제를 각각 맞는 통제로 연결한다.
- **Alternatives considered:**
1. **Redis/server-side session** — token custody가 명확하지만 state store가 필요하다.
2. **Opaque cookie ID + server memory** — 단일 인스턴스 학습에 맞지만 scale-out이 약하다.
3. **Encrypted cookie에 token 포함** — 운영은 단순하지만 size/replay 경계를 문서화해야 한다.
4. **Access token upstream 전달** — backend JWT validation contract가 추가된다.
- **Counterargument:** httpOnly encrypted cookie는 JS가 raw access token을 읽지 못하므로 “직접 노출 없음”이라는 보안 이점은 남는다. 다만 “브라우저에 두지 않음”이라는 물리적 보관 문구는 좁혀야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '**토큰 클라이언트 노출 없음**: `access_token`을 브라우저에 두지 않음 → XSS로 토큰이 탈취될 surface 제거. BFF-like.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
120:**토큰 클라이언트 노출 없음**: `access_token`을 브라우저에 두지 않음 → XSS로 토큰이 탈취될 surface 제거. BFF-like.
$ grep -nFo -- '**세션 쿠키 크기**: access_token까지 쿠키에 저장하면 nginx의 기본 4kb 헤더 한도를 넘어 분할 쿠키 처리 로직 필요.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
127:**세션 쿠키 크기**: access_token까지 쿠키에 저장하면 nginx의 기본 4kb 헤더 한도를 넘어 분할 쿠키 처리 로직 필요.
```
## 10. `feature-keycloak-federation-spa-zero-change.md`
### L1-F13: zero-change는 hosted-login 경로에만 성립하지만 제목·목표는 P2B 전체처럼 읽힌다
- **Source file:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md`
- **Source quote:**
> **P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다.
- **Counterpart quote:**
> SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)
- **Scope quote:**
> Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
- **Source lines:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:31`, `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:105`, `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:53`
- **Severity:** Medium
- **Classification:** `FACT — ambiguous scope / incompatible defaults`.
- **Claim:** hosted Keycloak login button만 쓰면 login entry는 zero-change지만, in-SPA account linking을 포함하면 URL fabrication·hashing·상태 표시 코드가 필요하다.
- **Real-world assumptions:**
1. 상위 P2B 독자가 “Google federation 기능 전체”에 zero-change를 일반화한다.
- **Falsifies if:** 상위 문서도 hosted-login-only로 범위를 명시한다.
- **Verify:** P2B parent의 In/Out scope와 TODO를 확인한다.
- **Concrete failure mode:** account-link button/status 요구가 있는데도 SPA change가 없다고 계획해 CIAL, CSRF state, linked-account read path가 누락된다.
- **Falsification condition:** 제품 요구가 Keycloak-hosted login page에서 Google을 선택하는 것뿐이고 account UX를 제공하지 않으면 zero-change는 성립한다.
- **Required action:** 제목·목표를 “Google IdP sign-in entry zero-change”로 좁히고 account-link 기능별 change matrix를 추가한다.
- **Why this action:** brokering의 login-path 불변식과 선택적 account UX 비용을 함께 보존한다.
- **Alternatives considered:**
1. **Hosted login only** — SPA diff 0.
2. **`idpHint` 버튼** — 작은 SPA 변경으로 Google을 직접 선택한다.
3. **Account Console 위임** — SPA account-link 코드는 줄지만 별도 UX로 이동한다.
4. **In-SPA linking/status** — CIAL과 read API/backend 구현이 필요하다.
- **Counterargument:** 이 branch는 `:53`에서 Account Linking을 명시적으로 제외한다. finding은 branch 내부 구현이 아니라 제목·목표가 상위 P2B 범위로 일반화될 위험을 지적한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '**P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다.' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
31:**P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다.
$ grep -nFo -- 'SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
105:SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)
$ grep -nFo -- 'Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
53:Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
```
### L1-F14: zero-change token baseline에서 `aud` owner와 기대값이 모호하다
- **Source file:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md`
- **Source quote:**
> `aud=<spa-client-id>` 확인
- **Counterpart quote:**
> `aud`=backend/SPA client, `azp`=SPA client → **구조 동일**
- **Source lines:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:78`, `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:131`
- **Severity:** High
- **Classification:** `FACT — ambiguous default`; P2A audience owner가 unseen이므로 `NEEDS_CROSS_LANE_AUDIT`.
- **Claim:** 동일 token expectation에서 `aud`가 SPA client로 고정되기도 하고 backend 또는 SPA client로 열려 있기도 하다.
- **Real-world assumptions:**
1. SPA와 Resource API가 별도 clients이고 backend가 audience를 강제한다.
- **Falsifies if:** 하나의 client가 SPA와 API audience를 겸하도록 의도됐다.
- **Verify:** realm export의 client/audience mapper와 backend validator를 확인한다.
- **Concrete failure mode:** token `aud`가 SPA client뿐인데 backend가 API audience를 기대하면 401이 발생한다. 반대로 backend가 SPA audience를 수용하면 cross-client token reuse 경계가 약해질 수 있다.
- **Falsification condition:** P2A owner가 `aud=<spa-client-id>`를 명시적으로 선택했고 backend validator도 같은 값을 SSOT로 참조한다면 모호성은 해소된다.
- **Required action:** P2A audience owner D-row를 직접 참조하고 expected claims를 `iss`, `aud`, `azp` 각각 정확한 client ID로 고정한다.
- **Why this action:** zero-change 검증은 기준선이 한 값으로 정의돼야 의미가 있다.
- **Alternatives considered:**
1. **`aud=api-client`, `azp=spa-client`** — SPA/API 분리가 선명하다.
2. **`aud=spa-client`** — 단일-client 학습에는 단순하지만 backend trust를 명시해야 한다.
3. **복수 audience** — 여러 API에 유연하지만 token scope가 넓어진다.
4. **BFF audience** — BFF가 token holder인 별도 pattern이다.
- **Counterargument:** `backend/SPA client`가 환경별 placeholder를 뜻할 수 있다. 그렇다면 zero-change test가 실제 어떤 값을 assert할지 선택 조건을 추가해야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '`aud=<spa-client-id>` 확인' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
78:`aud=<spa-client-id>` 확인
$ grep -nFo -- '`aud`=backend/SPA client, `azp`=SPA client → **구조 동일**' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
131:`aud`=backend/SPA client, `azp`=SPA client → **구조 동일**
```
## Grep verification
- Findings: 14
- Verbatim quotes: 30
- 실행한 quote-specific `grep -nFo`: 30
- 비어 있지 않은 관측 결과: 30
- Line corrections: 0
- Dropped quotes/findings: 0
- 모든 finding의 source/counterpart quote가 위 명령 결과와 일치했다.
## Claim Traceability Check
- Claim ID 연결 검사: 10개 branch-note의 DEM 구조를 읽었다. 같은 lane에 counterpart가 있는 decision edge는 의미 대조했으며, raw claim source body와 unseen owner D-row는 고정 slice 밖이므로 `BROKEN_CLAIM_REFERENCE` 여부를 판정하지 않았다.
- Decision Evidence Map: 10개 검토. lane-local semantic drift는 L1-F01~L1-F14에 반영했다.
- UNSUPPORTED_DECISION: 표기 자체만으로 inconsistency로 올리지 않았다. categorical summary/current DEM/owner summary가 어긋난 경우만 finding으로 다뤘다.
- **Repository changes:** 없음. 읽기 전용.
## Cross-Lane Candidates
| Citing file:line | Referenced owner/decision | Status | One-line reason |
|---|---|---|---|
| `raw/branch-notes/feature-authentication-authorization-contract.md:124,219` | `feature-security-operational-baseline``JwtToAuthenticatedUserConverter`, AUTHZ code SSOT | NEEDS_CROSS_LANE_AUDIT | `ROLE_*` authority와 raw role seam의 실제 owner vocabulary 확인 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:181` | `feature-keycloak-first-broker-login-flow` D1/D2 | NEEDS_CROSS_LANE_AUDIT | auto-link disable과 Email/Re-auth execution profile의 authoritative D-row 확인 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:183` | `feature-keycloak-idp-mappers-claim-to-role` D2 | NEEDS_CROSS_LANE_AUDIT | `email_verified`가 custom gate인지 Trust Email 설정인지 owner 판정 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:184` | `feature-keycloak-google-claim-attribute-mapping` D1/D4 | NEEDS_CROSS_LANE_AUDIT | `sub`/email claim mapping과 collision input의 owner summary 확인 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:211` | `feature-keycloak-first-broker-login-flow` D1/D2/D4 | NEEDS_CROSS_LANE_AUDIT | password/email verification 및 `email_verified=false` 방어 flow가 unseen owner에 위임됨. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:212` | `feature-keycloak-google-claim-attribute-mapping` D3/D4 | NEEDS_CROSS_LANE_AUDIT | Sync Mode와 email Attribute Importer의 current owner 결정을 확인해야 함. |
| `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md:226-229` | P2A parent D1, token-storage D1/D5, refresh D1/D2, audience D1/D4 | NEEDS_CROSS_LANE_AUDIT | SPA Direct 기준선 네 축이 본 lane 밖 owner에 의존함. |
| `raw/branch-notes/feature-keycloak-docker-compose-stack.md:236,240` | `feature-keycloak-single-ec2-no-google` D3; `feature-keycloak-iss-claim-hostname-mismatch` | NEEDS_CROSS_LANE_AUDIT | public issuer/internal reachability contract의 authoritative network profile 확인 필요. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:252` | root `feature-keycloak-patterns` F3/F4 | NEEDS_CROSS_LANE_AUDIT | shared realm/client와 secret ownership이 P1B Google client 설계 전제임. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:253` | idp-client, first-broker, claim-mapping child owners | NEEDS_CROSS_LANE_AUDIT | parent D1-D5와 child owner 경계를 전체 hierarchy에서 확인해야 함. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:248` | `feature-keycloak-header-spoofing-defense` D1/D3/D4 | NEEDS_CROSS_LANE_AUDIT | header-only trust의 load-bearing network isolation 결정이 unseen owner에 있음. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:249-250` | oauth2-proxy flow D2; nginx auth-request D2/D3/D5 | NEEDS_CROSS_LANE_AUDIT | P1A hub가 세부 config를 재진술했는지 owner D-row와 의미 대조 필요. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:145` | `feature-keycloak-internal-spa-direct-no-google` D3 | NEEDS_CROSS_LANE_AUDIT | `iss`/signature/expiry/audience baseline과 정확한 `aud` 값을 확인해야 함. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:146-149` | P2B parent, first-broker, claim-mapping, three-leg-trust owners | NEEDS_CROSS_LANE_AUDIT | zero-change 전제가 현재 delegated owners의 D-row와 맞는지 확인 필요. |
```wiki-stats
agent: wiki-research-lane
found: 10
processed: 10
dropped: 0
```
@@ -0,0 +1,477 @@
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
| --- | --- | --- | --- |
| `raw/branch-notes/feature-keycloak-first-broker-login-flow.md` | READ_FULL | 1261 | D1/D2 Confirm Link 소유증명, D4 silent auto-link 차단, hard-reject SPI 별도 트랙 |
| `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md` | READ_FULL | 1238 | attribute-mapping owner, D3 IdP default IMPORT, D5 role mapping 위임 |
| `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md` | READ_FULL | 1261 | exact redirect URI, basic-scope 예외, JavaScript origins 비움 |
| `raw/branch-notes/feature-keycloak-header-spoofing-defense.md` | READ_FULL | 1255 | K8s NetworkPolicy·EC2 SG·shared secret·trusted proxy 방어 경계 |
| `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md` | READ_FULL | 1284 | TLS 종단 대안과 명시적 HSTS 구성 |
| `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md` | READ_FULL | 1251 | Google IdP client, `trustEmail=false`, callback·origin 위임 |
| `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md` | READ_FULL | 1148 | role/RBAC owner, D1 FORCE, D2 email verification 강제 주장 |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md` | READ_FULL | 1512 | P2B composition hub, D8 zero-change brokering, D10 SMTP 사실, foreign-decision 상세 재진술 |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md` | READ_FULL | 1416 | P2A SPA Direct 정의, D2 refresh-token 저장, D4 brokering 비교 |
| `raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch.md` | READ_FULL | 1287 | issuer/JWKS 분리 대안, 미승인 F, 외부 owner 충돌 명시 |
## File 1 — `feature-keycloak-first-broker-login-flow.md`
### L2-F01: Confirm Link 보장의 버전 전제가 owner에 반영되지 않음
- **Severity:** High
- **Failure modes:** `STALE_SUMMARY`, `UNSUPPORTED_DECISION`
- **Source files:** first-broker-login-flow, idp-brokering-google-client, internal-spa-direct-google-federation
- **Original goal:**
- 인용:
```text
core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립
```
- 위치: `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`
- **Current state:**
- 인용:
```text
이는 `trustEmail=false`+email 인증 상태에서도 Confirm Link 흐름이 우회 가능했던 실제(패치완료) 취약점
```
- 위치: `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md:214`
- 인용:
```text
`CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다**
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:338`
- **Claim:** 문서 상태에 관한 FACT는 세 노트가 서로 다른 보장 강도를 제공한다는 것이다. 실제 취약점·영향 버전은 아직 `INFERENCE/UNVERIFIED`다.
- **Assumptions:** F의 취약점 보고가 정확하고 배포 버전이 영향 범위일 때만 owner의 무조건부 보장이 실패한다.
- **Concrete failure:** 구현자가 line 135만 소비하면 패치 상태를 확인하지 않고 Confirm Link를 충분한 차단으로 취급할 수 있다.
- **Falsification:** 공식 Keycloak advisory가 해당 흐름에 영향이 없다고 밝히거나, 배포 버전이 영향 범위 밖임을 증명하면 보안 충돌은 사라진다.
- **Required action / synthesis recommendation:** 공식 advisory를 raw로 보존한 뒤 owner D4에 버전 조건을 추가한다. 그 전에는 F의 “실제 취약점” 표현을 `needs-confirmation`으로 낮추고, owner에는 미검증 version gate만 둔다.
- **Why:** 보안 owner의 보장은 소비자가 한 줄만 읽어도 유효 조건을 알 수 있어야 한다.
- **Alternatives:** ① 패치 버전 allowlist 게이트, ② 취약 버전 배포 차단, ③ 별도 custom SPI 방어, ④ Google brokering 비활성화.
- **Counterargument:** 현재 지원 버전만 사용하면 Confirm Link 보장은 유효할 수 있다. 그렇더라도 owner 문서에 적용 버전 조건을 적으면 이 반론과 충돌하지 않는다.
- **Verification:**
```bash
grep -nFo -- 'core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립
grep -nFo -- '이는 `trustEmail=false`+email 인증 상태에서도 Confirm Link 흐름이 우회 가능했던 실제(패치완료) 취약점' raw/branch-notes/feature-keycloak-idp-brokering-google-client.md
# Observed: 214:이는 `trustEmail=false`+email 인증 상태에서도 Confirm Link 흐름이 우회 가능했던 실제(패치완료) 취약점
grep -nFo -- '`CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다**' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 338:`CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다**
```
### L2-F02: `email_verified=false` 정책이 silent-link 차단과 hard-reject 사이에서 갈림
- **Severity:** High
- **Failure modes:** `CONTRADICTION`, `RESTATED_FOREIGN_DECISION`
- **Source files:** first-broker-login-flow, idp-mappers-claim-to-role, internal-spa-direct-google-federation
- **Original goal:**
- 인용:
```text
`email_verified=false` 계정의 **silent auto-link 차단**
```
- 위치: `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`
- 인용:
```text
`email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 커스텀 SPI authenticator 필요 → 별도 트랙
```
- 위치: `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`
- **Current state:**
- 인용:
```text
`email_verified=true` 강제 (Attribute Importer + First Broker Login Flow custom step)
```
- 위치: `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:120`
- 인용:
```text
Google `email_verified=false` 계정 | 링크/생성 거부
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:387`
- **Claim:** owner D4는 소유증명 없는 auto-link만 차단한다. G와 H는 별도 SPI가 구현된 것처럼 전체 링크·생성을 거부한다고 요약한다.
- **Assumptions:** slice 안에는 hard-reject custom authenticator의 별도 owner나 구현 증거가 없다.
- **Concrete failure:** `email_verified=false` 사용자가 실제로는 Confirm Link 또는 신규 계정 흐름에 진입하는데, 테스트가 즉시 거부를 기대해 보안 정책과 구현이 어긋난다.
- **Falsification:** 별도 owner가 custom authenticator 구현과 E2E 거부 결과를 제시하면 G/H의 정책이 성립한다.
- **Required action / synthesis recommendation:** G/H를 owner D4의 “silent-link prevention only” 포인터로 축소한다. hard-reject가 필요하면 SPI branch를 단일 owner로 만들고 그 결정이 확정된 뒤 세 문서를 갱신한다.
- **Why:** 계정 연결 방어와 모든 계정 생성 거부는 서로 다른 정책·구현 계약이다.
- **Alternatives:** ① Confirm Link만 유지, ② custom SPI로 즉시 거부, ③ 통제된 enterprise IdP만 허용, ④ Google federation 자체를 사용하지 않음.
- **Counterargument:** Google이 일반적으로 verified email을 제공하므로 차이가 드물 수 있다. 발생 빈도와 정책 보장은 별개이며, 문서는 미검증 claim의 실제 동작을 정의해야 한다.
- **Verification:**
```bash
grep -nFo -- '`email_verified=false` 계정의 **silent auto-link 차단**' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:`email_verified=false` 계정의 **silent auto-link 차단**
grep -nFo -- '`email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 커스텀 SPI authenticator 필요 → 별도 트랙' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:`email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 커스텀 SPI authenticator 필요 → 별도 트랙
grep -nFo -- '`email_verified=true` 강제 (Attribute Importer + First Broker Login Flow custom step)' raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md
# Observed: 120:`email_verified=true` 강제 (Attribute Importer + First Broker Login Flow custom step)
grep -nFo -- 'Google `email_verified=false` 계정 | 링크/생성 거부' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 387:Google `email_verified=false` 계정 | 링크/생성 거부
```
## File 2 — `feature-keycloak-google-claim-attribute-mapping.md`
### L2-F03: IMPORT owner와 FORCE 소비자의 적용 범위가 불명확함
- **Severity:** Medium
- **Failure modes:** `AMBIGUOUS_OWNERSHIP`, 조건부 `CONTRADICTION`
- **Source files:** google-claim-attribute-mapping, idp-mappers-claim-to-role
- **Original goal:**
- 인용:
```text
그쪽이 attribute-mapping **owner**
```
- 위치: `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:19`
- **Current state:**
- 인용:
```text
| D1 | Sync Mode = `FORCE`
```
- 위치: `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:119`
- 인용:
```text
| D3 | Sync Mode = IMPORT (first login 시점 매핑만)
```
- 위치: `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md:130`
- 인용:
```text
IdP-level `Sync Mode` = 모든 mapper default
```
- 위치: `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md:130`
- **Claim:** B가 IdP-level default를 IMPORT로 소유하지만, G의 D1은 FORCE가 global인지 role mapper override인지 밝히지 않는다.
- **Assumptions:** G D1이 IdP-level 설정을 가리키면 두 값은 공존할 수 없다. mapper-level override이고 Keycloak이 해당 override를 지원한다면 공존 가능하다.
- **Concrete failure:** 운영자가 동일 Admin UI 필드를 두 지침대로 번갈아 설정하거나, 모든 attribute를 로그인마다 덮어써 사용자 편집값을 잃을 수 있다.
- **Falsification:** G D1을 특정 role mapper의 override로 명시하고 공식 근거·Admin UI 실측으로 override 우선순위를 확인하면 충돌이 해소된다.
- **Required action / synthesis recommendation:** B D3를 IdP default owner로 유지하고, G D1은 role mapper 전용 override로 증명·범위화하거나 제거한다.
- **Why:** fresh role과 보존해야 할 profile attribute는 서로 다른 갱신 정책을 가질 수 있으나 설정 계층을 분명히 해야 한다.
- **Alternatives:** ① global IMPORT + role mapper FORCE, ② 전부 IMPORT, ③ 전부 FORCE, ④ role 계산을 별도 authorization 계층으로 이동.
- **Counterargument:** role freshness에는 FORCE가 합리적이다. 이 finding은 FORCE 자체가 아니라 어느 설정 계층에 적용하는지 불명확한 점을 지적한다.
- **Verification:**
```bash
grep -nFo -- '그쪽이 attribute-mapping **owner**' raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md
# Observed: 19:그쪽이 attribute-mapping **owner**
grep -nFo -- '| D1 | Sync Mode = `FORCE`' raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md
# Observed: 119:| D1 | Sync Mode = `FORCE`
grep -nFo -- '| D3 | Sync Mode = IMPORT (first login 시점 매핑만)' raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md
# Observed: 130:| D3 | Sync Mode = IMPORT (first login 시점 매핑만)
grep -nFo -- 'IdP-level `Sync Mode` = 모든 mapper default' raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md
# Observed: 130:| D3 | Sync Mode = IMPORT (first login 시점 매핑만)
# Observed: 173:IdP-level `Sync Mode` = 모든 mapper default
```
## File 3 — `feature-keycloak-google-redirect-uri-policy.md`
### L2-F04: basic-scope 예외를 옛 test-user 한도 설명이 덮음
- **Severity:** Low
- **Failure mode:** `STALE_SUMMARY`
- **Source file:** google-redirect-uri-policy
- **Original goal:**
- 인용:
```text
이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨
```
- 위치: `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md:143`
- **Current state:**
- 인용:
```text
학습용은 `openid email profile`만 사용 → unverified로 100명 test users까지 동작
```
- 위치: `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md:92`
- **Claim:** D5의 정정 이후에도 TODO가 이전 한도 모델을 유지한다.
- **Assumptions:** 요청 scope가 정말 `openid email profile`에 한정된다.
- **Concrete failure:** 불필요한 test-user 등록·인원 관리 절차를 구현 계획에 넣고, 서로 다른 Google quota를 혼동한다.
- **Falsification:** sensitive/restricted scope가 실제로 추가되면 TODO의 한도 설명이 별도 시나리오에서 다시 필요하다.
- **Required action / synthesis recommendation:** line 92를 D5 포인터로 바꾸거나 삭제하고, sensitive-scope 시나리오는 별도 조건문으로 분리한다.
- **Why:** 한 파일 안의 TODO가 authoritative D-row보다 오래된 운영 지침을 제공한다.
- **Alternatives:** ① TODO 삭제 후 D5 포인터, ② 최신 예외 설명으로 갱신, ③ sensitive-scope 실험용 별도 TODO로 범위화.
- **Counterargument:** Testing 상태의 다른 quota를 기억시키려는 문장일 수 있다. 그렇다면 basic identity scope와 다른 quota임을 명시해야 한다.
- **Verification:**
```bash
grep -nFo -- '이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨' raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md
# Observed: 143:이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨
grep -nFo -- '학습용은 `openid email profile`만 사용 → unverified로 100명 test users까지 동작' raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md
# Observed: 92:학습용은 `openid email profile`만 사용 → unverified로 100명 test users까지 동작
```
### L2-F05: JavaScript origins가 owner와 consumer에서 다름
- **Severity:** Medium
- **Failure modes:** `STALE_SUMMARY`, `RESTATED_FOREIGN_DECISION`
- **Source files:** google-redirect-uri-policy, idp-brokering-google-client
- **Original goal:**
- 인용:
```text
| Authorized JavaScript origins | (비움) |
```
- 위치: `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md:167`
- **Current state:**
- 인용:
```text
Authorized JavaScript origins: `https://<kc-host>`
```
- 위치: `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md:81`
- **Claim:** redirect-policy owner는 server-side flow 때문에 origins를 비우지만, Google-client TODO는 외부 Keycloak host를 등록하라고 한다.
- **Assumptions:** Google OAuth 교환은 Keycloak 서버가 수행하며 브라우저 Google SDK를 직접 사용하지 않는다.
- **Concrete failure:** 불필요한 browser origin을 허용하고, 동일 client를 재현하는 사람마다 다른 Console 설정을 만든다.
- **Falsification:** Google Console 실측에서 해당 client type이 origin을 요구하거나 실제 browser-side Google SDK 흐름이 추가되면 host 등록이 필요하다.
- **Required action / synthesis recommendation:** F의 origin 값을 삭제하고 C D6 포인터로 교체한다. 브라우저 흐름이 생기면 별도 client와 owner를 둔다.
- **Why:** callback URI와 JavaScript origin은 다른 정책 필드이며, consumer가 owner 값을 재정의하면 drift가 생긴다.
- **Alternatives:** ① 비워 둠, ② browser flow 도입 시 최소 origin만 등록, ③ browser용 Google client 분리.
- **Counterargument:** host 하나를 미리 등록해도 당장 실패하지 않을 수 있다. 그러나 사용하지 않는 권한 표면과 재현 차이는 남는다.
- **Verification:**
```bash
grep -nFo -- '| Authorized JavaScript origins | (비움) |' raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md
# Observed: 167:| Authorized JavaScript origins | (비움) |
grep -nFo -- 'Authorized JavaScript origins: `https://<kc-host>`' raw/branch-notes/feature-keycloak-idp-brokering-google-client.md
# Observed: 81:Authorized JavaScript origins: `https://<kc-host>`
```
## File 4 — `feature-keycloak-header-spoofing-defense.md`
**0 independent findings.** D3(K8s)과 D4(EC2)는 환경별 대안이며 동시에 적용해야 하는 상충 결정이 아니다. D5는 두 환경의 보조 계층, D6는 Keycloak proxy 신뢰 경계로 분리돼 있다. 다만 부모·proxy owner가 slice 밖이므로 lines 202206은 `NEEDS_CROSS_LANE_AUDIT`로 이관한다.
## File 5 — `feature-keycloak-https-termination-caddy-nginx.md`
### L2-F06: Caddy HSTS 기본값 설명이 authoritative 구성과 상충함
- **Severity:** Medium
- **Failure mode:** `STALE_SUMMARY`
- **Source file:** https-termination-caddy-nginx
- **Original goal:**
- 인용:
```text
**명시 필요** (default 로 단정 금지)
```
- 위치: `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md:173`
- **Current state:**
- 인용:
```text
Caddy는 디폴트로 HTTPS redirect + HSTS
```
- 위치: `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md:43`
- **Claim:** 구현 표는 HSTS를 명시하라고 정정했지만 초기 설계 답변은 여전히 자동 적용이라고 말한다.
- **Assumptions:** 별도 global Caddy 설정이나 upstream edge가 HSTS를 주입하지 않는다.
- **Concrete failure:** line 43을 따라 구성하면 HTTPS redirect는 동작해도 HSTS 헤더가 빠질 수 있다.
- **Falsification:** 대상 Caddy 버전의 공식 문서나 응답 헤더 실측이 자동 HSTS를 입증하면 line 43이 맞다.
- **Required action / synthesis recommendation:** line 43을 “automatic HTTPS redirect, HSTS explicit”으로 정정하고 D2/D5에만 값을 소유시킨다.
- **Why:** TLS 자동화와 HSTS 정책은 서로 다른 기능이다.
- **Alternatives:** ① Caddy `header` 명시, ② nginx에서 명시, ③ Cloudflare edge에서 단일 owner로 주입, ④ ephemeral dev에서는 의도적으로 생략하고 범위를 표시.
- **Counterargument:** 학습 환경에서는 HSTS가 필요하지 않을 수 있다. 그렇다면 dev-only 생략 결정으로 적어야지 default 기능으로 설명하면 안 된다.
- **Verification:**
```bash
grep -nFo -- 'Caddy는 디폴트로 HTTPS redirect + HSTS' raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md
# Observed: 43:Caddy는 디폴트로 HTTPS redirect + HSTS
grep -nFo -- '**명시 필요** (default 로 단정 금지)' raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md
# Observed: 173:**명시 필요** (default 로 단정 금지)
```
## File 6 — `feature-keycloak-idp-brokering-google-client.md`
**0 additional independent findings.** 이 파일은 L2-F01의 미아카이브 CVE 주장과 L2-F05의 stale JavaScript-origin TODO에 직접 참여한다. 그 외 D6의 `trustEmail=false` 값 선택과 default 미확정 구분은 내부적으로 일관된다. 부모가 정하는 `<kc-host>`는 slice 밖이므로 line 192를 교차 lane 후보로 남긴다.
## File 7 — `feature-keycloak-idp-mappers-claim-to-role.md`
**0 additional independent findings.** 이 파일의 실제 상충은 L2-F02의 hard-reject 주장과 L2-F03의 FORCE 적용 범위로 모두 수거했다. role/RBAC만 소유한다는 line 19와 attribute-level D1/D2가 함께 남아 있는 것이 두 finding의 공통 원인이다.
## File 8 — `feature-keycloak-internal-spa-direct-google-federation.md`
### L2-F07: Reference-Only를 선언하면서 foreign decision 상세를 의도적으로 유지함
- **Severity:** Medium
- **Failure mode:** `RESTATED_FOREIGN_DECISION`
- **Source file:** internal-spa-direct-google-federation
- **Original goal:**
- 인용:
```text
본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:22`
- **Current state:**
- 인용:
```text
**유지 + 위임 고지** — 학습용 흐름 이해에 필요한 개요이므로 삭제하지 않고
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:443`
- **Claim:** 위임 고지는 Reference-Only의 “포인터 + 1줄” 제한을 대체하지 않는다. 파일 스스로 detailed restatement를 식별하면서 유지하기로 했다.
- **Assumptions:** 현재 consistency-contract의 Single-Owner/Reference-Only 규칙이 그대로 적용된다.
- **Concrete failure:** attribute mapping 또는 trust-chain owner가 바뀔 때 H의 상세 표가 같은 변경을 수동으로 따라가지 못해 stale copy가 된다.
- **Falsification:** 규칙이 synchronized derived view를 허용하도록 바뀌고, owner 변경 때 자동 검증되는 생성물이 되면 상세 유지가 가능하다.
- **Required action / synthesis recommendation:** branch-note에서는 owner 포인터와 한 줄 요약만 남긴다. 학습용 상세 설명은 canonical concept나 owner에서 생성되는 view로 이동한다.
- **Why:** “owner가 우선”이라는 주석은 독자가 stale 상세를 실행하는 것을 막지 못한다.
- **Alternatives:** ① 한 줄 pointer-only, ② canonical explainer로 이동, ③ owner 기반 자동 생성 view, ④ H가 새 owner가 되고 기존 owner를 pointer로 전환.
- **Counterargument:** composition hub에는 end-to-end 이해를 위한 상세가 필요하다. 그 요구는 canonical explainer나 생성 view로 충족할 수 있다.
- **Verification:**
```bash
grep -nFo -- '본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 22:본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다
grep -nFo -- '**유지 + 위임 고지** — 학습용 흐름 이해에 필요한 개요이므로 삭제하지 않고' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 443:**유지 + 위임 고지** — 학습용 흐름 이해에 필요한 개요이므로 삭제하지 않고
```
### L2-F08: brokering zero-change payload에 두 owner가 존재함
- **Severity:** Medium
- **Failure mode:** `DUAL_OWNERSHIP`
- **Source files:** internal-spa-direct-google-federation, internal-spa-direct-no-google
- **Original goal:**
- 인용:
```text
P2B 의 federation 은 **Keycloak 레이어에서만** broker
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:324`
- **Current state:**
- 인용:
```text
brokering 은 SPA/Backend **코드 0줄**로 얹히는 cross-cutting
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:282`
- 인용:
```text
**이중 주장 (`/sync` 수거 대상)**
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:402`
- **Claim:** H D8과 I D4가 동일한 zero-change 결론을 각각 결정 payload로 보유하며 H도 이를 이중 주장으로 인정한다.
- **Assumptions:** “Keycloak-only broker”와 “SPA/backend code zero”가 같은 구조적 보장의 두 표현이다.
- **Concrete failure:** `kc_idp_hint`, 로그인 버튼 노출 기본값, SPA 변경 조건이 진화할 때 두 D-row가 다른 경계를 가질 수 있다.
- **Falsification:** I D4를 범위 선택과 H D8 pointer만 남기도록 축소하면 소유권이 분리된다.
- **Required action / synthesis recommendation:** H D8을 composition owner로 지정하고 I D4를 포인터로 전환한다. 별도 `federation-spa-zero-change` owner를 택하려면 먼저 그 unseen branch와 교차 감사를 수행한다.
- **Why:** 아키텍처 불변성은 여러 구현 결정의 전제가 되므로 한 곳에서 조건과 예외를 소유해야 한다.
- **Alternatives:** ① H D8 owner, ② I D4 owner, ③ zero-change 전용 child owner, ④ project hub의 고정 결정으로 승격.
- **Counterargument:** baseline과 extension 양쪽에 같은 설명이 있어야 읽기 쉽다. pointer와 한 줄 요약이면 가독성을 유지하면서 owner를 하나로 둘 수 있다.
- **Verification:**
```bash
grep -nFo -- 'P2B 의 federation 은 **Keycloak 레이어에서만** broker' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 324:P2B 의 federation 은 **Keycloak 레이어에서만** broker
grep -nFo -- 'brokering 은 SPA/Backend **코드 0줄**로 얹히는 cross-cutting' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 282:brokering 은 SPA/Backend **코드 0줄**로 얹히는 cross-cutting
grep -nFo -- '**이중 주장 (`/sync` 수거 대상)**' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 402:**이중 주장 (`/sync` 수거 대상)**
```
L2-F01과 L2-F02도 이 파일의 version gate 및 `email_verified` edge table에 직접 적용된다. D10의 SMTP 소유권은 실제 docker-compose owner가 slice 밖이라 finding으로 확정하지 않고 교차 lane 후보로 남겼다.
## File 9 — `feature-keycloak-internal-spa-direct-no-google.md`
### L2-F09: SPA Direct 정의 안에 BFF refresh-cookie 선택을 넣음
- **Severity:** High
- **Failure modes:** `INCOMPATIBLE_ASSUMPTIONS`, `RESTATED_FOREIGN_DECISION`
- **Source file:** internal-spa-direct-no-google
- **Original goal:**
- 인용:
```text
**Edge proxy 없음.**
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:17`
- **Current state:**
- 인용:
```text
**secure httpOnly cookie** (BFF 변형)
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:188`
- 인용:
```text
| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:280`
- **Claim:** `INFERENCE` — HttpOnly refresh cookie를 발급·회전·소비하려면 token-mediating server가 필요하다. 정의된 backend는 Resource Server일 뿐이고 edge도 없으므로 D2 권장은 현재 패턴 안에서 실행할 주체가 없다.
- **Assumptions:** 브라우저 SPA는 HttpOnly cookie 값을 읽을 수 없고, Keycloak이 이 프로젝트 origin용 refresh cookie를 대신 관리하도록 설계되지 않았다.
- **Concrete failure:** 구현자가 Resource Server에 token exchange·cookie issuance·CSRF 방어를 뒤늦게 추가하면서 P2A를 사실상 BFF/AP3로 바꾸지만 문서·threat model은 SPA Direct로 남는다.
- **Falsification:** 별도 mediator 없이 Keycloak/browser adapter가 usable HttpOnly refresh cookie를 제공한다는 공식 근거와 E2E 결과가 있거나, 이미 mediator가 아키텍처에 포함돼 있음을 증명하면 성립하지 않는다.
- **Required action / synthesis recommendation:** P2A 기본은 memory-only로 두거나, HttpOnly 선택 시 명시적으로 token-mediating/BFF 패턴으로 전환하고 CSRF·rotation owner를 함께 연결한다. unseen token-storage owner도 교차 감사해야 한다.
- **Why:** storage 선택이 토큰 보유 주체와 인증 아키텍처를 바꾸고 있다.
- **Alternatives:** ① memory-only, ② Web Worker 격리, ③ 최소 token-mediator 추가, ④ full BFF, ⑤ browser storage 사용 후 XSS 위험 수용.
- **Counterargument:** reload 후 세션 유지에는 HttpOnly cookie UX가 낫다. 그 장점은 mediator 도입 비용과 CSRF 계약을 명시적으로 수용할 때만 비교할 수 있다.
- **Verification:**
```bash
grep -nFo -- '**Edge proxy 없음.**' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 17:**Edge proxy 없음.**
grep -nFo -- '**secure httpOnly cookie** (BFF 변형)' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 188:**secure httpOnly cookie** (BFF 변형)
grep -nFo -- '| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 280:| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장
```
L2-F08의 zero-change 이중 소유도 이 파일 D4에 적용된다.
## File 10 — `feature-keycloak-iss-claim-hostname-mismatch.md`
**0 slice-internal findings.** D6의 `jwk-set-uri` 대안은 부모와 audience-validator owner가 승인하기 전까지 권고안으로만 유지되고, 현재 기본을 C로 명시해 파일 내부에서 두 구성을 동시에 채택하지 않는다. `KC-HOST-C6`는 미아카이브 claim 후보라고 lines 142·230·247에 명시돼 Supporting Claim으로 위장하지 않는다. 외부 owner 충돌은 lines 204209의 교차 lane 후보로 분리한다.
## Synthesis
이 slice에는 High 3건, Medium 5건, Low 1건의 정합 gap이 있다. 가장 직접적인 보안 오해는 `email_verified=false`를 silent-link 차단과 hard-reject로 혼용한 L2-F02다. L2-F01의 버전 위험은 파급은 크지만 공식 advisory가 raw에 없어 실재·영향 범위를 아직 FACT로 취급할 수 없다. IMPORT/FORCE, JavaScript origins, HSTS는 owner 값보다 오래된 소비자·초기 설명이 남은 drift다. P2B는 Reference-Only를 선언하면서 상세 foreign decisions를 유지하고, zero-change 결론도 P2A와 공동 소유한다. P2A의 HttpOnly-cookie 권장은 SPA Direct와 BFF의 경계를 흐리므로 구현 전 owner 결정이 필요하다. Header-spoofing과 issuer-mismatch 파일은 slice 내부에서는 대안 조건과 미승인 상태를 구분하고 있다.
## Inferences
1. CVE 보고가 정확할 때만 owner의 무조건부 Confirm Link 보장이 영향 버전에서 실패한다 — Based on: `feature-keycloak-idp-brokering-google-client.md:214`, `feature-keycloak-internal-spa-direct-google-federation.md:338`.
2. G D1이 mapper-level override라면 IMPORT/FORCE는 공존할 수 있다 — Based on: `feature-keycloak-google-claim-attribute-mapping.md:130`, `feature-keycloak-idp-mappers-claim-to-role.md:119`.
3. HttpOnly refresh cookie에는 token-mediating server가 필요하다 — Based on: `feature-keycloak-internal-spa-direct-no-google.md:17,188,280`. 실제 adapter 동작으로 반증 가능하다.
## Out of Scope
- Slice 밖 parent/project/owner branch의 본문 의미 감사
- Keycloak·Google Console·Caddy 실제 구동
- CVE 공식 advisory 외부 fetch 및 raw 보존
- branch-note 수정 또는 canonical 문서 생성
## Coverage Gaps
- Files marked NOT_READ or BLOCKED: none
- Whole-corpus conclusions: INCOMPLETE — 아래 unseen owner/decision을 교차 lane에서 확인해야 한다.
## Grep Verification
각 finding 안에 25개 quote pattern의 절대경로 명령과 observed output을 기록했다. 대표 확인:
```bash
grep -nFo -- 'core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립
grep -nFo -- '| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 280:| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장
```
- 검증한 verbatim quote 총 개수 V: 25
- 일치 P: 25
- 라인 정정 C: 0
- 폐기 D: 0
## Claim Traceability Check
- Claim ID 연결 검사: slice에서 추출한 unique Claim ID 153개 중 152개가 branch/project note 밖의 raw source에서 lexical resolve. `KC-HOST-C6` 1개는 iss-claim-hostname-mismatch:142·230·247이 명시한 미아카이브 후보이며 Supporting Claim으로 사용되지 않아 `BROKEN_CLAIM_REFERENCE`는 none found.
- Decision Evidence Map: 10/10 파일에서 1개씩, 총 10개 검토. 이번 lane은 raw Claim ID 존재성까지 확인했으며 각 raw claim의 의미 재감사는 범위 밖이다.
- UNSUPPORTED_DECISION: 정합 finding과 직접 연결된 3개 축 — CVE version gate(H:338), JavaScript origins 비움(C:73·167), P2A brokering scope/zero-change(I:282). 그 밖의 project-scope·implementation caveat는 파일이 명시적으로 라벨링해 상충 finding으로 승격하지 않았다.
## Cross-Lane Audit Candidates
| File:line | Referenced owner / decision | Reason |
| --- | --- | --- |
| `feature-keycloak-google-redirect-uri-policy.md:202-205` | single-EC2-google-federation D4/D6; public-domain-tunneling D1/D2 | callback host/path와 tunnel 선택 owner가 unseen |
| `feature-keycloak-header-spoofing-defense.md:202-206` | edge-forwardauth-no-google D2/D4; nginx-auth-request; reverse-proxy-headers | 부모 enforcement 위임과 신규 `X-Internal-Auth-Token` 계약 확인 필요 |
| `feature-keycloak-https-termination-caddy-nginx.md:232-233` | reverse-proxy-headers D1; public-domain-tunneling | TLS 종단 위치와 forwarded-header mode 결합 검증 필요 |
| `feature-keycloak-idp-brokering-google-client.md:189,192` | account-linking-sub-vs-email; edge-forwardauth-google-federation | `trustEmail` 소비 의미와 `<kc-host>` owner 확인 필요 |
| `feature-keycloak-internal-spa-direct-google-federation.md:403` | 미지정 CSRF/`state` owner | 파일이 project-wide owner 부재를 주장하므로 다른 lane 확인 필요 |
| `feature-keycloak-internal-spa-direct-google-federation.md:404` | docker-compose-stack / realm configuration | H D10의 SMTP deployment-fact 소유권이 실제 stack owner와 겹치는지 확인 필요 |
| `feature-keycloak-internal-spa-direct-google-federation.md:407-408` | account-linking-sub-vs-email D1; three-leg-trust-chain D1/D2/D5 | L2-F07의 foreign-detail summary가 실제 owner와 같은지 확인 필요 |
| `feature-keycloak-internal-spa-direct-no-google.md:365-368` | audience-validator; token-storage; refresh-rotation; PKCE-flow | Hubs의 foreign D-row 요약과 L2-F09 token-storage owner 확인 필요 |
| `feature-keycloak-iss-claim-hostname-mismatch.md:204-209` | single-EC2-no-google D3; audience-validator D6; docker-compose D3/D5 | 파일이 이미 owner conflict·trigger erosion·3자 재진술을 명시함 |
## Stats
```wiki-stats
agent: wiki-research-lane
found: 10
processed: 10
dropped: 0
```
@@ -0,0 +1,386 @@
# Lane 3 — Keycloak Branch-Note Consistency Audit
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
| --- | --- | --- | --- |
| `raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md` | READ_FULL | 1306 | `auth_request` body handling, browser/API failure routing, upstream ownership edges |
| `raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md` | READ_FULL | 1275 | bearer-token terminology, nginx decision references, discovery startup dependency |
| `raw/branch-notes/feature-keycloak-patterns.md` | READ_FULL | 1205 | retired numbered naming versus retained D2 |
| `raw/branch-notes/feature-keycloak-pkce-flow-stages.md` | READ_FULL | 1167 | PKCE S256 ownership and Admin Console terminology |
| `raw/branch-notes/feature-keycloak-public-domain-tunneling.md` | READ_FULL | 1220 | quick versus named Cloudflare Tunnel domains and tunnel-choice ownership |
| `raw/branch-notes/feature-keycloak-realm-client-export.md` | READ_FULL | 1238 | import wiring ownership, credential export assertions, PKCE terminology |
| `raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md` | READ_FULL | 1254 | rotation demonstration, Max Reuse semantics, back-channel logout scope |
| `raw/branch-notes/feature-keycloak-refresh-token-rotation.md` | READ_FULL | 1284 | rotation contract, family invalidation, receiving endpoint delegation |
| `raw/branch-notes/feature-keycloak-reverse-proxy-headers.md` | READ_FULL | 1297 | Caddy path behavior, Keycloak proxy variables, delegated security decisions |
| `raw/branch-notes/feature-keycloak-single-ec2-google-federation.md` | READ_FULL | 1370 | parent integration decisions and delegated tunnel/proxy details |
## 1. `feature-keycloak-nginx-auth-request-integration.md`
### L3-F01: `auth_request` body behavior contradicts its configuration table
- **Severity / class:** High — `CONTRADICTION`
- **Original goal / current state:** line 101 says the body is not automatically copied; line 163 says nginx defaults to forwarding it.
- **Claim:** Both statements cannot govern the same configuration.
- **Assumption:** `proxy_pass_request_body off` can be omitted in the rendered config. This is falsified if every applicable location explicitly contains it.
- **Failure / falsification:** Request bodies reach the auth upstream, causing unwanted disclosure, buffering, or size failures. Falsify with `nginx -T` and upstream request capture.
- **Action / why:** Rewrite line 101 to say body forwarding must be disabled explicitly, and make the table canonical.
- **Alternatives:** (1) explicit `off`, (2) intentional bounded forwarding with documented limits, (3) a separate body-less auth endpoint.
- **Counterargument:** oauth2-proxy may ignore the body. That does not establish that nginx did not transmit it.
```text
$ sed -n '101p;163p' 'raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md'
- nginx `auth_request`는 subrequest이므로 **POST body가 자동 복제되지 않는다**. `/oauth2/auth`는 HEAD-like check이므로 body 불필요 → `proxy_pass_request_body off` 권장.
| `proxy_pass_request_body` | `off` | `O2PN-C8`, `NGAR-C8`. **생략하면 default `on`(`NGXPM-C1`) 이라 body 가 전달됨** |
```
### L3-F02: unconditional sign-in TODO is stale after browser/API response split
- **Severity / class:** Medium — `STALE_SUMMARY`
- **Original goal / current state:** line 89 gives one 302 sign-in task; lines 193194 later divide browser 302 from API 401.
- **Claim:** The implementation checklist can be read as applying the redirect to every protected location.
- **Assumption:** Implementers use the TODO before the later D9 table. Falsified if generated config and tests always preserve the location split.
- **Failure / falsification:** API clients receive HTML redirects, parsing failures, or loops. Verify both browser and `/api/` requests.
- **Action / why:** Restrict the TODO to browser locations and add an explicit API 401 task.
- **Alternatives:** (1) location split, (2) 401 everywhere with frontend login handling, (3) redirect allowlist, (4) content-negotiated behavior.
- **Counterargument:** The TODO may describe only creation of the named location. Its application scope nevertheless remains unstated there.
```text
$ sed -n '89p;193p;194p' 'raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md'
- [ ] named location `@oauth2_signin` 작성: `return 302 https://$host/oauth2/start?rd=$scheme://$host$request_uri;` — 등급: `planned`
| browser-facing (`location /`) | `auth_request /oauth2/auth;` | `error_page 401 = @oauth2_signin;` → 302 | D2, `O2PN-C5` |
| API/machine (`location /api/`) | `auth_request /oauth2/auth;` | `error_page 401 =401;` → **plain 401 pass-through** | D9, `O2PN-C9` |
```
## 2. `feature-keycloak-oauth2-proxy-oidc-flow.md`
### L3-F03: locally validated JWT behavior remains labeled “token introspection”
- **Severity / class:** Low — `STALE_SUMMARY`, `NAMING_DRIFT`
- **Original goal / current state:** the TODO calls the flags an introspection mode, while its own audit says no introspection endpoint call is documented.
- **Claim:** The term implies a network protocol behavior not supported by the notes evidence.
- **Assumption:** Readers use the label to infer latency, revocation, and firewall dependencies. Falsified by an observed outbound introspection request.
- **Failure / falsification:** Architecture is designed around an endpoint call that may not happen. Verify debug logs and outbound traffic.
- **Action / why:** Use neutral “bearer-token mode” or the verified local-validation/bypass behavior.
- **Alternatives:** (1) local JWT validation, (2) bearer-token bypass, (3) separately implement RFC 7662 introspection, (4) retain a neutral label pending measurement.
- **Counterargument:** “Introspection” might be informal internal vocabulary, but it conflicts with the protocols established meaning.
```text
$ sed -n '85p;101p' 'raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md'
- [ ] token introspection 모드 (`--skip-jwt-bearer-tokens`, `--extra-jwt-issuers`)의 의미 정리 — 등급: `planned`
- **2026-07-17** (drift): 본 노트가 쓰는 **"token introspection 모드" 명칭이 부정확할 가능성**을 확인 — 공식 문서는 introspection endpoint 호출을 서술하지 않음. 사용자 작성 본문이라 자동 수정하지 않고 §Audit & Findings `NAMING_DRIFT` 로 남김. rename 여부는 사용자 판단.
```
### L3-F04: three decision references are not machine-resolvable
- **Severity / class:** Low — `BARE_DECISION_REF`, ambiguous ownership
- **Original goal / current state:** all three passages intend to identify an owner decision but use bare slugs or quoted prose instead of the repositorys decision-reference syntax.
- **Claim:** Impact analysis cannot reliably bind these references to their owners.
- **Assumption:** Deterministic consistency tooling is part of change propagation. Falsified only if the tool and contract are changed to recognize these forms.
- **Failure / falsification:** Owner changes do not surface in consumer impact reports. Re-run the consistency checker after repair.
- **Action / why:** Replace each with `[[raw/branch-notes/<slug>]] D<n>`.
- **Alternatives:** N/A — reference syntax is fixed by `rules/consistency-contract.md`.
- **Counterargument:** A person can understand the references, but that does not make them machine-traceable.
```text
$ sed -n '214p;228p' 'raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md'
| `SESSION_STORAGE_4KB_ABSENT` | session storage 공식 페이지에 `4k`/`4096`/`split` 문자열이 **0건** — 4kb cookie split 함정의 근거는 이 페이지가 아니라 **nginx 통합 페이지**([[raw/official-docs/oauth2-proxy-nginx-integration-official]], "Nginx normally only copies the first `Set-Cookie` header ... if your cookies are larger than 4kb, you will need to extract additional cookies manually")에 있다. | `raw/official-docs/oauth2-proxy-session-storage-official.md` §Usage Boundaries | D5a 의 Open Risk 에 반영 완료. 4kb 대응의 owner 는 형제 branch `feature-keycloak-nginx-auth-request-integration` `D5`. |
| **인가 거부 시 실제 응답 코드/본문** (401 vs 403 vs 로그인 루프) | D4 Open Risk 가 자인 — `O2PK-C3` 은 인가 실패 코드의 의미를 명시 안 하고, `O2PBEH-C2`~`C4` 는 authentication 단계 전용. 형제 branch `feature-keycloak-nginx-auth-request-integration` `D2` 의 `error_page` 분기가 이 값에 의존 | 허용 role 이 **없는** 사용자로 로그인 후 보호 경로 요청 → 응답 코드/본문 확인. `/oauth2/auth` subrequest 응답도 함께 확인(202/401 만 반환하는지 — `O2PE-C5` 와 대조) | `needs-confirmation` |
```
```text
$ sed -n '182p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] `#D3` — `KC_HOSTNAME=localhost` + issuer-uri + network 계약 owner. 이 hostname 계약이 D2 의 redirect URI 값(`http://localhost/*`, `http://127.0.0.1/*`)의 전제. parent `#D3` 이 hostname/포트를 바꾸면 본 realm-export.json 의 redirect URI(D2) 를 동기화해야 함. (sibling `feature-keycloak-docker-compose-stack` 도 동일 의존을 "parent D3" 로 인용). 아울러 본 branch 의 client scope(realm 1개 + `spa-client` public + PKCE S256) 는 parent `#D5` 의 실 구현.
```
## 3. `feature-keycloak-patterns.md`
### L3-F05: retired numbered naming remains an active D2 decision
- **Severity / class:** Medium — `CONTRADICTION`, `STALE_SUMMARY`
- **Original goal / current state:** line 60 retires numbered naming; line 134 still presents it as D2.
- **Claim:** A prohibited convention remains encoded as an active-looking decision.
- **Assumption:** D-rows denote current decisions. Falsified if D2 is structurally marked historical and excluded from owner resolution.
- **Failure / falsification:** New branches or consumers adopt the retired numbered hierarchy. Search inbound D2 references.
- **Action / why:** Move D2 to a retired-decision section or apply an explicit retired status.
- **Alternatives:** N/A — numbered branch hierarchy is prohibited by repository policy.
- **Counterargument:** The warning at line 60 is visible, but downstream D-reference semantics remain ambiguous.
- **Single-finding justification:** Other content is legacy-to-AP mapping; no second lane-local decision conflict was found.
```text
$ sed -n '60p;134p' 'raw/branch-notes/feature-keycloak-patterns.md'
> **⚠️ 갱신 (2026-07-14)**: 아래 6패턴 인덱스는 **Phase 0 legacy(배치×federation 축)**. 현 실행계획 SSOT 는 [[raw/project-notes/keycloak-patterns-overview]] 의 **§Branch 분해 / 실행계획(R4)** — 인증 아키텍처 4패턴(AP1~AP4) + 19 Tier-2. 신규 작업은 hub 분해표를 따르며, 아래 슬러그는 hub §2.3 매핑대로 AP 로 re-map 대상. D2(`-{N}-{M}` numbered 명명)는 CLAUDE.md §11 위반으로 폐기(각 sub-sub 는 이미 content-descriptive 슬러그라 실제 영향은 프레이밍뿐).
- **D2** 2026-05-25: sub-sub-branch 명명은 `-{N}-{M}` dash-숫자 형식. M은 학습/구현 단계 순서.
```
## 4. `feature-keycloak-pkce-flow-stages.md`
### L3-F06: D1 and D5 both own S256 enforcement
- **Severity / class:** Medium — `DUAL_OWNERSHIP`
- **Original goal / current state:** D1 and D5 both encode the S256 formula and Keycloak enforcement, with different evidence freshness.
- **Claim:** Two decisions own the same mutable contract.
- **Assumption:** Downstream notes treat a D-number as owner identity. Falsified if D5 is structurally non-normative.
- **Failure / falsification:** UI-label or rejection-semantics updates reach only one row. Compare inbound D1/D5 references.
- **Action / why:** Keep D1 canonical and turn D5 into a reference-only TODO mapping.
- **Alternatives:** (1) D1 owner/D5 pointer, (2) D5 owner/D1 scope summary, (3) merge rows, (4) split formula from vendor enforcement.
- **Counterargument:** D5 may exist only to map TODO step 2, but its current table form still looks normative.
```text
$ sed -n '118p;122p' 'raw/branch-notes/feature-keycloak-pkce-flow-stages.md'
| D1 | PKCE method = `S256` 만 정리 대상 (`plain` 은 비교용 1줄). Keycloak client 설정에서 PKCE method 옵션(정식 UI 라벨 "PKCE method")을 S256 으로 지정 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3` (S256 공식: `BASE64URL-ENCODE(SHA256(ASCII(verifier)))`), `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1` (OAuth 2.1: "Clients MUST use code_challenge and code_verifier ..."), `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C1` (Admin UI 옵션 정식 명칭·위치), `#KC-PKCE-C3` (S256 선택 시 서술) | `official-standard + official-standard + official-vendor-doc` | OA21-C1 은 PKCE 사용 자체를 MUST 로 강제하지만 "S256 강제 / plain 금지" 라는 정확한 문장은 OA21-C1 인용에 포함 안 됨 — §7.5.1 예외 조건 확인 필요. 단, RFC 7636 + OAuth 2.1 종합 권고로 보면 정당. **2026-07-17 업데이트**: `KC-PKCE-C1` 이 UI 라벨 오류를 정정("Proof Key for Code Exchange Code Challenge Method" 가 아니라 "PKCE method", Capability Config 섹션)했으나, `KC-PKCE-C3` 은 "Keycloak applies... S256" 이라고만 서술 — **S256 설정 시 `code_challenge_method=plain` 요청을 실제로 거부(reject)한다는 명시적 문장은 여전히 없음**. 아래 Claims To Verify 의 "plain 메서드 요청을 거부" 항목은 `needs-confirmation` 유지 |
| D5 (TODO 표 step 2) | S256 공식 `code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` + Keycloak client 에 S256 강제 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3` (S256 공식), `raw/official-docs/keycloak-securing-apps-overview-official.md#KC-SECAPP-C1` (Keycloak 이 OIDC/OAuth 2.0 stack 을 secure 가능) | `official-standard` + `official-vendor-doc` (Keycloak overview) | Keycloak client 의 "Proof Key for Code Exchange Code Challenge Method = S256" 정확한 admin UI 위치는 KC-SECAPP-C1 인용 범위 밖 — Keycloak Securing Applications 의 client adapter / settings 섹션 별도 인용 필요 |
```
### L3-F07: deprecated PKCE Admin label survives in two TODOs
- **Severity / class:** Low — `STALE_SUMMARY`
- **Original goal / current state:** two implementation tasks use the old label; the audit identifies `PKCE method` as the actual label.
- **Claim:** The correction has not reached the operational checklist.
- **Assumption:** The pinned Keycloak version uses the audited label. Falsified by the target Admin Console displaying the older text.
- **Failure / falsification:** Implementers cannot find or select the intended field. Inspect the target-version UI.
- **Action / why:** Update both TODOs to `PKCE method = S256`; preserve the old label only as version history.
- **Alternatives:** N/A — use the label observed in the pinned target version.
- **Counterargument:** Labels can vary by Keycloak version or locale; record that variant explicitly if confirmed.
```text
$ sed -n '73p' 'raw/branch-notes/feature-keycloak-pkce-flow-stages.md'
- Keycloak client 설정: `Proof Key for Code Exchange Code Challenge Method = S256` 강제
```
```text
$ sed -n '81p;202p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- [ ] Client Advanced 탭: `Proof Key for Code Exchange Code Challenge Method = S256` — 등급: `planned`
- **`NAMING_DRIFT`** (Advisory, user prose 보존) — §목표·§TODO 의 라벨 "Proof Key for Code Exchange Code Challenge Method" 는 `KC-PKCE-C1` 기준 부정확. 실제 Admin Console 옵션명은 **"PKCE method"** (Capability Config 섹션 하위). 구현 시 이 라벨로 찾을 것. 사용자 작성 prose 는 verbatim 보존, 정정은 §구현 가이드 1 에 반영.
```
## 5. `feature-keycloak-public-domain-tunneling.md`
### L3-F08: `trycloudflare.com` is described as both permanent and random
- **Severity / class:** High — `CONTRADICTION`, `STALE_SUMMARY`
- **Original goal / current state:** line 69 offers a permanent `<sub>.trycloudflare.com`; line 77 says quick-tunnel URLs are random, while the parent repeats the static-looking form.
- **Claim:** Quick-tunnel hostnames and named-tunnel managed DNS are conflated.
- **Assumption:** Google requires a stable registered redirect and the described quick-tunnel behavior applies. Falsified by an account providing a persistent named `trycloudflare.com` hostname.
- **Failure / falsification:** Restarting the tunnel invalidates the Google redirect URI. Create/restart the tunnel and compare hostnames.
- **Action / why:** Distinguish random quick tunnels from named tunnels backed by managed DNS; synchronize the parent summary.
- **Alternatives:** (1) random URL plus console updates, (2) named tunnel plus managed domain, (3) ngrok reserved domain, (4) another stable public endpoint.
- **Counterargument:** Product capabilities may have changed; verify against the exact account and client version.
```text
$ sed -n '69p;77p' 'raw/branch-notes/feature-keycloak-public-domain-tunneling.md'
- [ ] **Cloudflare Tunnel 동작 확인** — `cloudflared tunnel create <name>` + `cloudflared tunnel route dns <name> <subdomain>` → 영구 도메인 (`<sub>.trycloudflare.com` 무료 또는 자체 도메인 CNAME) — 등급: `planned`
- Cloudflare Tunnel의 `trycloudflare.com` quick tunnel은 무료지만 URL이 random (ngrok와 유사). 정적 도메인 원하면 Cloudflare account + 자체 도메인 (Cloudflare DNS로 위임) + named tunnel 필요.
```
```text
$ sed -n '356p' 'raw/branch-notes/feature-keycloak-single-ec2-google-federation.md'
- **비교 핵심**: P3A 대비 **추가 운영 비용은 "public 접근 가능한 callback URL"** 1가지. Google OAuth client console에 등록할 redirect_uri는 **HTTPS 필수 + localhost 제외** (Google 명시 — `http://localhost` 만 예외, IP 주소 X). 학습 환경에서는 **ngrok** (`https://<random>.ngrok-free.app` 임시 URL) 또는 **Cloudflare Tunnel** (`<name>.trycloudflare.com` 또는 자체 도메인) 사용. ngrok은 무료 plan에서 URL이 매 세션 변경 → Google client console 매번 업데이트 필요 → Cloudflare Tunnel이 학습 단계에 더 적합 (정적 도메인). **Keycloak `KC_PROXY_HEADERS=xforwarded` + `KC_HOSTNAME=<public-domain>`** 명시 안 하면 reverse proxy 뒤에서 redirect URL이 internal `keycloak:8080`으로 떨어져 깨짐.
```
This file is also the child-side source for `L3-F15`.
## 6. `feature-keycloak-realm-client-export.md`
### L3-F09: import wiring remains in scope after ownership was delegated
- **Severity / class:** Medium — `STALE_SUMMARY`, `RESTATED_FOREIGN_DECISION`
- **Original goal / current state:** line 53 includes automatic container import; line 168 says this branch authors JSON only and must not restate wiring.
- **Claim:** Scope and ownership trace assign the same concern differently.
- **Assumption:** Scope bullets denote implementation responsibility. Falsified if line 53 is explicitly reclassified as a consumer assumption.
- **Failure / falsification:** Realm and stack branches independently change mount paths or startup flags. Inspect actual changed artifacts.
- **Action / why:** Keep JSON authorship here and make import wiring reference-only to the stack owner.
- **Alternatives:** (1) JSON/stack split, (2) this branch owns full import lifecycle, (3) dedicated import-lifecycle owner.
- **Counterargument:** Line 53 might describe desired behavior, not ownership; its current placement does not state that distinction.
```text
$ sed -n '53p;168p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- Keycloak 컨테이너 `--import-realm` 옵션으로 부트 시 자동 import
> **Trace / R3**: volume mount(`./realm-export.json:/opt/keycloak/data/import/realm-export.json`) + Keycloak 부트 command `--import-realm` 는 sibling [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `#D4` 가 owner (`KC-IMPORT-C1/C2/C3/C4` 인용). 본 branch 는 **realm-export.json 저작만** — 배선은 restate 하지 않는다. 배선 계약이 바뀌면(import dir 경로/flag) 본 export 파일 배치에 영향(§엣지·의존).
```
### L3-F10: credential export is asserted before its evidence gate
- **Severity / class:** High — `CONTRADICTION`, unsupported certainty
- **Original goal / current state:** line 99 states credential behavior; line 193 says the password-inclusive `usersExport` claim has no primary raw source.
- **Claim:** Operational prose presents an unverified export mode as available.
- **Assumption:** Bootstrap automation may rely on exported users or passwords. Falsified by version-matched documentation and inspected export output.
- **Failure / falsification:** Imported users cannot authenticate, or sensitive credentials are handled under a false assumption.
- **Action / why:** Move credential claims to verification status until the pinned images help and actual export are inspected.
- **Alternatives:** (1) no users and separate bootstrap, (2) verified user export with secret controls, (3) Admin API provisioning, (4) disposable test credentials.
- **Counterargument:** The behavior may be remembered from another Keycloak version; that does not establish the pinned version.
```text
$ sed -n '99p;193p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- Realm export JSON은 **user password를 포함하지 않음** (보안). user는 ID/role만 export됨 → 부트 후 admin이 password 재설정 필요. 또는 realm export에서 `usersExport=true`로 별도 export 가능.
| `usersExport=true` 옵션으로 user (password 포함) export 가능 | 본 branch 진행 중 메모 — 1차 raw 미수집 | Keycloak `kc.sh export --help` 또는 admin REST API 문서 발췌 | `planned` |
```
This file is also implicated in `L3-F04` and `L3-F07`.
## 7. `feature-keycloak-refresh-rotation-and-logout.md`
### L3-F11: concept and execution notes both own the same rotation sequence
- **Severity / class:** High — `DUAL_OWNERSHIP`, `RESTATED_FOREIGN_DECISION`
- **Original goal / current state:** the concept note declares a concept/execution split, but both notes retain a normative D-row for the same family-invalidation flow.
- **Claim:** Mutable expected behavior has two decision owners.
- **Assumption:** D-rows are normative. Falsified if P3A D5 is structurally converted into a non-normative test procedure.
- **Failure / falsification:** A failed family-kill test updates only one note. Inspect incoming D4/D5 references and modification ownership.
- **Action / why:** Keep policy/expected behavior in the concept owner and make P3A reference it from its test procedure.
- **Alternatives:** (1) concept owner/execution pointer, (2) execution owner/concept principle, (3) explicit invariant/procedure split, (4) separate verified-behavior owner.
- **Counterargument:** Definition and demonstration are naturally repeated, but both current rows make the repeated value independently mutable.
```text
$ sed -n '159p;240p' 'raw/branch-notes/feature-keycloak-refresh-token-rotation.md'
| D4 | rotation flow 4단계 (RT_1 사용 → invalidate → AT_2+RT_2 발급 → 재사용 시 family invalidate) | N/A — rotation ON 이면 항상 이 flow. 단 재사용 시 무효화 **범위**(단일 토큰 vs family 전체)는 verify 대상 | `raw/official-docs/keycloak-refresh-token-rotation-sessions-official.md#KC-RTROT-C2` (RT 1회 사용 후 무효 + 최신 RT 저장 강제 = flow 1~3단계 근거) + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (refresh token = scope/RS bound MUST, 배경) + `UNSUPPORTED_DECISION` (4단계 "family 전체 invalidate" 는 `KC-RTROT-C6` doc text 부재) | `official-vendor-doc (1~3단계) + official-standard (배경) + UNSUPPORTED_DECISION (family invalidate)` | family invalidate 동작을 docker-compose 실측(RT_1 재사용 → RT_2 도 거부되는지)으로 확인 — Claims To Verify 에 계획됨 |
- cousin(다른 phase) [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] (P3A) — 본 개념 계약의 **실측·시연** 소유. 본 노트 = 개념/계약, 그쪽 = 실행.
```
```text
$ sed -n '124p' 'raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md'
| D5 | rotation flow 시연 (RT_1 사용 → AT_2+RT_2 → RT_1 재사용 → family 전체 invalidate → 재로그인 강제) | N/A (rotation ON 전제의 단일 시연 시나리오). rotation OFF 면 재사용 탐지 자체가 불가 → D1 이 전제 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1` (rotation = 사용된 RT 무효화 + 새 발급) + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3`·`#OA21-C6` (RT bound MUST, code grant → AT+RT) + `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C4`·`#OIDCTS-C5` (Refresh Token Grant + Silent Refresh — SPA 자동 갱신 경로) + `UNSUPPORTED_DECISION` ("RT_1 재사용 → family 전체 invalidate" 자동 동작 = `KC-ROT-C6` 문서 부재) | `official-vendor-doc (KC-ROT-C1, OIDCTS-C4/C5) + official-standard (OA21-C3/C6) + UNSUPPORTED_DECISION (family invalidate 구체 동작)` | docker-compose 실측: RT 재사용 시 4xx + RT_2 동시 invalidate(family kill) 확인 (Claims To Verify #1) |
```
### L3-F12: delegated back-channel receiver is out of scope at its destination
- **Severity / class:** High — `CONTRADICTION`, ambiguous ownership
- **Original goal / current state:** the concept note delegates receiver implementation to P3A; P3A lists provider-triggered logout as an optional demonstration only.
- **Claim:** No note unambiguously owns the receiving endpoint.
- **Assumption:** “옵션 시연만” excludes backend implementation. Falsified by an explicit endpoint artifact and validation plan in P3A.
- **Failure / falsification:** Logout Token reception is relied upon but never built or verified.
- **Action / why:** Add the endpoint to P3A, remove the delegation, or create a dedicated owner.
- **Alternatives:** (1) implement in P3A, (2) remove from scope, (3) dedicated branch, (4) provider-side demonstration with explicit absence of receiver.
- **Counterargument:** The optional demonstration might include the endpoint; planned files and acceptance tests must show that.
```text
$ sed -n '67p' 'raw/branch-notes/feature-keycloak-refresh-token-rotation.md'
- **back-channel logout 수신** backend 구현(Logout Token 수신 endpoint) — D3, P3A 실 구현 시
```
```text
$ sed -n '61p' 'raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md'
- back-channel logout (Keycloak admin → 모든 session kill) — 옵션 시연만
```
### L3-F13: Max Reuse greater than zero is both meaningless and a weaker alternative
- **Severity / class:** High — `CONTRADICTION`, incompatible policy assumptions
- **Original goal / current state:** one note says any value above zero makes reuse detection meaningless; the other treats one or more as weaker but viable.
- **Claim:** The policy models the setting with incompatible semantics.
- **Assumption:** Max Reuse is a bounded-use counter in the pinned version. If the field differs or is absent, both claims need replacement.
- **Failure / falsification:** Concurrency mitigation either disables the intended test or rejects legitimate refreshes. Test values 0 and 1 with concurrent requests.
- **Action / why:** Replace certainty with one measured semantics table shared by both notes.
- **Alternatives:** (1) zero plus refresh serialization, (2) measured bounded reuse, (3) BFF/server-side serialization, (4) defer selection until measurement.
- **Counterargument:** “의미 없음” may mean only “unsuitable for D5”; the note does not currently state that narrower scope.
```text
$ sed -n '127p' 'raw/branch-notes/feature-keycloak-refresh-token-rotation.md'
- `Refresh Token Max Reuse > 0`이면 reuse detection 의미 없음
```
```text
$ sed -n '120p' 'raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md'
| D1 | rotation ON + Max Reuse 0 강제 — stolen token 탐지 시연 | stolen-token 탐지를 *시연*하려면 rotation ON + Max Reuse 0. 정상 SPA 가 동시 갱신(race)으로 같은 RT 를 2회 보낼 위험이 크면 대안: Max Reuse ≥1 로 완화(단 탐지력 약화, D5 시연 불가) | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1` ("Revoke Refresh Token" ON = 사용된 RT revoke + 새 토큰 발급 = rotation) + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (RT scope/RS bound MUST — rotation 권고 배경) + `UNSUPPORTED_DECISION` ("Refresh Token Max Reuse" 설정명 + 재사용 시 family invalidate 자동 동작 = `KC-ROT-C6` 문서 부재) | `official-vendor-doc (KC-ROT-C1) + official-standard (OA21-C3 배경) + UNSUPPORTED_DECISION (Max Reuse 0 라벨 + family invalidate)` | admin UI 실측으로 "Refresh Token Max Reuse" 필드 실재·라벨 + RT 재사용 시 family kill 동작 확인 (Claims To Verify #1) |
```
## 8. `feature-keycloak-refresh-token-rotation.md`
This file is the co-source for `L3-F11`, `L3-F12`, and `L3-F13`. No additional lane-local inconsistency remained after those shared findings; its remaining related D-rows retain explicit `UNSUPPORTED_DECISION` labels rather than asserting family invalidation as verified.
## 9. `feature-keycloak-reverse-proxy-headers.md`
### L3-F14: executable Caddy draft strips a prefix the selected method requires
- **Severity / class:** High — `STALE_SUMMARY`, cannot-coexist configuration
- **Original goal / current state:** the copyable draft uses `handle_path`; its later audit says method B requires prefix-preserving `handle`/`reverse_proxy`.
- **Claim:** The executable draft and `KC_HTTP_RELATIVE_PATH=/keycloak` cannot coexist without another rewrite.
- **Assumption:** Standard `handle_path` stripping applies and Keycloak runs at `/keycloak`. Falsified by a verified rewrite or root-path Keycloak configuration.
- **Failure / falsification:** Discovery and authorization endpoints reach the wrong path and return 404. Test all OIDC endpoints through Caddy.
- **Action / why:** Repair the snippet itself or explicitly switch the design to method A.
- **Alternatives:** (1) prefix-preserving `handle`, (2) method A with deliberate stripping, (3) explicit verified rewrite, (4) nginx prefix preservation.
- **Counterargument:** Another layer may restore the prefix; no such layer is shown in the draft.
```text
$ sed -n '125p;202p' 'raw/branch-notes/feature-keycloak-reverse-proxy-headers.md'
handle_path /keycloak/* {
> ⚠️ **method B ↔ proxy 라우팅 정합 함정 (depth 감사 2026-07-18 F1)**: §진행 중 메모의 **Caddy 초안 `handle_path /keycloak/*`** 은 `/keycloak` prefix 를 **strip** 한다 (= method A 동작). 그런데 D3 은 method B(`KC_HTTP_RELATIVE_PATH=/keycloak`, Keycloak 이 스스로 `/keycloak/*` 발급)를 채택했으므로, prefix 가 strip 된 요청이 prefix 를 기대하는 Keycloak 에 도달 → **이중 strip 으로 404**. 초안의 "Caddy 환경변수는 nginx case 와 동일" 은 *환경변수*는 맞으나 *라우팅*은 다름을 가린다. nginx `location /keycloak/ { proxy_pass http://127.0.0.1:8080; }` 는 prefix 를 **보존**하므로 method B 와 정합. **method B 를 Caddy 로 쓰려면 `handle_path`(strip) 대신 `handle`/`reverse_proxy` 로 `/keycloak` prefix 를 보존**해야 한다. Caddy vendor doc 미보존(D4 UNSUPPORTED)이라 FACT 단정 대신 §Claims To Verify 로 실측 위임.
```
### L3-F16: parent owns exact proxy values while delegating proxy details
- **Severity / class:** High — `DUAL_OWNERSHIP`, `RESTATED_FOREIGN_DECISION`
- **Original goal / current state:** parent D4 fixes five exact variables; its ownership summary delegates proxy-header detail to the child.
- **Claim:** The parent contains a mutable configuration bundle that overlaps delegated owners.
- **Assumption:** Parent D4 is consumed as normative configuration. Falsified if it is generated from one canonical source and children cease owning values.
- **Failure / falsification:** A routing or security owner changes a value while the parent remains deployable and stale.
- **Action / why:** Keep integration invariants in the parent and reference each variables owner decision.
- **Alternatives:** (1) child owners plus parent references, (2) parent owns final bundle and children provide evidence, (3) explicit per-variable ownership matrix.
- **Counterargument:** An integration parent may need the assembled bundle, but then it must be the declared canonical owner rather than also delegating details.
```text
$ sed -n '239p;301p' 'raw/branch-notes/feature-keycloak-single-ec2-google-federation.md'
| D4 | Keycloak `KC_HOSTNAME=https://kc.example.com` + `KC_HTTP_RELATIVE_PATH=/keycloak` + `KC_PROXY_HEADERS=xforwarded` + `KC_HTTP_ENABLED=true` + `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1` | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C5`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C2`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C3`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C4`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C5`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C6` | `official-vendor-doc` | `KC_HTTP_RELATIVE_PATH=/keycloak` 와 `X-Forwarded-Prefix` 가 nginx → Keycloak 경로에서 OIDC discovery (`.well-known/openid-configuration`) 의 `issuer` 값을 어떻게 결정하는지 실 검증 필요 (`KC-RP-C6` 두 방법의 trade-off 미해소) |
- sub-sub-branch 관심사 위임: [[raw/branch-notes/feature-keycloak-public-domain-tunneling]](tunnel 상세) · [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]](proxy header 상세) · [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]](redirect URI 정책) · [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]](TLS termination) 가 각 관심사 detail owner.
```
## 10. `feature-keycloak-single-ec2-google-federation.md`
### L3-F15: parent and child both decide Cloudflare-over-ngrok
- **Severity / class:** Medium — `DUAL_OWNERSHIP`
- **Original goal / current state:** parent D3 and child D1 both own the same provider preference, while the child supplies the selection conditions.
- **Claim:** Tunnel-selection authority is duplicated.
- **Assumption:** Both D-rows are normative and independently mutable. Falsified by a declared selection/detail split without restating the mutable preference.
- **Failure / falsification:** A provider-policy change updates only one branch. Inspect inbound D3/D1 references and approval ownership.
- **Action / why:** Make the child the selection owner and reduce the parent to required outcomes plus a decision reference.
- **Alternatives:** (1) child owns selection, (2) parent owns selection and child operationalizes, (3) parent owns requirements while child maps conditions to providers.
- **Counterargument:** Parent selection plus child implementation can be valid, but both current rows phrase the provider preference as their own decision.
```text
$ sed -n '238p' 'raw/branch-notes/feature-keycloak-single-ec2-google-federation.md'
| D3 | 개발용 public URL 노출 수단은 Cloudflare Tunnel 우선, ngrok 차선 | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1`, `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2`, `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C4`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C1`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C3`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C4`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C5` | `official-vendor-doc + official-vendor-doc + official-vendor-doc` | Cloudflare Tunnel `<UUID>.cfargotunnel.com` 도메인이 Google OAuth client redirect URI 정책 (`GOOGLE-REDIR-C2` raw IP 금지 / `C3` exact match) 을 통과하는지 직접 검증 안 됨 |
```
```text
$ sed -n '106p' 'raw/branch-notes/feature-keycloak-public-domain-tunneling.md'
| D1 | 학습 환경 1순위 **Cloudflare Tunnel** (named tunnel + 정적 URL + 무료 TLS + inbound port 0) | 정적 public URL + inbound port 0 + 무료 TLS 가 필요하고 Cloudflare 에 위임한 관리 도메인 1개를 확보할 수 있을 때. 도메인이 없거나 1회성 데모면 → **D2**(ngrok) 또는 `trycloudflare.com` quick tunnel(random URL) | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1` (cloudflared outbound), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2` (firewall inbound 차단 권장), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C3` (tunnel `<UUID>.cfargotunnel.com` subdomain 자동 부여), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C4` (사용자 hostname CNAME → cfargotunnel.com), `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C2` (Google redirect URI raw IP 금지 → 도메인 필요) | `official-vendor-doc + official-vendor-doc` | Cloudflare Tunnel 의 `<UUID>.cfargotunnel.com` generic subdomain 이 Google Cloud Console redirect URI 정책에 통과하는지는 `CLOUDFLARE-TUNNEL-C4` "Does not prove" 단서에 명시된 미검증 사항 — P3B 실 검증 필요 |
```
This parent is also implicated in `L3-F08` and `L3-F16`.
## Grep verification
- 실제 실행해 위에 출력한 `sed`/`grep` source lines V: 37
- byte-exact 일치 P: 37
- 라인 정정 C: 0
- 폐기 D: 0
## Claim Traceability Check
- Claim ID 연결 검사: lane-local decision references를 대조했고 `L3-F04`의 bare references 3건을 확인했다. 외부 raw Claim ID source truth 재검증은 slice 밖이다.
- Decision Evidence Map: 10개 branch note의 DEM을 검토했다. 중복 또는 stale ownership은 `L3-F06`, `L3-F11`, `L3-F15`, `L3-F16`에 기록했다.
- UNSUPPORTED_DECISION: 새 unsupported decision을 사실로 승격하지 않았다. 원문의 Max Reuse, family invalidation, proxy 관련 labels는 그대로 보존했다.
## Cross-lane candidates
| Source edge | External owner / decision | Reason |
| --- | --- | --- |
| `feature-keycloak-nginx-auth-request-integration.md:50` | `feature-keycloak-edge-forwardauth-no-google D1` | standalone nginx 예시와 parent Kubernetes/Traefik split의 정합성 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-nginx-auth-request-integration.md:249` | `feature-keycloak-edge-forwardauth-no-google D2` | forwarded-header naming의 canonical owner — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-nginx-auth-request-integration.md:253` | `feature-keycloak-header-spoofing-defense D5` | 요구하는 `X-Internal-Auth-Token`이 현재 nginx D4에 없음 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-oauth2-proxy-oidc-flow.md:194` | `feature-keycloak-docker-compose-stack D3` | discovery startup gating과 healthcheck 책임 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-realm-client-export.md:168` | `feature-keycloak-docker-compose-stack D4` | realm import mount/command ownership — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-realm-client-export.md:182` | `feature-keycloak-single-ec2-no-google D3`, docker-compose D3 | hostname/redirect URI 전제와 bare sibling 귀속 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-reverse-proxy-headers.md:151` | `feature-keycloak-https-termination-caddy-nginx` | Caddy/nginx selection ownership — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-reverse-proxy-headers.md:153`, `:258` | `feature-keycloak-header-spoofing-defense D6` | `KC_PROXY_TRUSTED_ADDRESSES` owner 정규화 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-reverse-proxy-headers.md:154` | `feature-keycloak-iss-claim-hostname-mismatch` | `KC_HOSTNAME` value owner와 parent D4의 중복 여부 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-single-ec2-google-federation.md:240`, `:279` | first-broker/account-linking siblings | parent D5와 consume-only 구현 절의 경계 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-single-ec2-google-federation.md:241`, `:301` | `feature-keycloak-google-redirect-uri-policy` | parent D6 exact URI와 delegated policy owner의 경계 — `NEEDS_CROSS_LANE_AUDIT` |
```wiki-stats
agent: wiki-research-lane
found: 10
processed: 10
dropped: 0
```
@@ -0,0 +1,564 @@
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
|---|---|---:|---:|
| `raw/branch-notes/feature-keycloak-single-ec2-no-google.md` | READ_FULL | 1342 | 3 findings |
| `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md` | READ_FULL | 1256 | 3 findings |
| `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md` | READ_FULL | 1268 | 2 findings |
| `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md` | READ_FULL | 1231 | 2 findings |
| `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md` | READ_FULL | 1229 | 3 findings |
| `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md` | READ_FULL | 1237 | 2 findings |
| `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md` | READ_FULL | 1257 | 3 findings |
| `raw/branch-notes/feature-security-operational-baseline.md` | READ_FULL | 1417 | 3 findings |
## `raw/branch-notes/feature-keycloak-single-ec2-no-google.md`
### L4-F01 — `localhost` issuer와 `extra_hosts` 해결책이 실행 가능한 한 가지 구성으로 종결되지 않음
- **Severity / 유형**: High — ambiguous/incompatible default flow
- **Source quotes**:
- `"extra_hosts: [host.docker.internal:host-gateway]"``raw/branch-notes/feature-keycloak-single-ec2-no-google.md:146`
- `"backend도 \`issuer-uri=http://localhost:8080/...\` 사용. 컨테이너 간 통신은 \`network_mode: host\` 또는 \`extra_hosts\` 사용."` — `raw/branch-notes/feature-keycloak-single-ec2-no-google.md:181`
- **Claim**: bridge-network backend 컨테이너에서 `localhost`는 backend 자신이다. `extra_hosts``host.docker.internal` 이름을 추가할 뿐 `localhost`를 호스트로 바꾸지 않는다. 두 메커니즘을 동일한 해결책처럼 병기하면 기본 구성이 실행 불가능하다.
- **Assumptions**: backend도 Docker bridge network에서 실행되고, 별도 `jwk-set-uri` override가 없다.
- **Failure mode**: OIDC discovery/JWKS 접근 실패로 backend startup 실패 또는 전 요청 401.
- **Falsification**: 실제 기본이 `network_mode: host`, backend host-process 실행, 또는 `issuer-uri=localhost``jwk-set-uri=host.docker.internal`을 명시적으로 분리한 구성이라면 비판이 무효화된다.
- **Required action / Why**: `feature-keycloak-iss-claim-hostname-mismatch` owner에서 기본 한 가지를 고정하고 이 노트는 포인터만 유지한다. issuer 문자열 일치와 네트워크 도달성은 서로 다른 조건이기 때문이다.
- **Alternatives**:
1. host network + `issuer-uri=localhost`
2. bridge + `issuer-uri=localhost` + 명시적 `jwk-set-uri=host.docker.internal`
3. front/backchannel 동적 URL을 명시적으로 구성
- **Counterargument**: `extra_hosts`로 호스트 접근은 가능하다. 다만 접근 hostname은 `host.docker.internal`이므로, 현재 적힌 `localhost` discovery URI와 자동으로 결합되지는 않는다.
- **Self-grep**:
```text
$ grep -nFo -e 'backend도 `issuer-uri=http://localhost:8080/...` 사용. 컨테이너 간 통신은 `network_mode: host` 또는 `extra_hosts` 사용.' -e 'extra_hosts: [host.docker.internal:host-gateway]' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
146:extra_hosts: [host.docker.internal:host-gateway]
181:backend도 `issuer-uri=http://localhost:8080/...` 사용. 컨테이너 간 통신은 `network_mode: host` 또는 `extra_hosts` 사용.
```
### L4-F02 — `aud` validator owner 정정 후 컴포넌트 표가 옛 owner를 계속 지목
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"Spring RS 셋업·\`aud\` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner"` — `...single-ec2-no-google.md:110`
- `"\`aud\` validator 는 child [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D2"` — `...single-ec2-no-google.md:215`
- **Claim**: 같은 문서가 owner를 `audience-validator`로 정정한 뒤 실행 표에서는 여전히 deferred RBAC 문서의 D2를 가리킨다.
- **Assumptions**: line 110의 2026-07-18 owner split이 최신 결정이다.
- **Failure mode**: 구현자가 role-mapping의 복제된 validator를 수정하고 정본과 다시 갈라진다.
- **Falsification**: role-mapping D2가 여전히 공동 owner라면 Single-Owner 선언 자체를 철회해야 한다.
- **Required action / Why**: line 215와 동일 요약들을 `[[...spring-rs-audience-validator]] D1`로 바꾸고 role-mapping은 RBAC D4D6만 가리킨다.
- **Alternatives**: RS/audience 전체를 role-mapping으로 다시 이관할 수도 있으나 두 문서 중 하나만 owner여야 한다.
- **Counterargument**: 표가 역사적 child 목록일 수 있다. 그러나 셀 문구가 현재 구현 근거를 직접 지목하므로 역사 기록으로 읽히지 않는다.
- **Self-grep**:
```text
$ grep -nFo -e 'Spring RS 셋업·`aud` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner' -e '`aud` validator 는 child [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D2' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
110:Spring RS 셋업·`aud` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner
215:`aud` validator 는 child [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D2
```
### L4-F03 — PKCE 구현 순서가 parent와 실행 owner에서 반대
- **Severity / 유형**: Medium — `CONTRADICTION`
- **Source quotes**:
- `"vanilla JS는 manual fetch + \`crypto.subtle\` 기반 PKCE 구현 우선"` — `...single-ec2-no-google.md:182`
- `"\`oidc-client-ts\` 우선, manual은 별도 단계."` — `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md:106`
- **Claim**: parent는 manual-first, 실행 owner는 library-first를 결정한다.
- **Assumptions**: 두 문구 모두 실제 코딩 순서를 뜻한다.
- **Failure mode**: 작업 순서·완료 조건·테스트 baseline이 달라진다.
- **Falsification**: 전자를 사전 학습 순서, 후자를 repo 구현 순서로 명시하면 양립 가능하다.
- **Required action / Why**: 실행 순서는 child D1을 정본으로 두고 parent D4를 “manual 사전 학습 선택지”로 재분류한다.
- **Alternatives**: manual spike → 폐기 → library 구현, 또는 library E2E → manual 비교 실습.
- **Counterargument**: 결국 둘 다 수행하므로 충돌이 작다. 하지만 단계별 구현 branch의 착수 순서와 acceptance criteria에는 직접 영향을 준다.
- **Self-grep**:
```text
$ grep -nFo -e 'vanilla JS는 manual fetch + `crypto.subtle` 기반 PKCE 구현 우선' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
182:vanilla JS는 manual fetch + `crypto.subtle` 기반 PKCE 구현 우선
$ grep -nFo -e '`oidc-client-ts` 우선, manual은 별도 단계.' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
106:`oidc-client-ts` 우선, manual은 별도 단계.
```
## `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md`
### L4-F04 — “SPA Direct, BFF 미채택” 기본이 실제로는 TMB를 요구
- **Severity / 유형**: High — incompatible architecture default
- **Source quotes**:
- `"P2A 권장 조합 = access_token: 메모리 + refresh_token: secure httpOnly cookie"``...spa-token-storage-tradeoff.md:140`
- `"사실상 최소 Token-Mediating Backend(TMB). \"순수 no-server SPA Direct\" 와의 긴장"``...spa-token-storage-tradeoff.md:165`
- **Claim**: 브라우저 JS는 HttpOnly cookie를 설정할 수 없다. D1을 구현하려면 backend가 refresh token을 받아 `Set-Cookie`하고 `/refresh`를 소유해야 하므로 순수 SPA Direct가 아니다.
- **Assumptions**: “SPA Direct”가 token endpoint를 브라우저가 직접 호출하고 server-side token custody가 없다는 뜻이다.
- **Failure mode**: HttpOnly 저장이 구현되지 않거나, 암묵적으로 TMB를 추가하면서 CSRF·rotation·logout 계약이 빠진다.
- **Falsification**: 프로젝트 taxonomy가 Resource Server의 `/refresh` 추가를 여전히 AP1로 명시적으로 허용하면 무효화된다.
- **Required action / Why**: D1을 순수 AP1과 TMB 변형으로 분리하고 기본을 하나 고정한다. token custody가 인증 패턴을 바꾸기 때문이다.
- **Alternatives**:
1. 순수 SPA: 양 토큰 memory-only + reload 재로그인
2. TMB: refresh token HttpOnly cookie + `/refresh`
3. BFF: 모든 token server-side
- **Counterargument**: 기존 Resource Server에 endpoint 하나만 추가하면 된다. 그래도 refresh token 보유·CSRF·cookie lifecycle 책임이 추가되어 trust boundary가 달라진다.
- **Self-grep**:
```text
$ grep -nFo -e 'P2A 권장 조합 = access_token: 메모리 + refresh_token: secure httpOnly cookie' -e '사실상 최소 Token-Mediating Backend(TMB). "순수 no-server SPA Direct" 와의 긴장' -- raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
140:P2A 권장 조합 = access_token: 메모리 + refresh_token: secure httpOnly cookie
165:사실상 최소 Token-Mediating Backend(TMB). "순수 no-server SPA Direct" 와의 긴장
```
### L4-F05 — D3·D6 근거 상태를 해소한 뒤 Claims To Verify가 계속 `UNSUPPORTED`로 기록
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"D3 를 브라우저·토폴로지 조건부로 **정밀화**"``...spa-token-storage-tradeoff.md:131`
- `"D6 를 \`UNSUPPORTED\` 에서 해소"` — `...spa-token-storage-tradeoff.md:132`
- `"본 branch D3 의 근거가 UNSUPPORTED"``...spa-token-storage-tradeoff.md:214`
- `"D6 가 UNSUPPORTED"``...spa-token-storage-tradeoff.md:216`
- **Claim**: active decision과 검증 큐가 같은 근거 상태를 다르게 표시한다.
- **Assumptions**: 2026-07-18 decision update가 최신이다.
- **Failure mode**: 이미 조사한 근거를 재조사하거나, 실제로 남은 runtime 검증과 문헌 근거 갭을 혼동한다.
- **Falsification**: Claims 행이 과거 상태를 보존하는 역사 섹션이면 날짜와 resolved 표기가 있어야 한다.
- **Required action / Why**: D3는 “근거 확보, topology별 E2E planned”, D6는 “inference 근거 확보, 저장 동작 E2E planned”로 분리한다.
- **Alternatives**: 해소된 행을 resolved archive로 이동하고 새 empirical claim만 남긴다.
- **Counterargument**: 구현 검증은 아직 안 됐다. 그렇더라도 “문헌 unsupported”와 “runtime unverified”는 다른 상태다.
- **Self-grep**:
```text
$ grep -nFo -e 'D3 를 브라우저·토폴로지 조건부로 **정밀화**' -e '본 branch D3 의 근거가 UNSUPPORTED' -e 'D6 를 `UNSUPPORTED` 에서 해소' -e 'D6 가 UNSUPPORTED' -- raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
131:D3 를 브라우저·토폴로지 조건부로 **정밀화**
132:D6 를 `UNSUPPORTED` 에서 해소
214:본 branch D3 의 근거가 UNSUPPORTED
216:D6 가 UNSUPPORTED
```
### L4-F06 — cookie-CSRF 구현 위임 대상에 소유 Decision이 없음
- **Severity / 유형**: Medium — ambiguous owner / owner-less concern
- **Source quotes**:
- `"Spring Security \`CsrfTokenRepository\` 설정/검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 계열 RS 결정으로 위임"` — `...spa-token-storage-tradeoff.md:63`
- `"본 노트는 발급된 토큰의 **검증 측**만."``...spring-rs-audience-validator.md:64`
- **Claim**: storage 문서는 CSRF 실 구현을 audience-validator “계열”로 보냈지만, 대상 문서는 bearer JWT 검증만 소유하며 CSRF D-row가 없다. 포인터도 `D<n>` 없이 광범위하다.
- **Assumptions**: HttpOnly refresh cookie/TMB 변형을 실제 선택한다.
- **Failure mode**: bearer API의 `csrf.disable()`를 cookie refresh endpoint에도 적용하거나, CSRF token 발급·검증을 아무 branch도 구현하지 않는다.
- **Falsification**: 별도 unseen owner가 CSRF endpoint 계약을 이미 소유한다면 그 owner D-row를 직접 가리키면 된다.
- **Required action / Why**: TMB/BFF refresh endpoint owner에 CSRF Decision을 두고 여기서는 정확한 D 포인터만 유지한다.
- **Alternatives**: audience-validator가 범위를 확장할 경우 명시적 D-row·cookie endpoint scope·음성 테스트를 추가한다.
- **Counterargument**: 같은 `SecurityFilterChain`에서 구현할 수 있다. 구현 위치와 결정 owner는 별개이며 bearer와 cookie 요청은 CSRF 위협 모델이 다르다.
- **Self-grep**:
```text
$ grep -nFo -e 'Spring Security `CsrfTokenRepository` 설정/검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 계열 RS 결정으로 위임' -- raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
63:Spring Security `CsrfTokenRepository` 설정/검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 계열 RS 결정으로 위임
$ grep -nFo -e '본 노트는 발급된 토큰의 **검증 측**만.' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md
64:본 노트는 발급된 토큰의 **검증 측**만.
223:본 노트는 발급된 토큰의 **검증 측**만.
```
## `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md`
### L4-F07 — “custom validator 필수” 요약이 최신 property-first 선택 조건과 어긋남
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"custom \`OAuth2TokenValidator<Jwt>\`를 추가해야 하는 이유"` — `...spring-rs-audience-validator.md:17`
- `"단일 audience** 면 property 한 줄이 단순·안전(권장)"``...spring-rs-audience-validator.md:183`
- **Claim**: 목적·본문은 custom validator를 필수로 표현하지만 최신 구현 가이드는 단일 audience에서 Boot `audiences` property를 권장 baseline으로 둔다.
- **Assumptions**: 본 프로젝트는 우선 단일 expected audience다.
- **Failure mode**: 불필요한 custom decoder wiring에서 default issuer/timestamp validator를 덮어쓰거나 누락한다.
- **Falsification**: 다중 audience/`azp` 조건부 검증이 고정 요구라면 custom이 필수일 수 있다.
- **Required action / Why**: “aud 검증은 필수, 구현 방식은 단일=property / 복합=custom”으로 목표·TODO·면접 문장을 동기화한다.
- **Alternatives**: custom 방식은 비교 학습 예제로 남긴다.
- **Counterargument**: custom 구현 학습이 목적이다. 학습 예제라는 점과 production 기본을 분리하면 된다.
- **Self-grep**:
```text
$ grep -nFo -e 'custom `OAuth2TokenValidator<Jwt>`를 추가해야 하는 이유' -e '단일 audience** 면 property 한 줄이 단순·안전(권장)' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md
17:custom `OAuth2TokenValidator<Jwt>`를 추가해야 하는 이유
183:단일 audience** 면 property 한 줄이 단순·안전(권장)
```
### L4-F08 — expected audience가 `backend-client-id`와 `spa-client`로 갈림
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"backend client_id 로 \`aud\` 를 검증"` — `...spring-rs-audience-validator.md:156`
- `"\`aud=spa-client\`를 강제했습니다"` — `...spring-rs-role-mapping.md:38`
- **Claim**: 정본은 API/backend용 audience를 요구하지만 소비 문서는 SPA public client ID를 expected audience로 고정한다.
- **Assumptions**: `backend-client-id``spa-client`는 별도 client다.
- **Failure mode**: 정상 토큰이 모두 401이 되거나, 잘못된 resource audience를 허용해 cross-resource token reuse 방어가 무효화된다.
- **Falsification**: API audience 식별자를 의도적으로 `spa-client`와 동일하게 정의했다면 두 문구는 같은 값을 뜻할 수 있다. 이 경우 owner가 그 동일성부터 명시해야 한다.
- **Required action / Why**: realm/client-export owner와 함께 expected audience 심볼 하나를 고정하고 mapper·validator·음성 테스트에 동일하게 사용한다.
- **Alternatives**: backend client ID, API audience URI, 또는 의도적으로 SPA client ID를 API audience로 재사용.
- **Counterargument**: Keycloak public client가 access token audience일 수 있다. 그렇다면 “backend client_id mapper 필수” 결정이 잘못된 것이므로 둘 중 하나를 고쳐야 한다.
- **Self-grep**:
```text
$ grep -nFo -e 'backend client_id 로 `aud` 를 검증' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md
156:backend client_id 로 `aud` 를 검증
$ grep -nFo -e '`aud=spa-client`를 강제했습니다' -- raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
38:`aud=spa-client`를 강제했습니다
```
## `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md`
### L4-F09 — RS/audience 위임 선언 뒤 active scope·TODO에서 세부를 계속 재명세
- **Severity / 유형**: Medium — `RESTATED_FOREIGN_DECISION`
- **Source quotes**:
- `"Spring RS 공통 셋업 + \`aud\` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner**"` — `...spring-rs-role-mapping.md:19`
- `"custom \`OAuth2TokenValidator<Jwt>\` for \`aud=spa-client\`"` — `...spring-rs-role-mapping.md:50`
- `"여기서 재명세하지 않고"``...spring-rs-role-mapping.md:128`
- **Claim**: owner 정리는 정확하지만 제목·WHY·In scope·TODO가 custom validator, issuer, decoder를 실행 항목으로 계속 보유한다.
- **Assumptions**: line 19/128의 owner 이관이 최신이다.
- **Failure mode**: audience 구현이 두 branch에서 독립 진화하며 audience 값과 wiring이 갈라진다.
- **Falsification**: 이 파일이 역사적 migration snapshot이라면 active scope/TODO가 아니라 Audit 섹션에 있어야 한다.
- **Required action / Why**: 파일을 RBAC-only로 축소하고 RS-common은 `[[...audience-validator]] D1/D6 — 1줄 요약`만 남긴다.
- **Alternatives**: 반대로 전체 RS를 이 파일로 합치고 audience-validator를 폐기할 수 있으나 Single-Owner는 하나여야 한다.
- **Counterargument**: 구현 편의를 위한 fold-in이다. 세부 복제는 owner 진화 시 현재처럼 `aud` 값이 갈라지는 원인이 된다.
- **Self-grep**:
```text
$ grep -nFo -e 'Spring RS 공통 셋업 + `aud` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner**' -e 'custom `OAuth2TokenValidator<Jwt>` for `aud=spa-client`' -e '여기서 재명세하지 않고' -- raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
19:Spring RS 공통 셋업 + `aud` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner**
50:custom `OAuth2TokenValidator<Jwt>` for `aud=spa-client`
128:여기서 재명세하지 않고
```
### L4-F10 — realm-role-only 결정의 “단일 client” 전제가 4-pattern/client base와 stale
- **Severity / 유형**: Medium — assumption drift / `STALE_SUMMARY`
- **Source quotes**:
- `"단일 client 환경(client-level role 분리 의미 적음)"``...spring-rs-role-mapping.md:122`
- `"4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개"``...single-ec2-no-google.md:199`
- **Claim**: RBAC D4의 선택 조건은 client가 하나라는 전제인데 현재 배포 base는 패턴별 여러 client를 둔다.
- **Assumptions**: deferred authZ를 4개 패턴 통합 base에 적용할 예정이다.
- **Failure mode**: 패턴별 권한 분리가 필요한데 realm role이 모든 client에 전역 적용되거나, 반대로 필요한 공통 role이 중복 정의된다.
- **Falsification**: RBAC가 모든 client에 의도적으로 동일한 realm-global 정책이면 유지 가능하다.
- **Required action / Why**: D4 선택 조건을 “realm-global 권한” 대 “client-specific 권한”으로 재작성하고 client 수를 근거로 삼지 않는다.
- **Alternatives**: realm-only, `resource_access` only, 공통 realm role + 민감 endpoint client role 병행.
- **Counterargument**: authZ는 deferred라 지금 결정할 필요가 없다. 그렇다면 “단일 client라 의미 없음”이라는 확정 이유도 deferred로 낮춰야 한다.
- **Self-grep**:
```text
$ grep -nFo -e '단일 client 환경(client-level role 분리 의미 적음)' -- raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
122:단일 client 환경(client-level role 분리 의미 적음)
$ grep -nFo -e '4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
199:4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개
```
## `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md`
### L4-F11 — 3-leg 표기에는 Backend가 없지만 실제 Hop 3의 검증자는 Backend
- **Severity / 유형**: Medium — ambiguous trust-chain taxonomy
- **Source quotes**:
- `"P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google)."``...three-leg-trust-chain.md:33`
- `"**Hop 3: SPA → Backend**"``...three-leg-trust-chain.md:47`
- **Claim**: WHY의 actor chain은 BrowserKeycloakGoogle만 표시하지만 계약 표는 Google→Keycloak→SPA→Backend의 세 hop과 네 actor를 다룬다.
- **Assumptions**: “leg”가 실제 검증 hop을 뜻한다.
- **Failure mode**: trust-boundary 그림에서 backend validator가 빠지거나, Browser↔Keycloak leg와 Keycloak→SPA 발급 leg가 서로 다른 방식으로 계산된다.
- **Falsification**: “3-leg”가 federation actor 수를 뜻한다면 Hop 3 명칭을 다른 체계로 분리해야 한다.
- **Required action / Why**: “3-hop / four-party validation chain”으로 정의하거나 federation chain과 API validation chain을 별도 도식으로 나눈다.
- **Alternatives**: `Google→Keycloak`, `Keycloak→SPA`, `SPA→Backend`를 canonical hop 정의로 고정.
- **Counterargument**: 제목은 학습용 약칭이다. 신뢰 경계 owner 문서에서는 약칭도 verifier 누락을 만들지 않도록 정의가 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google).' -e '**Hop 3: SPA → Backend**' -- raw/branch-notes/feature-keycloak-three-leg-trust-chain.md
33:P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google).
47:**Hop 3: SPA → Backend**
```
### L4-F12 — 면접 답변은 Keycloak nonce 자동 처리를 사실로 말하지만 D3는 미검증
- **Severity / 유형**: Medium — evidence-status contradiction
- **Source quotes**:
- `"\`nonce\`를 확인합니다."` — `...three-leg-trust-chain.md:36`
- `"Keycloak 의 *자동 처리 동작*은 미검증"``...three-leg-trust-chain.md:120`
- **Claim**: normative OIDC RP 의무는 확보됐지만 Keycloak adapter가 해당 요청/검증을 자동 수행하는지는 아직 확인하지 않았다. 면접 문장은 이 구분을 제거한다.
- **Assumptions**: line 36은 추후 파생 가능한 답변 초안이다.
- **Failure mode**: 구현 관측 없이 Keycloak-specific 동작을 외부에 확정적으로 주장한다.
- **Falsification**: Keycloak source, 공식 IdP 문서 또는 HAR로 nonce 송신·대조가 확인되면 승격 가능하다.
- **Required action / Why**: 검증 전에는 “OIDC RP가 nonce를 검증해야 하며, Keycloak 자동 처리는 확인 예정”으로 제한한다.
- **Alternatives**: CV6을 닫은 뒤 현재 문장 복원.
- **Counterargument**: 표준 준수 제품이라 예상 가능하다. 표준이 요구하는 것과 특정 버전·설정이 실제 수행하는 것은 별도 증거다.
- **Self-grep**:
```text
$ grep -nFo -e '`nonce`를 확인합니다.' -e 'Keycloak 의 *자동 처리 동작*은 미검증' -- raw/branch-notes/feature-keycloak-three-leg-trust-chain.md
36:`nonce`를 확인합니다.
120:Keycloak 의 *자동 처리 동작*은 미검증
```
### L4-F13 — out-of-scope claim/linking 정책을 구체 검증 항목으로 재진술
- **Severity / 유형**: Medium — `RESTATED_FOREIGN_DECISION`
- **Source quotes**:
- `"claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]"``...three-leg-trust-chain.md:57`
- `"Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]"``...three-leg-trust-chain.md:58`
- `"\`email_verified\`, \`hd\`, Account Linking 결정"` — `...three-leg-trust-chain.md:95`
- **Claim**: 이 노트는 mapper/account-linking을 위임하면서 내부 hop 표에서는 `email_verified`·`hd`·linking을 Keycloak이 검증하는 고정 정책처럼 적는다. 이들은 설정/flow 의존이며 owner D 포인터도 없다.
- **Assumptions**: 기본 brokering만으로 세 항목이 모두 강제되지는 않는다.
- **Failure mode**: `hd` enforcement 또는 verified-email linking guard가 구성되지 않았는데 trust check가 존재한다고 오인한다.
- **Falsification**: unseen owners가 세 항목을 명시적으로 강제하고 이 문서가 정확한 D-row를 가리키면 1줄 요약으로 유지 가능하다.
- **Required action / Why**: row를 “First Broker Login + mapper 정책(config-dependent)”으로 낮추고 각 owner D-row만 연결한다.
- **Alternatives**: 이 검증들을 본 문서가 소유하려면 out-of-scope 선언을 제거하고 Single-Owner를 재배치해야 한다.
- **Counterargument**: 단순 요약이다. 현재 요약은 어떤 검사가 실제 활성인지까지 암시하므로 메커니즘 복제에 해당한다.
- **Self-grep**:
```text
$ grep -nFo -e '`email_verified`, `hd`, Account Linking 결정' -e 'claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]' -e 'Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]' -- raw/branch-notes/feature-keycloak-three-leg-trust-chain.md
57:claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
58:Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
95:`email_verified`, `hd`, Account Linking 결정
```
## `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md`
### L4-F14 — P3A를 nginx+oauth2-proxy edge-auth 패턴으로 잘못 귀속
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"nginx+oauth2-proxy 확정(P3A)"``...traefik-forwardauth-alternative.md:169`
- `"client (vanilla JS SPA via nginx static)"` / `"Browser → Keycloak → Backend Resource Server JWT validation"``...single-ec2-no-google.md:33`
- **Claim**: Traefik 문서는 P3A를 edge ForwardAuth 구성으로 고정하지만 P3A owner는 SPA-direct PKCE + backend JWT validation이며 nginx는 static server다.
- **Assumptions**: P3A 최신 재매핑은 AP1 + single-EC2 deployment base다.
- **Failure mode**: oauth2-proxy를 잘못 추가해 browser token custody, backend validation 책임, 인증 redirect 흐름이 모두 바뀐다.
- **Falsification**: “AP4 edge-auth를 single-EC2 위에 올린 별도 실행 변형”을 뜻했다면 P3A가 아니라 그 조합으로 명명해야 한다.
- **Required action / Why**: nginx+oauth2-proxy는 P1A/AP4 owner로 돌리고, single-EC2는 배포 축으로만 참조한다.
- **Alternatives**: `AP1-on-single-EC2``AP4-on-single-EC2`를 별도 matrix row로 둔다.
- **Counterargument**: 같은 EC2에서 oauth2-proxy도 실행할 수 있다. 물리 배포 가능성과 인증 패턴 정체성은 별도 축이다.
- **Self-grep**:
```text
$ grep -nFo -e 'nginx+oauth2-proxy 확정(P3A)' -- raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md
169:nginx+oauth2-proxy 확정(P3A)
$ grep -nFo -e 'client (vanilla JS SPA via nginx static)' -e 'Browser → Keycloak → Backend Resource Server JWT validation' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
33:client (vanilla JS SPA via nginx static)
33:Browser → Keycloak → Backend Resource Server JWT validation
```
### L4-F15 — redirect를 middleware가 생성한다고 적었지만 D3는 auth server 응답 전달로 정의
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"middleware가 직접 302를 발급"``...traefik-forwardauth-alternative.md:97`
- `"비 2XX → 인증 서버 응답 그대로 client 에 반환"``...traefik-forwardauth-alternative.md:112`
- **Claim**: ForwardAuth middleware가 redirect를 생성하는 것과 auth service의 302를 전달하는 것은 다른 책임 모델이다.
- **Assumptions**: D3의 vendor-backed contract가 정본이다.
- **Failure mode**: redirect loop나 `Location` 손실을 Traefik 설정 문제로만 진단하고 oauth2-proxy 응답을 점검하지 않는다.
- **Falsification**: 사용 중인 auth plugin이 Traefik 내부에서 직접 redirect를 생성한다면 별도 D10 경로로 분리해야 한다.
- **Required action / Why**: line 97을 “auth server의 302를 Traefik이 그대로 전달”로 갱신한다.
- **Alternatives**: oauth2-proxy 외부 서버와 in-process OIDC plugin의 redirect owner를 표에서 분리한다.
- **Counterargument**: browser 입장에서는 Traefik에서 302가 온다. 그러나 장애 분석에는 302 생성 주체와 전달 주체 구분이 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'middleware가 직접 302를 발급' -e '비 2XX → 인증 서버 응답 그대로 client 에 반환' -- raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md
97:middleware가 직접 302를 발급
112:비 2XX → 인증 서버 응답 그대로 client 에 반환
```
## `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md`
### L4-F16 — relative `/api/me`·`/token` URL이 static-only nginx topology에서 잘못된 origin으로 감
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"fetch('/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })"``...vanilla-js-spa-pkce.md:176`
- `"manual \`fetch('/token'"` — `...vanilla-js-spa-pkce.md:190`
- `"nginx는 단순 static 파일 서빙"``...single-ec2-no-google.md:159`
- **Claim**: SPA origin이 `http://localhost`이고 nginx가 static-only이면 두 relative URL은 nginx:80으로 향한다. 문서의 backend:8081·Keycloak:8080 직노출 topology와 양립하지 않는다.
- **Assumptions**: nginx에 `/api`·`/token` reverse proxy가 없다.
- **Failure mode**: API는 404, token exchange는 404/405가 되어 E2E가 시작되지 않는다.
- **Falsification**: 실제 nginx config가 두 path를 proxy한다면 parent의 static-only·3-port/CORS 계약이 stale다.
- **Required action / Why**: direct topology면 absolute `http://localhost:8081/api/me`와 discovered Keycloak token endpoint를 사용하고, same-origin topology면 nginx proxy를 owner 결정으로 승격한다.
- **Alternatives**:
1. 3-port direct + CORS
2. nginx `/api`·OIDC proxy + same-origin
3. runtime config에서 endpoint base URL 주입
- **Counterargument**: dev proxy가 상대 URL을 처리할 수 있다. 현재 산출물은 vanilla static nginx이며 dev proxy가 명세에 없다.
- **Self-grep**:
```text
$ grep -nFo -e 'fetch('\''/api/me'\'', { headers: { Authorization: '\''Bearer '\'' + user.access_token } })' -e 'manual `fetch('\''/token'\''' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
84:fetch('/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })
176:fetch('/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })
190:manual `fetch('/token'
$ grep -nFo -e 'nginx는 단순 static 파일 서빙' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
159:nginx는 단순 static 파일 서빙
```
### L4-F17 — oidc-client-ts default가 localStorage라는 단정과 “미증명” 상태가 공존
- **Severity / 유형**: Medium — evidence-status `CONTRADICTION`
- **Source quotes**:
- `"oidc-client-ts 기본은 \`WebStorageStateStore\` (localStorage)."` — `...vanilla-js-spa-pkce.md:98`
- `"default store 종류(localStorage 여부)는 본 Sources 로 미증명"``...vanilla-js-spa-pkce.md:168`
- **Claim**: 같은 파일이 library default를 사실과 미확인으로 동시에 표시한다. `stateStore``userStore` default도 구분되지 않았다.
- **Assumptions**: library version별 default가 달라질 수 있고 현재 raw source가 이를 고정하지 않는다.
- **Failure mode**: 불필요하거나 잘못된 override로 redirect transaction state가 소실되거나, 실제 token이 persistent storage에 남는다.
- **Falsification**: pin된 oidc-client-ts 버전 공식 API/default 문서가 localStorage를 명시하면 첫 문장을 근거화할 수 있다.
- **Required action / Why**: 검증 전 line 98을 inference로 낮추고 두 store의 default와 token 저장 여부를 각각 확인한다.
- **Alternatives**: 명시적 custom store로 version default에 의존하지 않되 full-page redirect state 생존 테스트를 추가한다.
- **Counterargument**: 어차피 memory override를 쓸 예정이다. `stateStore`까지 memory로 바꾸면 callback state가 사라질 수 있어 default 확인이 여전히 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'oidc-client-ts 기본은 `WebStorageStateStore` (localStorage).' -e 'default store 종류(localStorage 여부)는 본 Sources 로 미증명' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
98:oidc-client-ts 기본은 `WebStorageStateStore` (localStorage).
168:default store 종류(localStorage 여부)는 본 Sources 로 미증명
```
### L4-F18 — iframe silent SSO는 out-of-scope인데 `automaticSilentRenew` 메커니즘은 미결정
- **Severity / 유형**: Medium — ambiguous flow/default
- **Source quotes**:
- `"iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)"``...vanilla-js-spa-pkce.md:58`
- `"어느 메커니즘이 default 인지 본 인용 범위 밖"``...vanilla-js-spa-pkce.md:217`
- **Claim**: iframe을 제외하고 refresh grant를 채택했다고 쓰면서 실제 library가 iframe과 refresh grant 중 무엇을 쓰는지 확인하지 않았다.
- **Assumptions**: E2E acceptance에는 reload·expiry 갱신 경로가 포함된다.
- **Failure mode**: Safari/cross-site에서 silent renew 실패, reload 후 in-memory user 복구 실패, 또는 rotation 시연이 실행되지 않는다.
- **Falsification**: pin된 library 설정과 network trace로 refresh-token grant만 사용함을 확인하면 out-of-scope 선언과 정합해진다.
- **Required action / Why**: refresh grant를 baseline으로 고정하고 필요한 scope/client 설정·reload bootstrap을 명세하거나 iframe을 조건부 경로로 다시 scope에 넣는다.
- **Alternatives**: memory-only + 명시적 재로그인.
- **Counterargument**: library가 런타임에 적절한 방식을 택한다. 브라우저 정책에 따라 결과가 달라지므로 학습 E2E에는 결정적 기대 동작이 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)' -e '어느 메커니즘이 default 인지 본 인용 범위 밖' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
58:iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)
217:어느 메커니즘이 default 인지 본 인용 범위 밖
```
## `raw/branch-notes/feature-security-operational-baseline.md`
### L4-F19 — Phase C2 완료 뒤 active 구현 표가 codes·CORS·redaction을 계속 미구현으로 표시
- **Severity / 유형**: High — `STALE_SUMMARY`
- **Source quotes**:
- `"production enum \`OperationalError\` 미구현"` — `...feature-security-operational-baseline.md:222`
- `"CorsSettings\` 가 \`[\"*\"]\`+\`allowCredentials=true\` 조합을 startup 거부하지 **않음**"` — `...feature-security-operational-baseline.md:269`
- `"token leak contract test"` (`planned`) — `...feature-security-operational-baseline.md:279`
- `"12 fine-grained AUTH/AUTHZ/INTERNAL codes"``...feature-security-operational-baseline.md:360`
- `"token/PII redaction"``...feature-security-operational-baseline.md:363`
- `"CORS wildcard+credentials 정적 거부"``...feature-security-operational-baseline.md:365`
- `CODE_GRANULARITY_DRIFT ... RESOLVED``...feature-security-operational-baseline.md:372`
- **Claim**: Phase C2 as-built 표는 세 항목을 locally verified로 승격했지만 앞선 Implementation Specification은 여전히 planned/미구현 상태다.
- **Assumptions**: Phase C2 표가 최신 코드 대조 결과다.
- **Failure mode**: 동일 기능을 재구현하거나 canonical 추출에서 구현 사실을 누락하고, 현재 테스트 범위를 잘못 판단한다.
- **Falsification**: Phase C2 표가 이후 revert된 상태라면 해당 표를 downgrade해야 한다.
- **Required action / Why**: active 구현 가이드와 Claims를 Phase C2 as-built에 맞추고 과거 상태는 Audit history로만 남긴다.
- **Alternatives**: 각 오래된 표에 “pre-2026-06-08 historical” 라벨을 붙인다.
- **Counterargument**: 아래 Audit에서 해소 사실을 찾을 수 있다. 상단 active specification과 검증 큐가 stale이면 소비자는 끝까지 읽지 않는 한 반대 결론을 얻는다.
- **Self-grep**:
```text
$ grep -nFo -e 'production enum `OperationalError` 미구현' -e '12 fine-grained AUTH/AUTHZ/INTERNAL codes' -e 'CODE_GRANULARITY_DRIFT' -e 'CorsSettings` 가 `["*"]`+`allowCredentials=true` 조합을 startup 거부하지 **않음**' -e 'token leak contract test' -e 'CORS wildcard+credentials 정적 거부' -e 'token/PII redaction' -- raw/branch-notes/feature-security-operational-baseline.md
144:CODE_GRANULARITY_DRIFT
214:CODE_GRANULARITY_DRIFT
222:production enum `OperationalError` 미구현
269:CorsSettings` 가 `["*"]`+`allowCredentials=true` 조합을 startup 거부하지 **않음**
279:token leak contract test
317:CODE_GRANULARITY_DRIFT
360:12 fine-grained AUTH/AUTHZ/INTERNAL codes
363:token/PII redaction
365:CORS wildcard+credentials 정적 거부
372:CODE_GRANULARITY_DRIFT
372:12 fine-grained AUTH/AUTHZ/INTERNAL codes
```
### L4-F20 — JWT decoder와 clock-skew의 현재 메커니즘이 세 가지 상태로 기록
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"auto-config** (custom decoder 미작성)"``...feature-security-operational-baseline.md:197`
- `"custom \`JwtDecoder\` bean"` — `...feature-security-operational-baseline.md:364`
- `"60s 는 코드에 명시되지 않고 Spring default leeway 에 의존"``...feature-security-operational-baseline.md:374`
- **Claim**: active guide는 auto-config/default skew, Phase C2 표는 custom decoder와 explicit skew, 후속 Audit bullet은 다시 명시 설정 부재라고 말한다.
- **Assumptions**: 세 진술 모두 같은 2026-06-08 이후 코드 상태를 설명한다.
- **Failure mode**: 잘못된 bean을 수정하거나 default drift 위험·startup IdP 의존성·validator chain 테스트 범위를 오판한다.
- **Falsification**: `SupplierJwtDecoder`가 custom wrapper이지만 내부적으로 auto-config/default skew를 그대로 사용한다면 line 364의 “explicit clock skew” 표현이 잘못된 것이다.
- **Required action / Why**: 실제 code owner에서 `JwtDecoderConfig`의 validator chain을 대조한 뒤 한 상태만 남긴다.
- **Alternatives**:
1. custom decoder + explicit `JwtTimestampValidator(60s)`
2. auto-config + vendor default 60s
- **Counterargument**: custom wrapper와 auto-config를 함께 쓸 수 있다. 그래도 “custom decoder 미작성”과 “custom bean locally verified”는 동시에 참일 수 없다.
- **Self-grep**:
```text
$ grep -nFo -e 'auto-config** (custom decoder 미작성)' -e 'custom `JwtDecoder` bean' -e '60s 는 코드에 명시되지 않고 Spring default leeway 에 의존' -- raw/branch-notes/feature-security-operational-baseline.md
197:auto-config** (custom decoder 미작성)
364:custom `JwtDecoder` bean
374:60s 는 코드에 명시되지 않고 Spring default leeway 에 의존
```
### L4-F21 — public-path 검증 계약이 폐기된 reflection 방식과 구현된 env-snapshot 방식을 함께 유지
- **Severity / 유형**: High — `STALE_SUMMARY` / incompatible verification command
- **Source quotes**:
- `"SecurityFilterChain.getFilters()\` introspection"` — `...feature-security-operational-baseline.md:305`
- `"gradle task 미존재"``...feature-security-operational-baseline.md:318`
- `"public path snapshot gate"` (`locally-verified`) — `...feature-security-operational-baseline.md:366`
- **Claim**: 구현 가이드 line 243250은 env-based snapshot으로 정정됐지만 Test Contract와 Claims는 reflection 및 task 부재를 계속 전제로 한다.
- **Assumptions**: Phase C2의 `verifyPublicPathSnapshot` task가 현행이다.
- **Failure mode**: 존재하는 task를 다시 작성하거나 reflection coverage를 기대해 Java hard-coded `permitAll()`까지 탐지된다고 오판한다.
- **Falsification**: task가 이후 제거됐다면 Phase C2와 구현 가이드를 downgrade해야 한다.
- **Required action / Why**: Test Contract와 Claims를 env `SECURITY_PUBLIC_PATHS` snapshot command·승인 flag·hard-coded `permitAll()` 미검출 한계로 동기화한다.
- **Alternatives**: 별도 reflection/behavioral test를 추가해 env gate의 blind spot을 보완한다.
- **Counterargument**: line 243에 정정 설명이 있다. 하지만 실행 명령을 제공하는 §테스트 계약과 active Claims가 반대 상태여서 운영자가 잘못된 검증을 수행한다.
- **Self-grep**:
```text
$ grep -nFo -e 'SecurityFilterChain.getFilters()` introspection' -e 'gradle task 미존재' -e 'public path snapshot gate' -- raw/branch-notes/feature-security-operational-baseline.md
243:SecurityFilterChain.getFilters()` introspection
305:SecurityFilterChain.getFilters()` introspection
318:gradle task 미존재
318:SecurityFilterChain.getFilters()` introspection
366:public path snapshot gate
```
## Cross-lane owner edges
| L4 finding | Unseen owner / governing source | 필요한 대조 | 상태 |
|---|---|---|---|
| L4-F01 | `feature-keycloak-iss-claim-hostname-mismatch` D6, `feature-keycloak-docker-compose-stack` | bridge/host network 기본과 `issuer-uri`/`jwk-set-uri` 조합 | NEEDS_CROSS_LANE_AUDIT |
| L4-F02, F10, F14 | `raw/project-notes/keycloak-patterns-overview` F3/F5/§8 | 최신 AP taxonomy·owner split·single-EC2 deployment 축 | NEEDS_CROSS_LANE_AUDIT |
| L4-F04, F06 | `feature-keycloak-internal-spa-direct-no-google`, `feature-keycloak-bff-vs-spa-direct` | TMB 허용 여부와 cookie/CSRF owner | NEEDS_CROSS_LANE_AUDIT |
| L4-F08 | `feature-keycloak-realm-client-export` | 실제 client IDs와 Audience mapper target | NEEDS_CROSS_LANE_AUDIT |
| L4-F10 | `feature-keycloak-realm-client-export` D5 | realm roles 대 client roles의 발급 계약 | NEEDS_CROSS_LANE_AUDIT |
| L4-F12, F13 | `feature-keycloak-first-broker-login-flow`, `feature-keycloak-idp-mappers-claim-to-role`, `feature-keycloak-account-linking-spa-ux` | nonce 자동 처리·`email_verified`·`hd`·linking 실제 정책 | NEEDS_CROSS_LANE_AUDIT |
| L4-F14, F15 | `feature-keycloak-edge-forwardauth-no-google`, `feature-keycloak-oauth2-proxy-oidc-flow` | AP4 owner 및 302 생성/전달 주체 | NEEDS_CROSS_LANE_AUDIT |
| L4-F18 | `feature-keycloak-refresh-token-rotation`, `feature-keycloak-refresh-rotation-and-logout` | refresh grant·rotation·reload 복구 계약 | NEEDS_CROSS_LANE_AUDIT |
| L4-F19F21 | `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` + ca-tmpl code owner | Phase C2 as-built와 canonical/current code 정합 | NEEDS_CROSS_LANE_AUDIT |
## Claim Traceability Check
- Claim ID 연결 검사: 8개 branch-note의 Supporting Claims 구조를 모두 확인했다. exact slice 밖 raw-source Claim 본문은 재열람하지 않았으므로 외부 Claim byte 검증은 범위 밖이다. slice 내부 owner 의미 오귀속은 L4-F02·F06·F09로 보고했다.
- Decision Evidence Map: 8개 모두 검토. 최신 D-row와 본문/소비자 요약의 semantic drift는 L4-F02·F03·F05·F07F10·F12·F19F21에 반영했다.
- UNSUPPORTED_DECISION: 새 미라벨 결정으로 확정한 건은 없으며, 기존 label을 무시한 상위 요약 과장은 L4-F12에 포함했다.
## Grep verification
- 실행한 `grep -nFo` self-check: 25회
- 검증한 verbatim quote fragment V: 55
- 일치 P: 55
- 라인 정정 C: 0
- 폐기 D: 0
- 모든 command exit code: 0
- 이 lane은 파일 수정 명령을 실행하지 않았다. 공유 worktree에는 선행 변경이 존재하므로 workspace-clean 주장은 하지 않는다.
## Stats
```wiki-stats
agent: wiki-research-lane
found: 8
processed: 8
dropped: 0
```
@@ -0,0 +1,69 @@
# Keycloak Semantic Edge Risk Sample
이 lane은 valid reference occurrence 647건 전체가 아니라 lane finding 중 고위험 후보 20건을 표본 대조했다. 실제 D 또는 § edge는 9건이었고 11건은 내부 모순, D-id 없는 비교, self-reference여서 NOT_AN_EDGE로 분리했다.
## Counts
| Classification | Count |
|---|---:|
| CONSISTENT | 2 |
| STALE_SUMMARY | 1 |
| CONTRADICTION | 1 |
| RESTATED_FOREIGN_DECISION | 5 |
| NOT_AN_EDGE | 11 |
## Edge Verdicts
| Lane ID | Citing → owner | Ref | Verbatim evidence | Verdict | Action |
|---|---|---|---|---|---|
| L1-F02 | account-linking-spa-ux:119 → account-linking-sub-vs-email:152 | D3 | Citing: “orphan 거부 메커니즘은 owner 브랜치 D3(역시 needs-confirmation)에서 추적” / Owner: “Account Console self-service unlink lockout 방지는 Keycloak 엔진이 서버에서 이미 강제” | STALE_SUMMARY | Citing을 server guard 확인 및 release-tag 재확인 필요라는 한 줄로 갱신. |
| L1-F03 | account-linking-spa-ux:117 → idp-mappers-claim-to-role:120 | D2 | Citing은 email_verified=true와 Attribute Importer와 custom step을 위임하면서 복제하고, owner D2도 같은 mechanism을 결정한다. | RESTATED_FOREIGN_DECISION | Citing에는 owner D2 pointer와 linking trust gate consume 한 줄만 남김. |
| L1-F05 | account-linking-sub-vs-email:154 → google-claim-attribute-mapping:130 | D3 | Citing과 owner가 IMPORT와 FORCE의 값 및 local edit 대 Google 최신성 조건을 함께 보유한다. | RESTATED_FOREIGN_DECISION | Takeover와 Sync Mode가 직교한다는 한 줄과 owner pointer만 유지. |
| L1-F08 | docker-compose-stack:236 → single-ec2-no-google:197 | D3 | Citing은 KC_HOSTNAME, issuer-uri, host network 또는 extra_hosts를 복제하고 owner D3도 같은 bundle을 결정한다. | RESTATED_FOREIGN_DECISION | Docker note는 issuer/network wiring을 consume한다는 한 줄로 축소. |
| L1-F14 | federation-spa-zero-change:130 → internal-spa-direct-no-google:281 | D3 | 양쪽이 iss, signature, exp, aud 4종 backend validation을 같은 의미로 사용한다. | CONSISTENT | 이 edge에는 조치 없음. 정확한 audience 값은 별도 L4-F08에서 처리. |
| L2-F03 | google-claim-attribute-mapping:130 → idp-mappers-claim-to-role:119 | D1 | Citing의 role freshness 목적 FORCE 요약이 owner D1의 최신 정보 반영 FORCE와 양립한다. | CONSISTENT | 적용 범위는 owner에서 명확화하되 edge summary 수정은 없음. |
| L2-F09 | internal-spa-direct-no-google:342 → spa-token-storage-tradeoff:140 | D1 | Hub와 owner가 access memory와 refresh secure HttpOnly cookie 조합을 같은 값으로 결정한다. | RESTATED_FOREIGN_DECISION | Hub는 storage owner D1 pointer와 의존 한 줄만 유지. |
| L3-F15 | public-domain-tunneling:106,169 → single-ec2-google-federation:238 | D3 | Parent D3와 child D1이 Cloudflare 우선, ngrok 차선의 provider 순서를 각각 normative decision으로 둔다. | RESTATED_FOREIGN_DECISION | Parent를 provider order owner로 두고 child는 운영 detail만 소유. |
| L4-F03 | single-ec2-no-google:182 → vanilla-js-spa-pkce:124 | D1 | Citing: “manual fetch와 crypto.subtle 기반 PKCE 구현 우선” / Owner: “oidc-client-ts 우선 채택, manual PKCE는 비교 학습용 별도 단계” | CONTRADICTION | Owner D1을 실행 순서 정본으로 두고 citing의 manual-first를 historical learning order로 격하. |
## NOT_AN_EDGE
| Lane ID | Evidence | Reason |
|---|---|---|
| L1-F01 | authentication-authorization-contract:73,169 | 자신의 D3와 같은 문서 raw-role 설명의 불일치이며 foreign edge가 아니다. |
| L1-F07 | bff-vs-spa-direct:130,165 | 같은 문서 요약 matrix와 D5의 불일치다. |
| L1-F10 | edge-forwardauth-google-federation:145 대 edge-forwardauth-no-google:112 | cross-doc 비교는 가능하나 target D 또는 § reference가 없다. |
| L1-F11 | edge-forwardauth-no-google:167,227 | 같은 문서의 선택축과 implementation table 문제다. |
| L2-F05 | idp-brokering-google-client:81 대 google-redirect-uri-policy:167 | 값은 다르지만 consumer에 owner D6 reference가 없다. |
| L3-F01 | nginx-auth-request-integration:101,163 | 같은 문서 body-forwarding 설명의 불일치다. |
| L3-F11 | refresh-token-rotation:159 대 refresh-rotation-and-logout:124 | 두 D-row의 중복 관찰이지만 연결 문구에 target D-id가 없다. |
| L3-F12 | refresh-token-rotation:67 대 refresh-rotation-and-logout:61 | destination wikilink와 D-id가 없는 범위 위임이다. |
| L3-F13 | refresh-token-rotation:127 대 refresh-rotation-and-logout:120 | Max Reuse 정책 사이에 D 또는 § reference edge가 없다. |
| L3-F16 | single-ec2-google-federation:239,301 | 같은 parent 내부 bundle과 D-id 없는 detail 위임의 ownership 문제다. |
| L4-F08 | role-mapping:38,115 대 audience-validator:156 | 값 drift는 있으나 packet의 D4는 role-mapping 자신의 RBAC D4라 유효 foreign D4 edge가 아니다. |
## Reconciliation with adversarial review
Semantic edge 판정과 finding 존속 판정은 목적이 다르다. 예를 들어 L4-F03 edge 문구는 CONTRADICTION이지만 citing 자체가 DECISION_DRIFT를 표시하고 owner를 정본으로 인정하므로 적대 리뷰는 독립 priority에서 REJECT했다. 원문을 고칠 때는 여전히 stale active 문구와 D-row를 수거하는 것이 맞다.
## Limits
- literal reference 647건과 logical edge 298건 중 고위험 후보 20건만 선택했다.
- 20건 중 edge verdict가 가능한 것은 9건이었다.
- 전체 logical edge 의미 대조는 별도 사용자 확인 후 20건 이하 lane으로 나눠야 한다.
~~~wiki-verdict
agent: wiki-consistency-auditor
verdict: not-ready
blocking: 1
should_fix: 6
advisory: 0
~~~
~~~wiki-stats
agent: wiki-consistency-auditor
found: 20
processed: 9
dropped: 11
dropped_reason: NOT_AN_EDGE
~~~