# notes — 마일스톤 4 (api-contracts.jsonl · schema-constraints.jsonl)

계획: `.claude/plans/insuniverse-docs-11-index-code-m4.plan.md`

> 이 파일에 Implement 게이트 기록을 둔다. 계획 본문에 쓰면 `plan_hash` 가 바뀌어
> `mccp-plan-codex` receipt 가 stale 로 떨어진다(마일스톤 2·3 과 같은 규약).

## Codex Implementation Review

- 호출: `node C:/Users/madsc/.claude/plugins/cache/mccp/mccp/1.23.0/scripts/lib/codex-invoke.js adversarial-review` (fail-closed Bash wrapper, v0.2.2)
- 라운드 수: 1
- R1 원 verdict: **`needs-attention`** — "Do not ship the proposed implementation unchanged. Reusing m2's policy-specific scanFile for the full m4 source population deterministically aborts generation."
- 합치 결론: 2건 중 **1건은 사실 오류로 반려, 1건은 R1 에서 흡수**했다. Codex 가 짚은 게이트 도구 결합(coupling) 축 자체는 옳은 질문이었고, 그래서 계획이 "재사용 가능하면 import, 아니면 규칙을 복제하지 말 것" 으로 남겨둔 자리를 이번에 실측으로 닫았다.

### F1 — REJECT (전제가 거짓, 실측으로 반증)

Codex 주장: "m2 `scanFile` 은 `APP_GUARD`/`useGlobalGuards` 를 만나면 throw 하는데 m2 는 RolesGuard 좁은 모집단만 훑었고 m4 는 전수를 훑으므로 `app.module.ts` 에서 생성이 중단된다."

실측 3건이 전제를 무너뜨린다.

| 확인 | 결과 | 근거 |
|---|---|---|
| m2 의 스캔 모집단 | **이미 전수다** — `for (const sr of r.src_roots) files.push(...walk(...))` 후 `.ts` 전 파일에 `scanFile` 호출. RolesGuard 좁힘은 그 **뒤** 행 방출 단계(`:613-615`)에서 일어난다 | `build-policy-anchors.mjs:576-592` |
| `APP_GUARD` · `useGlobalGuards` | server `apps`+`libs` **0건** | `grep -rln` |
| `applyDecorators` · `extends RolesGuard` | 각 **0건** | 동 |

`app.module.ts` 가 가진 것은 `APP_PIPE` 이고 `scanFile` 은 `APP_PIPE` 에 throw 하지 않는다 — Codex 가 `APP_PIPE` 를 `APP_GUARD` 로 읽었다. m2 는 **오늘 이미** `app.module.ts` 를 `scanFile` 로 통과시키고 게이트가 초록이다. 따라서 m4 의 재사용은 모집단을 **넓히지 않는다**(같은 walk · 같은 exclude · 같은 파일 집합).

남는 참인 부분 하나: 언젠가 `APP_GUARD` 가 생기면 m2 와 m4 가 **함께** 멈춘다. 그것은 결함이 아니라 fail-closed 설계의 의도한 동작이고(발견 28 의 "조용한 실패 금지" 와 같은 태도), 조용하지 않다. 정책 중립 파서 분리 리팩터는 **하지 않는다** — 이미 출하된 게이트 도구를 흔들 이유가 실측에 없다(발견 25 backlog 와 같은 판단).

### F2 — ACCEPT_NOW (흡수함)

Codex 주장: HTTP 경로 조립 규칙이 m2 에서 export 되지 않은 인라인 3행(`build-policy-anchors.mjs:619-621`)이라, m4 가 복제하면 policy-anchors 와 조인되는 355행에서만 드리프트가 잡히고 **m4 단독 301행은 검증 없이** 갈라진다.

> 위 355·301 은 **Codex 가 구현 시점 pin 에서 말한 수치를 그대로 인용한 것**이다. `bcb50ec` 재핀 후
> 현재 값은 조인 356 · 차집합 296 이다(`build-api-contracts --report`). 인용문은 고치지 않고 병기한다.

받는다. 계획 Task 4 문면이 이미 "재사용이 가능하면 import, 아니면 규칙을 복제하지 말고 그 사실을 findings 에 적는다" 로 이 갈림길을 열어뒀고, 실측 결과 **재사용이 가능하다**. m2 에서 순수 함수 하나를 export 하고 m2 자신도 그것을 쓰게 바꾼다 — 규칙이 한 곳에만 산다.

```js
// build-policy-anchors.mjs
export function httpString(method, prefix, path) { … }   // null 전파 → "unknown"
```

m2 동작은 바뀌지 않는다(같은 표현식을 함수로 옮길 뿐). 계획 Validation 블록에 이미 있는 m2 selftest + `verify-policy-anchors` 게이트 회귀가 그것을 증명한다.

