## Lane Inventory | Path | Status | Evidence Lines | Extracted Facts | |---|---|---:|---:| | `raw/branch-notes/feature-keycloak-single-ec2-no-google.md` | READ_FULL | 1–342 | 3 findings | | `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md` | READ_FULL | 1–256 | 3 findings | | `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md` | READ_FULL | 1–268 | 2 findings | | `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md` | READ_FULL | 1–231 | 2 findings | | `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md` | READ_FULL | 1–229 | 3 findings | | `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md` | READ_FULL | 1–237 | 2 findings | | `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md` | READ_FULL | 1–257 | 3 findings | | `raw/branch-notes/feature-security-operational-baseline.md` | READ_FULL | 1–417 | 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 D4–D6만 가리킨다. - **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` 없이 광범위하다. - **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\`를 추가해야 하는 이유"` — `...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`를 추가해야 하는 이유' -e '단일 audience** 면 property 한 줄이 단순·안전(권장)' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md 17:custom `OAuth2TokenValidator`를 추가해야 하는 이유 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\` 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` 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` 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은 Browser–Keycloak–Google만 표시하지만 계약 표는 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 243–250은 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-F19–F21 | `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·F07–F10·F12·F19–F21에 반영했다. - 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 ```