- YAGNI Triage:
  | Finding | Severity | Verdict | Why |
  |---|---|---|---|
  | F1 재사용한 m2 스캐너가 m4 모집단을 훑지 못한다 | HIGH | REJECT_YAGNI | 전제가 거짓. m2 는 이미 같은 전수 모집단에 `scanFile` 을 돌리고 있고 throw 트리거 4종이 실측 0건이다. 정책 중립 파서 분리는 실측 근거 없이 출하된 게이트 도구를 흔든다 |
  | F2 HTTP 조립 규칙 복제로 301행이 드리프트 미탐지 | MEDIUM | ACCEPT_NOW | 계획이 이미 "재사용 가능하면 import" 로 열어둔 자리. export 1개가 복제보다 싸고, 규칙이 한 곳에 산다 |
- Deferred to backlog: 0 (Plan-Codex 의 1건은 이미 `codex-findings-backlog.md` 에 등재)
- Open Questions: 없음
- Codex session 참조: threadId `019fda98-439c-7e42-ba75-5c3adc5bf459`

### 계획 이탈 (F2 흡수의 귀결)

`docs/_meta/verify/build-policy-anchors.mjs` 가 계획 **Files to Change 표에 없다.** F2 를 받으면 이 파일에 `export function httpString` 1개가 추가되고 인라인 3행이 그 호출로 바뀐다.

- **무엇**: m2 생성기에 순수 함수 1개 export 추가 + 자기 호출부 치환.
- **왜**: Codex F2. 계획 Task 4 가 명시적으로 허용한 분기("재사용이 가능하면 import")이고, 대안인 복제는 계획 자신이 금지한다.
- 계획의 "건드리지 않는다" 목록은 **주석 스트리퍼 3벌의 통합**을 금지한 것이고 `build-policy-anchors.mjs` 를 통째로 동결하지 않았다. 이 변경은 스트리퍼와 무관하다.
- m2 산출물(`policy-anchors.yaml`) 재생성 결과가 바뀌지 않아야 하며, Validation 블록의 m2 게이트 회귀가 그것을 판정한다.

### Security Reviewer

> security-reviewer unavailable, skipped (auto-fallback): 이 세션은 환경 지시로 Agent(Task) tool 호출이 금지돼 있어 `subagent_type: "security-reviewer"` 를 부를 수 없다.

이 축의 실제 보안 표면은 좁다 — 산출물은 읽기 전용 인덱스이고, 새 코드는 저장소 파일을 읽어 JSONL 을 쓰는 것뿐이다. 다만 `api-contracts.jsonl` 이 **가드 표면 사실**을 게시하므로(`guards_declared` · `policy_anchor`), 계획 Task 3 이 부재를 "제한 없음" 으로 읽히지 않게 두 필드를 필수로 둔 것이 그 자리의 완화책이다. 판정은 하지 않는다.

receipt 에 `security_skipped=true` 로 정직하게 기록한다 — 이 상태에서 `/mccp:pr` 은 막힌다.

### Design Review

> impeccable silent-skip: `skill_available=true` · `design_signal=false` (reason `no-signal`). 이 마일스톤은 렌더링 표면(`.tsx`/`.css`/`.html`/`.claude/cache/*`)을 만들지 않는 control-plane 변경이라 디자인 축이 없다. critique loop 미실행, receipt 에 `impeccable_silent_skip=true` 로 기록.

## Santa Loop — 라운드 기록 (15 이후)

Reviewer A = Claude Opus (`code-reviewer`, read-only). Reviewer B = codex `gpt-5.4`
(`--sandbox read-only`). 매 라운드 새 리뷰어(직전 라운드 기억 없음).

라운드마다 실행한 지상 검증: selftest 10종 · 게이트 5종 `--repos-root ../..` ·
빌더 5종 재실행 후 `git status` 로 재생성 바이트 동일성.

| 라운드 | A | B | 조치 | 커밋 |
|---|---|---|---|---|
| 15 | PASS | FAIL 2 | `scanProperties`·`paramBindings` 와 판정기 재스캔 2곳을 fail-closed 로 | `618f1bd` |
| 16 | PASS | FAIL 2 | 파라미터 데코레이터 어휘 목록 삭제(전수 재스캔) · 문자열 리터럴 안 라우트 오계수 차단 | `d51951a` |
| 17 | FAIL 1 (반려) | FAIL 1 | 파라미터·검증자 앵커를 튜플째 소스 대조 | `4009713` |
| 18 | PASS | FAIL 1 | 재핀 후 옛 pin 수치 20여 곳 재기술 + 리포트 3종에 pin 스탬프 | `4e4e0a3` |
| 19 | PASS | FAIL 2 (1 반려) | 메서드 레벨 인터셉터 어휘를 닫고 그 밖이면 중단 | `f4e300e` |
| 20 | PASS | FAIL 1 (+1 반려) | `policy-anchors.yaml` 부재를 통과가 아니라 실패로 · findings 조사 기준점 정정 | `6c3a6d1` |
| 21 | PASS | FAIL 2 | 모르는 제약 생성자·못 읽는 항목을 중단으로 · 판정기가 블록 밖에서 테이블을 비운다 | `8005ca9` |
| 22 | PASS | FAIL 1 | 따옴표 2종 수용 + 컬럼 파싱 실패를 중단으로 (제네릭 쉼표 절단 결함이 여기서 드러남) | `f056416` |
| 23 | FAIL 2 | PASS | `rescanSchema` 따옴표 누락 보정 · 선언 수 대 읽은 수 대조 신설 | `8502ebd` |
| 24 | PASS | FAIL 1 | m4 리포트 selftest 수치 3중 불일치 정합 | `017b46e` |
| 25 | PASS | FAIL 1 | 이름 없는 제약 skip 중단 · **제약 누락 역방향 검사 신설** | `15a750a` |
| 26 | FAIL 3 | FAIL 3 | 문자열 안 체이닝 오독 차단 · 여러 줄 테이블 선언 수용 · 셋째 축 안전장치 | `eab83e5` |
| 27 | PASS | FAIL 1 | `effective_properties` 출처 필드 대조 3종 (실측으로 후보 5 중 3 채택) | `b9ab9c8` |
| 28 | FAIL 3 | FAIL 2 | m2 판정기 데코레이터 배치 2종 수용 · 미검증 범위를 `meta.gap` 에 명시 | `441fbfa` |
| 29 | PASS | FAIL 1 | **출하된 행의 거짓** — 합집합에서 출처를 고르지 않고 집합으로 싣는다 | `5da1b08` |
| 30 | PASS | FAIL 3 | `meta.observed` 값 대조 · 전역 검증 앵커 실재 검사 · `policy_anchor` id 도출 대조 | `55a58f2` |
| 31 | PASS | FAIL 4 (전부 반려) | 새 조치 없음 — 종료점 | — |

### 라운드 17 Reviewer A — REJECT (전제가 불완전, 실측으로 반증)

주장: `parseComposition("OmitType(MyClass<string>, ['a'])")` 가
`{op:"omit", base_expr:"MyClass<string>", base_ref:null, props:["a"]}` 를 만든다.
"풀리지 않은 base 에 필드 단위 연산을 적용했다고 주장하는 것" 이므로 중단해야 한다.

실측 3건이 전제를 무너뜨린다.

| 확인 | 결과 |
|---|---|
| `flattenComposition` 실제 출력 | 간선이 **2개**다 — `omit` 간선 뒤에 `{op:"unparsed", base_expr:"MyClass<string>", base_ref:null}` 이 함께 실린다. A 의 보고는 뒤엣것을 빠뜨렸다 |
| 그 행의 `resolution` | `unresolved` · `effective_properties: null`. 계산된 속성이 나가지 않는다 |
| 실물 `api-contracts.jsonl` 에서 `op∈{omit,pick} ∧ base_ref=null ∧ props≠null` 인 행 | **0건** |

남는 것은 "소스가 `OmitType(<못 푼 표현식>, ['a'])` 라고 적혀 있다" 는 **참인 기록**이다.
`base_ref: null` 이 "어느 클래스인지 모른다" 를 말하고, `unparsed` 간선과 `resolution: unresolved`
가 fail-closed 신호를 이미 싣는다. 여기서 중단하면 **멀쩡한 TypeScript**(`OmitType(BaseDto<T>, [...])`)
가 인덱스에서 통째로 빠진다 — 커버리지 후퇴이지 강화가 아니다.

"base_ref 가 null 인데 resolution 이 resolved 로 나가는" 진짜 위험은 판정기가 이미 막는다
(`resolution 재계산 대조` · selftest `(l) 없는 base 는 unresolved`).

### 라운드 19 Reviewer B F2 — REJECT (실행 결과가 주장과 다르다)

주장: `build-schema-constraints.mjs` 의 `/\.on\(([^)]*)\)/` 가 첫 `)` 에서 잘리므로
`.on(desc(table.a), table.b)` 가 `columns: ["a"]` 로 **실린다**.

실행 결과는 다르다 — 부분 행을 싣지 않고 중단한다:

```
insuniverse-server/drizzle/schema.ts:5: 제약 t_idx 이 이 테이블에 없는 컬럼을 가리킨다 — desc(table.a
```

잘린 토큰(`desc(table.a`)이 테이블 컬럼 열거에 없어 fail-closed 로 떨어진다. 정규식이 잘리는 것은
사실이나 **거짓 행이 나가지 않는다**. 실물 `schema.ts` 의 중첩 `.on(` 도 0건(전체 `.on(` 186).

### 라운드 19 Reviewer B F1 의 **제안**은 반려, 결함은 수용

결함(메서드 레벨 봉투 인터셉터 → `response_envelope: null` 거짓)은 실행으로 재현해 수용했다.
다만 B 의 제안("클래스와 핸들러 데코레이터를 합쳐라")은 받지 않았다 — 실측 메서드 레벨 38개소가
전부 요청측 멀티파트 파서(`FileInterceptor` 16 · `FileFieldsInterceptor` 9 · `AnyFilesInterceptor` 9 ·
`FilesInterceptor` 4)라, 합치면 그 38행이 봉투로 실려 **거짓 1건이 거짓 38건이 된다.**
어휘를 닫고 그 밖이면 중단하는 쪽을 택했다.

### 라운드 20 Reviewer B — 판정기 독립성 지적은 REJECT

주장: `verify-api-contracts.mjs` 가 `build-policy-anchors.mjs` 의 `scanFile`·`httpString` 을
쓰므로 그 파서의 결함이 생성기·게이트에 함께 남는다.

사실이나 결함이 아니다. 3자 대조의 셋째 축 `rescanRoutes` 는 `scanFile` 을 **쓰지 않는다**
(주석·문자열을 덮은 뒤 라우트 데코레이터를 직접 센다). 즉 귀속 규칙이 어긋나면 그 축에서 갈라진다.
공유하는 것은 파서 프리미티브이고 **파생 규칙이 아니며**, `httpString` 은 m4 가 복제를 없애려고
export 한 것이다(Implement-Codex F2, 이 파일 위쪽). 분리하려면 TypeScript 파서를 한 벌 더 만들어야
하고 그것은 이미 출하된 게이트 2개를 흔든다 — 실측 근거 없이 하지 않는다.
어휘 목록처럼 **갈리면 거짓을 만드는 것**은 라운드 16·19 에서 이미 판정기 쪽에 따로 적어 두었다.

### 라운드 31 — 종료점

Reviewer A 는 네 인덱스 파일(`codes/*.yaml` · `constants.yaml` · `env-gates.yaml` ·
`policy-anchors.yaml`)의 **필드 27개를 하나씩 위조**해 각 판정기에 돌렸고 **27/27 전부 탐지**됐다.
critical 0.

Reviewer B 의 FAIL 4건은 **전부 같은 backlog 항목 하나**다 — `//` 이중 슬래시 라우트
(`POST /admin/agency-fc//subscribe/upload` · `GET //loss`).

**REJECT — 이미 판정·선언된 항목이다.**

| 어디에 | 무엇이 |
|---|---|
| `codex-findings-backlog.md` (2026-08-07 MEDIUM) | 유예 결정과 사유 |
| `findings/11-index-code.md` 발견 35 | 소스 앵커 · 영향 행 수 · 유예 사유 |
| `api-contracts.jsonl` `meta.gap` (8) | **"NestJS 는 실제 라우팅에서 이것을 정규화하므로 이 문자열을 실제 경로로 그대로 쓰면 틀린다"** |

루브릭이 그은 선은 "참되게 선언된 gap 은 결함이 아니다" 이고, B 가 제시한 실패가 **선언문 자체의
내용**이다. B 의 넷째 지적(selftest 가 틀린 출력을 정답으로 못박는다)도 같은 성격이다 — 그 케이스는
`(a2) 선행 슬래시는 겹친 채 남는다 (m2 규칙 그대로)` 라는 라벨을 달고 있어 정답 주장이 아니라
**유예된 동작의 특성화 테스트**다.

정규화 자체는 여전히 옳은 방향이고 backlog 에 남아 있다. 여기서 하지 않는 이유는 처음 유예할 때와
같다 — 권한 축(m2) 산출물이 재생성되고, 그 축을 다시 손대는 마일스톤에서 검증기 독립 재스캔과 함께
처리하는 것이 규약이다.

## 종료 사유

사용자 지시로 라운드 31 에서 멈춘다. 마지막 라운드에서 **새 축의 결함이 나오지 않았고**(A 위조
27/27 탐지 · B 는 기판정 항목 재보고만), 이 지점을 타협점으로 잡았다.

두 리뷰어 동시 PASS(NICE)는 17라운드 동안 한 번도 없었으므로 **push 하지 않는다** — santa-loop 규약상
push 는 NICE 에서만이다. 커밋은 전부 로컬 `feat/index-code` 에 있다.
