leerness 1.36.15 → 1.36.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.17 — 2026-07-14 — 렌즈-외 표면 버그헌트: 데이터손실 2 + gate FN + 아카이브 주입 + --json 에러경로 (codex, 재현확정)
4
+
5
+ 게시본 1.36.16 대상, database 렌즈를 제외한 CLI 표면을 codex로 광범위 적대 헌트 → 8건 중 6건 수정(고영향 재현확정), 2건 백로그(UR-0052).
6
+
7
+ - **P1 데이터손실 — `memory restore` (decisions/lessons)**: 복원이 MD 프로젝션에만 append 하고 canonical JSON을 안 고쳐, `list`엔 안 보이고 다음 add/save가 JSON→MD 재생성으로 복원분을 영구 삭제(재현확정). → 복원 후 MD를 canonical 파서로 재파싱해 `_saveDecisions`/`_saveLessons`로 JSON 재빌드.
8
+ - **P2 데이터손실 — `plan remove`**: `### ` 만 블록 경계로 써서 마지막 마일스톤 뒤 사용자 h1/h2 섹션(`## Risks` 등)이 함께 삭제(재현확정). → 모든 헤딩(`#{1,3} `)을 경계로 분할, `### M-` 블록만 삭제.
9
+ - **P2 gate FN — `verify-claim`**: FILE_RE/`_evidenceQuality`가 basename 첫 글자를 `[A-Za-z]`로만 잡아 `123.js` 같은 숫자시작 실파일 미추출 → 없는 파일 인용한 done 주장이 vacuous-pass(재현확정, 정상 done FP 회귀 없음 확인). → 첫 글자에 숫자 허용(prev-separator 가드 유지).
10
+ - **P2 아카이브 주입**: lesson/decision drop이 사용자 텍스트를 archive MD에 raw 삽입 → 개행으로 `## 제거` 델리미터 위조(가짜 archive 엔트리)(재현확정). → 아카이브 필드에 `_lineSafe`.
11
+ - **P2 --json 에러경로**: task update/drop·lesson/decision/plan/rule not-found 6경로가 `--json`에도 평문 출력 → JSON 소비자 파싱 실패. → `failJson(has('--json'), code, msg)`.
12
+ - **백로그(UR-0052)**: P1-2(10k+ ID 고정폭 `\d{4}` 리더 ~20곳, 날짜 혼동 위험으로 신중한 일괄 필요), P2-6(contract verify 주석 마스킹), P3-8(handoff `--compact` 단일 워크스페이스 분기).
13
+ - **검증**: selftest 287/287(신규 6수정 소스가드), 데이터손실 3건 재현→수정 후 재검, 게이트 e2e 386/386.
14
+
15
+ ## 1.36.16 — 2026-07-13 — 게시본 1.36.15 클린룸 codex 9건 채택: database 렌즈 정직/정밀 + selftest·병합 하드닝
16
+
17
+ 게시본 1.36.13~1.36.15 누적 신규 표면(문항 8→12, cap 16)을 codex 로 적대 리뷰 → 9건(P2/P3) 전부 재현/채택. clean 3축(i18n 패리티·앵커 유일성·크래시 없음).
18
+
19
+ - **문항 정직화 5건(KO=EN)**: (Q6) MVCC "reads don't block writes"를 non-locking 한정으로·스냅샷 스코프는 격리수준 의존·lost update 는 PG READ COMMITTED 노출이고 PG REPEATABLE READ는 조용한 유실 아닌 직렬화 abort로 정정; SQLITE_BUSY_SNAPSHOT은 busy_timeout 이 아니라 롤백+재시작. (Q9) **자기모순 수정** — abort 장벽이 "세대 카운터를 걷어낸다"고 썼으나, 카운터는 오히려 유지·단조증가해야 stale continuation 을 거부(제거하면 ABA 재유입 — deadlock 라운드 shipped 수정과 정합). (Q11) 단일 락만으론 데드락 사이클 불가(다른 락과 얽힐 때)·파티션 co-location≠순서(in-flight 1+순서보존 프로듀서+sink 시퀀스 필요). (Q12) 상태-조건 UPDATE 만으로 비순환 전이 직렬화; 버전 조건은 상태만으로 stale 구분 불가할 때만.
20
+ - **구조 4건**: (F6) 커스텀 병합 초과분 조용한 절단 → `_droppedCustom` 플래그 + lens 렌더 ⚠ 경고(내장 12는 앞이라 보존, 커스텀만 잘림). (F7) "병합이 12 안 자름" selftest 가 빈 custom 이라 slice 미실행(공허) → 넘치는 custom 주입해 내장12 보존+초과6 절단을 실제 검증. (F8) 단어 하나 앵커가 실질 문항 삭제를 못 잡음 → 문항별 다중절(clause) 판별로 강화(재현: 신 가드는 변이 포착, 구 가드는 놓침). (F9) selftest 이름 "11문항"→"12문항".
21
+ - **검증**: selftest 286/286, F3/F6/F8 프로브 재현확정, 게이트 e2e 386/386, 게시본 클린룸 재실증.
22
+
3
23
  ## 1.36.15 — 2026-07-13 — database 렌즈 11→12문항 + Q6 확장: 매트릭스 partial 6요소 승격
4
24
 
5
25
  - **왜(37-코퍼스 매트릭스 후속)**: v2 매트릭스가 partial(문항이 인접 개념만 언급)로 정직 기록한 16요소 중 실무 빈도 최상위 두 클러스터를 문항으로 승격.
package/README.md CHANGED
@@ -125,7 +125,7 @@ MIT
125
125
  <!-- leerness:project-readme:start -->
126
126
  ## Leerness Project Harness
127
127
 
128
- 이 프로젝트는 Leerness v1.36.15 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
128
+ 이 프로젝트는 Leerness v1.36.17 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
129
129
 
130
130
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
131
131
 
@@ -179,7 +179,7 @@ leerness memory restore decision <date|title>
179
179
 
180
180
  ### MCP server (외부 AI 통합)
181
181
 
182
- Leerness v1.36.15는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
182
+ Leerness v1.36.17는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
183
183
 
184
184
  ```jsonc
185
185
  // 카테고리별
@@ -200,7 +200,7 @@ Leerness v1.36.15는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
200
200
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
201
201
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
202
202
 
203
- 현재 누적: **70 라운드 (1.9.40 → 1.36.15)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
203
+ 현재 누적: **70 라운드 (1.9.40 → 1.36.17)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
204
204
 
205
205
  ### 성능 가이드 (1.9.140 측정)
206
206
 
@@ -238,6 +238,6 @@ leerness release pack --close --auto-main-push
238
238
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
239
239
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
240
240
 
241
- Last synced by Leerness v1.36.15: 2026-07-13
241
+ Last synced by Leerness v1.36.17: 2026-07-14
242
242
  <!-- leerness:project-readme:end -->
243
243
 
package/bin/leerness.js CHANGED
@@ -33,7 +33,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
33
33
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
34
34
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
35
35
 
36
- const VERSION = '1.36.15';
36
+ const VERSION = '1.36.17';
37
37
 
38
38
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
39
39
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -3894,22 +3894,27 @@ function _selfTestCases() {
3894
3894
  && eq(F([]), [])
3895
3895
  && eq(F(['a.css', 'b.md', 'c.js', 'd.test.js']).length, 2); // 최대 2개 클러터 방지
3896
3896
  } },
3897
- { name: 'DB 안전성 렌즈 (코어 승격 + playbook 역이식 + 37코퍼스 매트릭스 갭 보강): database 도메인 11문항(KO=EN) + DB파일 매핑 인라인 + 비-DB 회귀0 (행위)', run: () => {
3897
+ { name: 'DB 안전성 렌즈 (코어 승격 + playbook 역이식 + 37코퍼스 매트릭스 갭 보강): database 도메인 12문항(KO=EN) + DB파일 매핑 인라인 + 비-DB 회귀0 (행위)', run: () => {
3898
3898
  const F = _lensDomainsForFiles;
3899
3899
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
3900
3900
  const d = LENS_CATALOG.database;
3901
+ // 각 문항을 다중 절(clause)로 고정 — 앵커 한 단어만 남긴 변이(codex F8)에서도 실패하도록 2개 이상 클러스터로 판별
3902
+ const anyKo = (...subs) => d.questions.some(q => subs.every(s => q.includes(s)));
3903
+ const anyEn = (...subs) => d.questionsEn.some(q => subs.every(s => q.includes(s)));
3901
3904
  const catOk = !!d && d.questions.length === 12 && d.questionsEn.length === 12
3902
- && d.questions[0].includes('lost update') && d.questions.some(q => q.includes('TOCTOU'))
3903
- && d.questions.some(q => q.includes('in-doubt')) && d.questions.some(q => q.includes('SKIP LOCKED'))
3904
- && d.questions.some(q => q.includes('thenable')) // playbook 역이식 9번(콜백 async 이탈+등록/abort 장벽)
3905
- && d.questions.some(q => q.includes('ABA')) // playbook 역이식 10번(전-라이터 참여+토큰 식별자+테스트 정직성)
3906
- && d.questions.some(q => q.includes('hot row') && q.includes('backpressure') && q.includes('bulkhead')) // 11번(37코퍼스 매트릭스 갭: hot row/샤딩/큐 직렬화/backpressure/bulkhead)
3907
- && d.questionsEn.some(q => q.includes('thenables')) && d.questionsEn.some(q => q.includes('ABA'))
3908
- && d.questionsEn.some(q => q.includes('hot row') && q.includes('bulkhead'))
3909
- && d.questions.some(q => q.includes('허용 목록') && q.includes('inbox')) // 12번(상태 전이+순서+inbox 멱등)
3910
- && d.questionsEn.some(q => q.includes('allow-list') && q.includes('inbox'))
3911
- && d.questions.some(q => q.includes('dirty read')) // Q6 확장(격리 이상 열거+MVCC 가시성)
3912
- && _mergeLensCatalog(LENS_CATALOG, {}).database.questions.length === 12 // 병합 cap(16) 내장 12문항을 자르지 않음
3905
+ && d.questions[0].includes('lost update') && anyKo('TOCTOU', 'UNIQUE')
3906
+ && anyKo('in-doubt', '멱등키') && anyKo('SKIP LOCKED', '펜싱')
3907
+ && anyKo('thenable', '등록', 'identity 가드') && anyEn('thenables', 'REGISTERING', 'identity guard') // 9번(콜백/등록/abort)
3908
+ && anyKo('ABA', '식별자', '역방향 절도') && anyEn('ABA', 'identity', 'reverse theft') // 10번(라이터/토큰/검증)
3909
+ && anyKo('hot row', 'backpressure', 'bulkhead') && anyEn('hot row', 'backpressure', 'bulkhead') // 11번
3910
+ && anyKo('허용 목록', 'inbox', 'aggregate') && anyEn('allow-list', 'inbox', 'aggregate') // 12번(전이/순서/멱등)
3911
+ && anyKo('dirty read', 'REPEATABLE READ', 'BUSY_SNAPSHOT') && anyEn('dirty read', 'REPEATABLE READ', 'BUSY_SNAPSHOT') // Q6 확장(정직화)
3912
+ && anyKo('세대 카운터', '단조 증가') && anyEn('generation counter', 'must advance') // codex F3 회귀가드(제거X→증가)
3913
+ // 병합 cap 을 실제로 통과시켜( custom 이 아니라 넘치는 custom) 내장 12 보존 + 초과 절단을 검증 (codex F7)
3914
+ && (() => {
3915
+ const m = _mergeLensCatalog(LENS_CATALOG, { database: { questions: Array.from({ length: 10 }, (_, i) => `__probe_q${i}`) } }).database;
3916
+ return m.questions.length === 16 && d.questions.every(q => m.questions.includes(q)) && m._droppedCustom === 6;
3917
+ })()
3913
3918
  && d.affects.every(a => LENS_CATALOG[a]);
3914
3919
  // DB 파일 → database 최우선(questions[0] 인라인 노출), DB+code → [database, code], 비-DB 코드 → 회귀 없음
3915
3920
  const mapOk = eq(F(['db/migrations/001_init.sql']), ['database'])
@@ -3923,6 +3928,26 @@ function _selfTestCases() {
3923
3928
  && eq(F(['README.md']), ['docs']);
3924
3929
  return catOk && mapOk;
3925
3930
  } },
3931
+ { name: '비-렌즈 버그헌트 수정 회귀 (게시본 1.36.16 codex): restore canonical 재빌드 + plan 헤딩경계 + FILE_RE 숫자시작 + archive/json 구조화 (소스가드)', run: () => {
3932
+ const src = read(__filename);
3933
+ // P1-1: memory restore 가 decisions/lessons canonical JSON 을 재빌드(MD-only append 아님)
3934
+ const restoreOk = src.includes("if (surface === 'decisions') _saveDecisions(root, _decisionsFromMd(read(activePath)))")
3935
+ && src.includes("else if (surface === 'lessons') _saveLessons(root, _parseLessonEntries(read(activePath)))");
3936
+ // P2-4: plan remove 가 모든 헤딩(#{1,3} )을 경계로 split (사용자 h2 섹션 흡수 방지)
3937
+ const planOk = src.includes('const blocks = text.split(/\\n(?=#{1,3} )/);');
3938
+ // P2-5: FILE_RE + _evidenceQuality basename 첫 글자에 숫자 허용 (저-백슬래시 판별 조각)
3939
+ const fileReOk = src.includes(')?[A-Za-z0-9][');
3940
+ const analyzersOk = read(path.join(__dirname, '..', 'lib', 'analyzers.js')).includes('[A-Za-z0-9][\\w./');
3941
+ // P2-3: archive 쓰기에 _lineSafe (개행 델리미터 위조 차단)
3942
+ const archiveOk = src.includes('`\\n## 제거 ${today()} (target: "${_lineSafe(target)}")\\n${archiveBlocks}\\n`');
3943
+ // P2-7: drop/remove not-found 가 failJson (--json 에러 구조화)
3944
+ const jsonOk = src.includes("failJson(has('--json'), 'lesson_not_found'")
3945
+ && src.includes("failJson(has('--json'), 'decision_not_found'")
3946
+ && src.includes("failJson(has('--json'), 'milestone_not_found'")
3947
+ && src.includes("failJson(has('--json'), 'task_not_found'")
3948
+ && src.includes("failJson(has('--json'), 'rule_not_found'");
3949
+ return restoreOk && planOk && fileReOk && analyzersOk && archiveOk && jsonOk;
3950
+ } },
3926
3951
  { name: 'DB 렌즈 recall (dogfood FN, 1.36.4): 내용기반 감지 — 평범한 이름의 DB 모듈 잡고 산문 FP 0 (행위)', run: () => {
3927
3952
  const T = _isDbContentText, W = _withDbDomain;
3928
3953
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -4917,16 +4942,16 @@ const LENS_CATALOG = {
4917
4942
  "함께 성공하거나 함께 실패해야 할 여러 쓰기(여러 행·여러 테이블)를 하나의 명시적 BEGIN/COMMIT으로 묶었고, 모든 실패 경로(예외·조기 return·타임아웃)에서 ROLLBACK이 보장되는가? — 문장별 autocommit이면 중간 실패 시 절반만 커밋된다(partial commit). 트랜잭션 내내 같은 커넥션이 쓰이는지, 그리고 BEGIN~COMMIT 사이에 외부 HTTP/RPC·사용자 대기를 두지 않아 트랜잭션이 짧게 유지되는지(오래 열리거나 idle-in-transaction이면 커넥션 풀이 고갈되고 MVCC 팽창·VACUUM 차단이 생기니 idle_in_transaction_session_timeout 등으로 방어)도 확인했는가?",
4918
4943
  "롤백 불가능한 부수효과(이메일·큐 발행·외부 HTTP)와 파생 데이터(집계·카운터·캐시·검색 인덱스)가 원본 커밋과 어긋날 수 있지 않은가? — 트랜잭션 안에서 부수효과를 내면 롤백돼도 유령 이메일이 나가고, 커밋 뒤 fire-and-forget면 커밋과 발행 사이 크래시에 유실된다(멱등키는 중복만 막지 이 유실은 못 막는다). 그래서 반드시 도착해야 하는 효과는 트랜잭셔널 아웃박스(같은 트랜잭션에 의도를 저장하고 커밋 뒤 릴레이가 at-least-once 발송)/내구 job 행/CDC로 보장하고, 명시적으로 best-effort인 효과만 멱등키로 보호된 '커밋 후 발행'으로 빼고, 파생 데이터는 같은 트랜잭션(또는 트리거/아웃박스/CDC)으로 원본과 원자적으로 묶은 뒤 원본에서 재계산하는 정합성 경로를 뒀는가?",
4919
4944
  "이 불변식(잔액>=0, 유일성, NOT NULL, 참조무결성)이 DB 제약(CHECK/UNIQUE/FK/NOT NULL)으로 강제되는가, 아니면 앱 코드 검증에만 있는가? — 앱 검증은 동시 쓰기·다른 서비스·백필 스크립트·직접 SQL이 조용히 우회한다. 위반을 시도하는 테스트로 (a) 제약이 이 엔진/연결에서 실제로 켜져 있는지(예: SQLite는 연결마다 PRAGMA foreign_keys=ON; Postgres에서 NOT VALID로 추가한 FK는 신규 INSERT/UPDATE에는 그대로 적용되고 기존 행만 검증을 건너뛴다), (b) UNIQUE 컬럼의 NULL은 표준상 서로 구별돼 NULL 행이 무제한 허용된다는 함정을 확인했는가? — NULL이 유효하지 않으면 NOT NULL, NULL을 하나만 허용하려면 Postgres 15 NULLS NOT DISTINCT나 생성열/표현식 유니크로 풀어라(부분 인덱스 WHERE col IS NOT NULL은 NULL을 제약하는 게 아니라 오히려 무제한 허용한다 — non-null만 유니크할 때 쓰는 것).",
4920
- "이 트랜잭션의 격리 수준을 의식적으로 골랐고, 데드락·직렬화 실패에 유한 재시도 루프(재시도 안에서 최신 상태 재조회, 백오프/지터, 재시도되는 트랜잭션엔 외부 부수효과가 없어 두 번 실행돼도 안전할 것)를 넣었는가? — 기본값에 그냥 맡기면 dirty read(READ UNCOMMITTED 급: 커밋 전 값을 읽고 상대가 롤백)·non-repeatable read·phantom·write skew가 샌다. MVCC/스냅샷 엔진은 트랜잭션이 '자기 시점 스냅샷' 읽어 읽기가 쓰기를 안 막지만, 그게 자동 안전이 아니다 — 스냅샷 격리 ≠ 직렬화(write skew 통과) lost update 그대로 남는다(기본값은 엔진마다 다름: Postgres는 보통 READ COMMITTED, MySQL InnoDB는 보통 REPEATABLE READ). 재시도할 에러코드도 엔진별로 다르니 확인했는가(Postgres 데드락 40P01·직렬화 40001; MySQL 데드락 40001/1213·락 대기 타임아웃 1205 — 1205는 기본적으로 문장만 롤백하니 트랜잭션 전체를 BEGIN부터 재시도하거나 innodb_rollback_on_timeout을 켤 것; SQLite는 SQLITE_BUSY/BUSY_SNAPSHOT에 busy_timeout·쓰기 트랜잭션엔 BEGIN IMMEDIATE·필요 시 WAL대응). 그리고 COMMIT 도중 타임아웃/연결끊김으로 결과가 불확실(in-doubt)한 트랜잭션은 무조건 롤백으로 단정하지 말고 멱등키/트랜잭션ID/원본 재조회로 실제 반영 여부를 재확인한 뒤 재시도하는가? 스냅샷 격리/Postgres REPEATABLE READ만으론 write skew가 안 막히니(SERIALIZABLE 또는 명시적 잠금 필요) 합계·겹침 불변식엔 그 방어를 뒀고, 여러 행/테이블을 잠그는 경로는 늘 같은 순서로 잠그는가?",
4945
+ "이 트랜잭션의 격리 수준을 의식적으로 골랐고, 데드락·직렬화 실패에 유한 재시도 루프(재시도 안에서 최신 상태 재조회, 백오프/지터, 재시도되는 트랜잭션엔 외부 부수효과가 없어 두 번 실행돼도 안전할 것)를 넣었는가? — 기본값에 그냥 맡기면 dirty read(READ UNCOMMITTED 급: 커밋 전 값을 읽고 상대가 롤백)·non-repeatable read·phantom·write skew가 샌다. MVCC 엔진의 '보통(non-locking) 읽기' 대체로 쓰기를 안 막지만(잠금 읽기 FOR UPDATE는 막는다), 그게 자동 안전이 아니다 — 스냅샷 스코프는 격리수준에 달렸고(Postgres/MySQL READ COMMITTED는 명령마다 새 스냅샷, REPEATABLE READ는 트랜잭션 단위 스냅샷), 스냅샷 격리 ≠ 직렬화(write skew 통과)다. lost update 노출도 격리수준에 달렸다 — Postgres READ COMMITTED는 lost update 가 새지만, Postgres REPEATABLE READ는 같은 행 동시 갱신을 '조용히 유실'이 아니라 직렬화 실패로 abort 한다(기본값은 엔진마다 다름: Postgres는 보통 READ COMMITTED, MySQL InnoDB는 보통 REPEATABLE READ). 재시도할 에러코드도 엔진별로 다르니 확인했는가(Postgres 데드락 40P01·직렬화 40001; MySQL 데드락 40001/1213·락 대기 타임아웃 1205 — 1205는 기본적으로 문장만 롤백하니 트랜잭션 전체를 BEGIN부터 재시도하거나 innodb_rollback_on_timeout을 켤 것; SQLite는 재시도 가능한 SQLITE_BUSY busy_timeout으로 대기하되 SQLITE_BUSY_SNAPSHOT은 WAL 스냅샷이 낡은 것이라 더 기다려도 안 되니 롤백 후 새 트랜잭션으로 재시작, 쓰기 트랜잭션엔 BEGIN IMMEDIATE로 예방). 그리고 COMMIT 도중 타임아웃/연결끊김으로 결과가 불확실(in-doubt)한 트랜잭션은 무조건 롤백으로 단정하지 말고 멱등키/트랜잭션ID/원본 재조회로 실제 반영 여부를 재확인한 뒤 재시도하는가? 스냅샷 격리/Postgres REPEATABLE READ만으론 write skew가 안 막히니(SERIALIZABLE 또는 명시적 잠금 필요) 합계·겹침 불변식엔 그 방어를 뒀고, 여러 행/테이블을 잠그는 경로는 늘 같은 순서로 잠그는가?",
4921
4946
  "사용자에게 '성공'을 돌려준 이 쓰기가 실제로 확인된 COMMIT에 도달했고(커밋을 await하고 결과를 검사했는가), 그 ack가 메모리 버퍼가 아니라 안정 저장소(커밋 시 WAL/redo/journal이 flush됨)를 의미하는가? — autocommit을 끈 채 커밋을 빠뜨리면 커넥션 반납 시 열린 트랜잭션이 조용히 롤백된다. 내구성 설정은 엔진마다 다르니 데이터 등급에 맞게 확인했는가(예: Postgres synchronous_commit, MySQL innodb_flush_log_at_trx_commit=1 + sync_binlog=1, SQLite synchronous=FULL; Redis는 기본이 비내구적). 노드 손실까지 견뎌야 하는 데이터라면 쿼럼/동기 복제 ack(예: w:majority, 동기 스탠바이)로 한 노드가 죽어도 유실되지 않게 했고, failover RPO를 명시했는가?",
4922
4947
  "동시성 방어를 인프로세스 뮤텍스·싱글턴·인메모리 dedup에 기대거나, 여러 서비스에 걸친 작업을 하나의 DB 트랜잭션으로 감쌀 수 있다고 착각하거나, 방금 프라이머리에 쓴 값을 지연된 리드 레플리카에서 읽고 있지 않은가? — 인스턴스가 2개 이상이면 인프로세스 락은 무력하니 공유 지점으로 옮기고(DB UNIQUE 제약·행 잠금·advisory lock, 큐 소비는 FOR UPDATE SKIP LOCKED), 원자성은 프로세스/네트워크 경계를 못 넘으니 크로스서비스는 아웃박스+saga+보상 트랜잭션으로, at-least-once 전달은 멱등키(UNIQUE)로, 복제 지연이 깨는 read-your-writes는 프라이머리 라우팅/LSN 확인으로 옮겼는가? 분산 락을 쓴다면 리스 만료 후 늦게 깨어난 작업이 최신 작업을 덮어쓰지 못하게 단조 증가 펜싱 토큰(DB 시퀀스/행 버전)을 발급하고 하위 자원이 낮은 토큰을 거부하게 했는가?",
4923
4948
  // 9~10번: 9-라운드 현장 dogfood 회고(review-playbook)에서 역이식 — 그린 테스트가 놓치고 리뷰가 잡은 결함 패밀리.
4924
- "트랜잭션·락·리스·single-flight 가 감싼 '사용자 콜백'이 promise 를 반환할 수 있지 않은가? — async 콜백은 첫 suspension 까지만 동기라, await 하지 않으면 그 뒤 continuation 이 COMMIT/해제 뒤 보호 밖에서 실행된다(아웃박스 릴레이가 async 발행 실패를 모른 채 sent=1로 마킹, 펜스 해제 뒤 늦은 쓰기). await 를 설계로 강제하거나 thenable 을 명시적으로 거부했는가? 진행 중 작업을 추적하는 구조(단일비행 맵·락 테이블·in-progress 레지스트리)는 사용자 코드가 실행되거나 동기 throw 하기 '전'에 등록을 마치는가 — 등록 전에 콜백이 던지면 실패 경로가 반초기화 상태를 보고 원본 에러가 삼켜지거나 죽은 엔트리가 키를 영구 오염시킨다. abort/해제는 작업의 흔적 전부(보유 락·대기열 엔트리·무효화 표식·세대 카운터) 걷어내는 장벽인가 — 걷다 만 대기열 엔트리는 죽은 작업에 락을 승계시키고, 세대 재검사 없는 중단된 획득 루프는 abort 뒤에도 계속 획득한다. 그리고 정리는 identity 가드(맵의 엔트리가 여전히 '내 것'일 때만 삭제)로 후속 작업의 엔트리를 지우지 않는가?",
4949
+ "트랜잭션·락·리스·single-flight 가 감싼 '사용자 콜백'이 promise 를 반환할 수 있지 않은가? — async 콜백은 첫 suspension 까지만 동기라, await 하지 않으면 그 뒤 continuation 이 COMMIT/해제 뒤 보호 밖에서 실행된다(아웃박스 릴레이가 async 발행 실패를 모른 채 sent=1로 마킹, 펜스 해제 뒤 늦은 쓰기). await 를 설계로 강제하거나 thenable 을 명시적으로 거부했는가? 진행 중 작업을 추적하는 구조(단일비행 맵·락 테이블·in-progress 레지스트리)는 사용자 코드가 실행되거나 동기 throw 하기 '전'에 등록을 마치는가 — 등록 전에 콜백이 던지면 실패 경로가 반초기화 상태를 보고 원본 에러가 삼켜지거나 죽은 엔트리가 키를 영구 오염시킨다. abort/해제는 임시 소유권(보유 락·대기열 엔트리·무효화 표식) 걷어내되, 되레 유지·증가시켜야 할 것은 stale continuation 을 거부할 세대 카운터/톰스톤이다 — 걷다 만 대기열 엔트리는 죽은 작업에 락을 승계시키고, 반대로 세대 카운터를 '지워버리면' 재개된 작업이 같은 세대를 다시 받아 stale 인지 못 해 abort 장벽이 깨진다(카운터는 단조 증가). 세대 재검사 없는 중단된 획득 루프도 abort 뒤에 계속 획득한다. 그리고 정리는 identity 가드(맵의 엔트리가 여전히 '내 것'일 때만 삭제)로 후속 작업의 엔트리를 지우지 않는가?",
4925
4950
  "버전 컬럼·단조 토큰·워터마크가 지키는 불변식에 '모든' 라이터가 참여하는가 — upsert·마이그레이션·시드·관리자 경로 중 하나만 우회해도(INSERT OR REPLACE 가 version 을 기본값으로 초기화, 평범한 대입이 토큰을 되감음) 낡은 스냅샷의 CAS 가 통과한다(ABA). 그 토큰/키는 식별자를 갖는가 — 클러스터/스토어 표식 없는 맨 숫자 LSN 은 무관한 스토어의 레플리카와도 '같음'으로 비교돼 남의 데이터를 노출하고, 객체 키는 identity 비교라 같은 내용의 {id:1}도 서로 다른 키다. 수량/금액 파라미터는 경계에서 검증되는가 — 음수 qty 는 stock >= qty 가드를 '통과'하면서 이체 방향을 뒤집는다(역방향 절도). 마지막으로 그 방어의 테스트가 구현에 아부하지 않는가 — 구현이 스스로 보고하는 라벨(source:'replica')이 아니라 분기된 데이터로 판별하고, no-op/naive 변이를 실제로 돌려 그 테스트가 '실패함'을 확인했으며, '언젠가 완료/거부된다'류 활성성 검증엔 timeout 이 있어 감지기가 무력화된 변이에서 스위트가 행(hang)이 아니라 실패로 끝나는가?",
4926
4951
  // 11번: 37-코퍼스 렌즈 검증 매트릭스(lens-validation, 2026-07)가 측정한 커버리지 갭 — hot row/부하 계열.
4927
- "쓰기가 한 행/키에 몰리지 않는가(인기 상품 재고·전역 카운터·선착순 쿠폰 — hot row)? — 단일 행 락 경합은 대기·타임아웃·데드락·커넥션 풀 고갈로 번진다. 정확한 실시간 값이 필요 없는 카운터(좋아요·조회수)는 샤딩(counter shard N개에 분산 후 합산)으로 흩고 — 단 정확성이 필요한 재고/잔액 차감엔 샤딩을 함부로 쓰지 마라 —, 경합이 심한 같은 키 작업은 큐 직렬화(같은 키 → 같은 파티션 순차 처리)나 예약 패턴(임시 점유 + expires_at 만료 회수)으로 경합 자체를 줄였는가? 유입이 처리량을 넘어설 때의 backpressure 설계가 있는가 — 큐 상한·rate limit·워커/커넥션 풀 상한·기능별 풀 분리(bulkhead)로 흐름을 제어하지 않고 DB 락만으로 폭주를 받으면 장애가 시스템 전체로 전파된다.",
4952
+ "쓰기가 한 행/키에 몰리지 않는가(인기 상품 재고·전역 카운터·선착순 쿠폰 — hot row)? — 단일 행 락 경합은 대기·타임아웃·커넥션 풀 고갈로 번지고, 다른 락과 얽히면 데드락으로도 이어진다(단일 락 하나만으로 사이클은 안 생긴다). 정확한 실시간 값이 필요 없는 카운터(좋아요·조회수)는 샤딩(counter shard N개에 분산 후 합산)으로 흩고 — 단 정확성이 필요한 재고/잔액 차감엔 샤딩을 함부로 쓰지 마라 —, 경합이 심한 같은 키 작업은 큐 직렬화나 예약 패턴(임시 점유 + expires_at 만료 회수)으로 경합 자체를 줄였는가? 큐로 순서를 지키려면 파티션 co-location 만으론 부족하다 — 같은 키에 in-flight 소비자 1개 + 순서보존 프로듀서 설정(재시도 재정렬 금지) + sink 측 시퀀스/버전 검사까지 갖췄는가? 유입이 처리량을 넘어설 때의 backpressure 설계가 있는가 — 큐 상한·rate limit·워커/커넥션 풀 상한·기능별 풀 분리(bulkhead)로 흐름을 제어하지 않고 DB 락만으로 폭주를 받으면 장애가 시스템 전체로 전파된다.",
4928
4953
  // 12번: 37-코퍼스 매트릭스 partial 승격 2차 — 상태 전이/이벤트 순서/소비 멱등(inbox) 계열.
4929
- "상태(status)를 가진 행의 전이를 허용 목록으로 제한했는가 — 현재-상태 조건 없는 UPDATE ... SET status='REFUNDED' WHERE id=? 는 이미 취소된 주문을 환불하고 배송된 주문을 결제-전으로 되돌린다. 모든 전이는 WHERE status IN (허용 이전 상태) + 버전/시퀀스 조건으로 걸고, 영향 행 0을 불법 전이/충돌로 처리하라. 이벤트를 발행/소비한다면 순서를 aggregate 별 시퀀스·버전으로 지키는가 — created_at 만 믿으면 '주문 취소'가 '배송 시작'보다 늦게 도착하는 역전에 깨진다(상태-조건 UPDATE 가 마지막 방어선). 소비자는 inbox(처리한 message_id 기록, 재수신 무시)로 멱등한가 — at-least-once 전달에서 중복은 정상 상황이고 exactly-once 는 좁은 조건에서만 성립하니 '중복이 온다'를 기본값으로 설계하라. 그리고 같은 멱등키인데 요청 본문이 다르면 기존 결과 재사용이 아니라 거부해야 한다."
4954
+ "상태(status)를 가진 행의 전이를 허용 목록으로 제한했는가 — 현재-상태 조건 없는 UPDATE ... SET status='REFUNDED' WHERE id=? 는 이미 취소된 주문을 환불하고 배송된 주문을 결제-전으로 되돌린다. 모든 전이는 WHERE status IN (허용 이전 상태) 조건으로 걸고 영향 행 0을 불법 전이/충돌로 처리하라 — 비순환 상태머신이면 이 원자적 조건 UPDATE 하나로 직렬화된다(SHIPPED WHERE status='PAID'). 현재 상태만으로 stale 명령을 구분 못 하거나(다른 필드가 관여, 상태가 순환) 할 땐 버전/시퀀스 조건을 추가하라. 이벤트를 발행/소비한다면 순서를 aggregate 별 시퀀스·버전으로 지키는가 — created_at 만 믿으면 '주문 취소'가 '배송 시작'보다 늦게 도착하는 역전에 깨진다(상태-조건 UPDATE 가 마지막 방어선). 소비자는 inbox(처리한 message_id 기록, 재수신 무시)로 멱등한가 — at-least-once 전달에서 중복은 정상 상황이고 exactly-once 는 좁은 조건에서만 성립하니 '중복이 온다'를 기본값으로 설계하라. 그리고 같은 멱등키인데 요청 본문이 다르면 기존 결과 재사용이 아니라 거부해야 한다."
4930
4955
  ],
4931
4956
  questionsEn: [
4932
4957
  "Am I reading a value with SELECT, computing it in app code, then writing it back with UPDATE? Two requests reading the same row concurrently silently drop one write (lost update). Did I switch to an atomic conditional UPDATE (SET n = n + ?) or a version CAS (... WHERE version = ?, treating 0 rows affected as a conflict to retry, not as success)?",
@@ -4934,13 +4959,13 @@ const LENS_CATALOG = {
4934
4959
  "Are the writes that must succeed-or-fail together (multiple rows / tables) wrapped in ONE explicit BEGIN/COMMIT, with ROLLBACK guaranteed on every failure path (exception, early return, timeout)? Under per-statement autocommit a mid-way failure leaves a half-applied partial commit. Did I also confirm the same pinned connection is used throughout, and that the transaction stays short — no external HTTP/RPC or user-wait between BEGIN and COMMIT (a long-open or idle-in-transaction session exhausts the connection pool and, on MVCC engines, bloats the heap and blocks VACUUM; guard with idle_in_transaction_session_timeout)?",
4935
4960
  "Can a non-rollbackable side effect (email, queue publish, external HTTP) or derived data (aggregate, counter, cache, search index) diverge from the source commit? A side effect issued inside the tx can't be rolled back (ghost email); one fired-and-forgotten after commit is lost on a crash between commit and dispatch (an idempotency key prevents duplicates, not this loss). So for effects that MUST arrive, guarantee them with a transactional outbox (persist intent in the same tx; a relay dispatches at-least-once after commit) / durable job row / CDC, and only move an explicitly best-effort effect to publish-after-commit guarded by an idempotency key; and bind derived data to the source atomically (same tx / trigger / outbox / CDC) with a recompute-from-source reconciliation path?",
4936
4961
  "Is this invariant (balance>=0, uniqueness, NOT NULL, referential integrity) enforced by a DB constraint (CHECK/UNIQUE/FK/NOT NULL), or does it live only in app-code validation? App validation is silently bypassed by concurrent writers, other services, backfill scripts, and direct SQL. Did I confirm, with a test that attempts a violation and asserts rejection: (a) the constraint is actually live on this engine/connection (e.g. SQLite needs PRAGMA foreign_keys=ON per connection; a Postgres FK added NOT VALID IS enforced for all new INSERT/UPDATE and only skips validating pre-existing rows), and (b) I'm not tripped by UNIQUE's NULL semantics — standard UNIQUE treats NULLs as distinct, so a 'unique' nullable column silently allows unlimited NULL rows? If NULL is invalid, use NOT NULL; if only one NULL is allowed, use Postgres 15 NULLS NOT DISTINCT or a generated/expression unique (a partial index WHERE col IS NOT NULL does NOT constrain NULLs — it leaves them unlimited; that's the tool for unique-when-present).",
4937
- "Did I consciously choose this transaction's isolation level and add a bounded retry loop for deadlock / serialization failure (re-read fresh state inside the retry, use backoff/jitter, and make sure the retried transaction is free of external side effects so re-running it is safe)? Inheriting an unexamined default can leak dirty reads (READ UNCOMMITTED class: reading a value the writer then rolls back), non-repeatable reads, phantoms, or write skew. MVCC/snapshot engines let each transaction read its own point-in-time snapshot so reads don't block writes — but that is not automatic safety: snapshot isolation is NOT serializability (write skew passes) and lost updates remain (defaults differ by engine — Postgres tends to READ COMMITTED, MySQL InnoDB tends to REPEATABLE READ). Did I use the RIGHT retry error codes (Postgres deadlock 40P01 / serialization 40001; MySQL deadlock 40001/1213 / lock-wait timeout 1205 — 1205 by default rolls back only the statement, so retry the WHOLE transaction from BEGIN or set innodb_rollback_on_timeout; SQLite: handle SQLITE_BUSY/BUSY_SNAPSHOT with busy_timeout, BEGIN IMMEDIATE for write txns, WAL where apt)? And if a COMMIT is left in-doubt by a timeout/dropped connection, do I NOT assume rollback but reconcile actual persistence via idempotency key / transaction id / source-of-truth read before retrying? Snapshot isolation / Postgres REPEATABLE READ does NOT stop write skew (needs SERIALIZABLE or explicit locking), so did I guard sum/overlap invariants that way — and do all paths that lock multiple rows/tables acquire them in the same order?",
4962
+ "Did I consciously choose this transaction's isolation level and add a bounded retry loop for deadlock / serialization failure (re-read fresh state inside the retry, use backoff/jitter, and make sure the retried transaction is free of external side effects so re-running it is safe)? Inheriting an unexamined default can leak dirty reads (READ UNCOMMITTED class: reading a value the writer then rolls back), non-repeatable reads, phantoms, or write skew. An MVCC engine's ordinary non-locking reads generally don't block writers (locking reads like FOR UPDATE do) — but that is not automatic safety: snapshot scope depends on isolation (READ COMMITTED takes a fresh snapshot per statement; REPEATABLE READ one per transaction), and snapshot isolation is NOT serializability (write skew passes). Lost-update exposure also depends on isolation — Postgres READ COMMITTED leaks it, but Postgres REPEATABLE READ aborts a same-row concurrent update as a serialization failure rather than silently losing it (defaults differ by engine — Postgres tends to READ COMMITTED, MySQL InnoDB tends to REPEATABLE READ). Did I use the RIGHT retry error codes (Postgres deadlock 40P01 / serialization 40001; MySQL deadlock 40001/1213 / lock-wait timeout 1205 — 1205 by default rolls back only the statement, so retry the WHOLE transaction from BEGIN or set innodb_rollback_on_timeout; SQLite: wait on retryable SQLITE_BUSY with busy_timeout, but SQLITE_BUSY_SNAPSHOT means a stale WAL snapshot that waiting can't fix — roll back and restart the transaction from a fresh snapshot; use BEGIN IMMEDIATE preventively for write txns)? And if a COMMIT is left in-doubt by a timeout/dropped connection, do I NOT assume rollback but reconcile actual persistence via idempotency key / transaction id / source-of-truth read before retrying? Snapshot isolation / Postgres REPEATABLE READ does NOT stop write skew (needs SERIALIZABLE or explicit locking), so did I guard sum/overlap invariants that way — and do all paths that lock multiple rows/tables acquire them in the same order?",
4938
4963
  "Did the write I returned 'success' for actually reach a checked COMMIT (did I await the commit and inspect its result), and does that ack mean stable storage — WAL/redo/journal flushed on commit — not just a memory buffer? With autocommit off and a forgotten COMMIT, the open transaction is silently rolled back when the connection returns to the pool. Durability settings differ by engine — did I verify the one matching this data's class (e.g. Postgres synchronous_commit, MySQL innodb_flush_log_at_trx_commit=1 + sync_binlog=1, SQLite synchronous=FULL; Redis is not durable by default)? For must-survive-node-loss data, did I require a quorum / synchronous-replication ack (e.g. w:majority, synchronous standby) so one node dying can't lose it, and state the failover RPO?",
4939
4964
  "Am I relying on an in-process mutex/singleton/in-memory dedup, or assuming one DB transaction can wrap work spanning multiple services, or reading a just-written value from a lagging read replica? With >=2 instances an in-process lock is useless, so move mutual exclusion to a shared point (DB UNIQUE constraint, row lock, advisory lock; pull queue work with FOR UPDATE SKIP LOCKED); atomicity can't cross a process/network boundary, so route cross-service work through an outbox + saga + compensating actions and collapse at-least-once delivery with an idempotency key (UNIQUE); and replication lag breaks read-your-writes, so route read-after-write / consistency-critical reads to the primary or gate on replica LSN/lag. If I use a distributed lock, did I issue a monotonically increasing fencing token (DB sequence / row version) and have the guarded resource reject lower tokens, so a worker that resumes after its lease expired can't overwrite newer work?",
4940
- "Can a user callback wrapped by a transaction/lock/lease/single-flight return a promise? An async callback is synchronous only until its first suspension — if the returned promise isn't awaited, its continuations run after COMMIT/release, outside the protection (an outbox relay marking sent=1 before an async publish rejects; a fenced write landing after release). Did I await by design, or explicitly reject thenables? Does every 'track this operation' structure (single-flight map, lock table, in-progress registry) finish REGISTERING before user code can run or throw synchronously — a callback that throws pre-registration leaves the failure path staring at half-initialized state (the real error swallowed, a dead entry poisoning the key forever)? Is abort/release a real barrier that removes EVERY trace of the operation (held locks, queue entries, invalidation flags, generation counters) — a leftover queue entry hands the lock to a dead transaction, and a suspended acquisition loop without a post-await generation re-check keeps acquiring after the abort. And does cleanup use an identity guard (delete only if the map entry is still MINE) so it never removes a successor's entry?",
4965
+ "Can a user callback wrapped by a transaction/lock/lease/single-flight return a promise? An async callback is synchronous only until its first suspension — if the returned promise isn't awaited, its continuations run after COMMIT/release, outside the protection (an outbox relay marking sent=1 before an async publish rejects; a fenced write landing after release). Did I await by design, or explicitly reject thenables? Does every 'track this operation' structure (single-flight map, lock table, in-progress registry) finish REGISTERING before user code can run or throw synchronously — a callback that throws pre-registration leaves the failure path staring at half-initialized state (the real error swallowed, a dead entry poisoning the key forever)? Is abort/release a real barrier that removes transient ownership (held locks, queue entries, invalidation flags) while PRESERVING or monotonically advancing the generation counter / tombstone that rejects a stale continuation — a leftover queue entry hands the lock to a dead transaction, but conversely DELETING the generation counter lets a resuming operation be reassigned the same generation and fail to detect it's stale (the counter must advance, not reset). A suspended acquisition loop without a post-await generation re-check also keeps acquiring after the abort. And does cleanup use an identity guard (delete only if the map entry is still MINE) so it never removes a successor's entry?",
4941
4966
  "Does EVERY writer participate in the invariant that a version column / monotonic token / watermark protects — one bypassing path (an INSERT OR REPLACE resetting version to its default, a plain assignment rolling a token backward, a migration/seed/admin write) lets a stale snapshot's CAS pass (ABA). Does the token/key carry identity — a bare numeric LSN with no cluster/store branding compares 'equal' against an unrelated store's replica and exposes foreign data, and object keys compare by identity so two {id:1} objects are different keys. Are quantity/amount parameters validated at the boundary — a negative qty PASSES a stock >= qty guard while inverting the transfer's direction (reverse theft). Finally, do the tests for these defenses avoid flattering the implementation — assert on divergent data rather than a self-reported label (source:'replica'), actually run the no-op/naive mutant and watch the test FAIL it, and put timeouts on 'eventually completes/rejects' liveness checks so a disabled detector fails the suite instead of hanging it?",
4942
- "Are writes converging on one hot row/key (a popular product's stock, a global counter, a first-come coupon)? Single-row lock contention cascades into waits, timeouts, deadlocks, and connection-pool exhaustion. For counters that don't need an exact real-time value (likes, views), spread the load with sharding (N counter-shard rows summed on read) — but don't casually shard stock/balance decrements that need exactness; for heavily contended same-key work, reduce the contention itself with queue-based serialization (same key same partition, processed in order) or a reservation pattern (temporary hold + expires_at reclamation). And when inflow exceeds throughput, is backpressure designed in — queue bounds, rate limits, worker/connection-pool caps, per-feature pool isolation (bulkhead)? Absorbing a surge with DB locks alone propagates the failure system-wide.",
4943
- "Are the transitions of a status-bearing row restricted to an allow-list — an UPDATE ... SET status='REFUNDED' WHERE id=? with no current-state condition refunds an already-cancelled order and drags a shipped one back to pre-payment. Gate every transition with WHERE status IN (allowed prior states) plus a version/sequence condition, and treat 0 affected rows as an illegal transition or a conflict. If you publish/consume events, is ordering kept per aggregate via a sequence/version — trusting created_at alone breaks when 'order cancelled' arrives after 'shipping started' (the state-conditioned UPDATE is the last line of defense). Is the consumer idempotent via an inbox (record processed message_ids, ignore re-deliveries) — under at-least-once delivery duplicates are NORMAL, and exactly-once holds only under narrow conditions, so design for 'duplicates will come'. And if the same idempotency key arrives with a different request body, reject it instead of replaying the stored result."
4967
+ "Are writes converging on one hot row/key (a popular product's stock, a global counter, a first-come coupon)? Single-row lock contention cascades into waits, timeouts, and connection-pool exhaustion, and can contribute to a deadlock when it entangles with other locks (one contended lock alone can't form a cycle). For counters that don't need an exact real-time value (likes, views), spread the load with sharding (N counter-shard rows summed on read) — but don't casually shard stock/balance decrements that need exactness; for heavily contended same-key work, reduce the contention itself with queue-based serialization or a reservation pattern (temporary hold + expires_at reclamation). Same-key partitioning only co-locates work serial processing also needs a single in-flight consumer per key, an ordering-preserving producer config (no reordering on retry), and a sink-side sequence/version check. And when inflow exceeds throughput, is backpressure designed in — queue bounds, rate limits, worker/connection-pool caps, per-feature pool isolation (bulkhead)? Absorbing a surge with DB locks alone propagates the failure system-wide.",
4968
+ "Are the transitions of a status-bearing row restricted to an allow-list — an UPDATE ... SET status='REFUNDED' WHERE id=? with no current-state condition refunds an already-cancelled order and drags a shipped one back to pre-payment. Gate every transition with WHERE status IN (allowed prior states) and treat 0 affected rows as an illegal transition or conflict — for an acyclic state machine that atomic conditional UPDATE alone serializes it (SHIPPED WHERE status='PAID'). Add a version/sequence condition when the current state alone can't distinguish a stale or concurrent command (other fields participate, or states can cycle). If you publish/consume events, is ordering kept per aggregate via a sequence/version — trusting created_at alone breaks when 'order cancelled' arrives after 'shipping started' (the state-conditioned UPDATE is the last line of defense). Is the consumer idempotent via an inbox (record processed message_ids, ignore re-deliveries) — under at-least-once delivery duplicates are NORMAL, and exactly-once holds only under narrow conditions, so design for 'duplicates will come'. And if the same idempotency key arrives with a different request body, reject it instead of replaying the stored result."
4944
4969
  ],
4945
4970
  affects: ['code', 'test'], affectsNote: '동시성 방어엔 인터리빙/부하/재시도/멀티인스턴스 실패를 재현하는 테스트가 따라와야 함(단일 인스턴스 초록불은 증거가 아님)',
4946
4971
  affectsNoteEn: 'a concurrency defense needs an interleaving/load/retry/multi-instance reproduction test'
@@ -4966,10 +4991,13 @@ function _mergeLensCatalog(builtin, custom) {
4966
4991
  if (out[k]) {
4967
4992
  const seen = new Set(out[k].questions);
4968
4993
  for (const q of cq) if (!seen.has(q)) { out[k].questions.push(q); seen.add(q); }
4969
- out[k].questions = out[k].questions.slice(0, 16);
4994
+ const before = out[k].questions.length;
4995
+ out[k].questions = out[k].questions.slice(0, 16); // 내장이 앞에 있어 커스텀이 tail 에서 절단됨(내장 보존)
4996
+ if (before > 16) out[k]._droppedCustom = before - 16; // codex F6: 조용한 절단 대신 가시화 (렌더가 경고)
4970
4997
  out[k]._customAdded = cq.length > 0;
4971
4998
  } else if (cq.length) {
4972
4999
  out[k] = { title: (c.title || k), persona: (c.persona || '검토자'), questions: cq.slice(0, 16), affects: Array.isArray(c.affects) ? c.affects : [], affectsNote: c.affectsNote || '(프로젝트 정의)', _custom: true };
5000
+ if (cq.length > 16) out[k]._droppedCustom = cq.length - 16;
4973
5001
  }
4974
5002
  }
4975
5003
  return out;
@@ -5064,6 +5092,7 @@ function lensCmd(domain, opts = {}) {
5064
5092
  let qs = l.questions;
5065
5093
  if (L === 'en' && l.questionsEn) qs = l.questions.length > l.questionsEn.length ? [...l.questionsEn, ...l.questions.slice(l.questionsEn.length)] : l.questionsEn;
5066
5094
  log(`## ${key} (${title}) — ${t('페르소나', 'persona')}: ${persona}${cflag}`);
5095
+ if (l._droppedCustom) log(t(` ⚠ 프로젝트 질문 ${l._droppedCustom}개가 12+커스텀 상한(16)을 넘어 잘림 — 핵심 질문을 앞쪽에 두거나 별도 도메인으로 분리하세요`, ` ⚠ ${l._droppedCustom} project question(s) dropped past the 12+custom cap (16) — put key ones first or use a separate domain`));
5067
5096
  qs.forEach((q, i) => log(` ${i + 1}. ${q}`));
5068
5097
  log(t(` ↔ 인과: ${key} 를 바꾸면 → ${(l.affects || []).join(', ') || '(없음)'} 질문도 다시 — ${note}`, ` ↔ causality: change ${key} → re-check ${(l.affects || []).join(', ') || '(none)'} too — ${note}`));
5069
5098
  }
@@ -7708,11 +7737,13 @@ function planRemoveCmd(root, target) {
7708
7737
  if (!exists(pp)) return fail('plan.md 없음');
7709
7738
  const text = read(pp);
7710
7739
  // milestone 블록은 "### M-XXXX. 제목" 으로 시작; "## " (Out of Scope 등) 헤더 또는 EOF 이전까지
7711
- const blocks = text.split(/\n(?=### )/);
7740
+ // codex 버그헌트 P2: `### ` 만 경계로 쓰면 마지막 마일스톤 뒤의 사용자 h1/h2 섹션(예: `## Risks`)
7741
+ // 그 마일스톤 블록에 흡수돼 함께 삭제된다. 모든 헤딩(#{1,3} )을 경계로 잘라 각 섹션을 독립 블록화.
7742
+ const blocks = text.split(/\n(?=#{1,3} )/);
7712
7743
  let removed = 0;
7713
7744
  const kept = [];
7714
7745
  for (const b of blocks) {
7715
- if (!b.startsWith('### ')) { kept.push(b); continue; }
7746
+ if (!b.startsWith('### ')) { kept.push(b); continue; } // 마일스톤(### M-) 블록만 삭제 후보
7716
7747
  const headerMatch = b.match(/^### (.+)$/m);
7717
7748
  if (!headerMatch) { kept.push(b); continue; }
7718
7749
  const titleLine = headerMatch[1].trim();
@@ -7735,7 +7766,7 @@ function planRemoveCmd(root, target) {
7735
7766
  }
7736
7767
  kept.push(b);
7737
7768
  }
7738
- if (removed === 0) return fail(`매칭 milestone 없음: "${target}"`);
7769
+ if (removed === 0) return failJson(has('--json'), 'milestone_not_found', `매칭 milestone 없음: "${target}"`); // codex P2: --json 에러 구조화
7739
7770
  writeUtf8(pp, kept.join('\n'));
7740
7771
  ok(`milestone removed: ${removed}건 (보존: .harness/plan.archive.md)`);
7741
7772
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7909,7 +7940,7 @@ function taskUpdate(root, id) {
7909
7940
  }
7910
7941
  if (!_validateChoice(arg('--status', null), TASK_STATUSES, 'task status')) { process.exitCode = 1; return; } // 1.9.310 (UR-0046)
7911
7942
  const rows = readProgressRows(root);
7912
- if (!rows.find(r => r.id === id)) { fail(`task ${id} not found in progress-tracker.md`); return; }
7943
+ if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
7913
7944
  const patch = { id };
7914
7945
  if (arg('--status') !== null) patch.status = _normTaskStatus(arg('--status')); // 1.11.3 (UR-0178): completed/verified → done 정규화
7915
7946
  if (arg('--evidence') !== null) patch.evidence = arg('--evidence');
@@ -7927,7 +7958,7 @@ function taskDrop(root, id) {
7927
7958
  if (!id) return fail('id required');
7928
7959
  const rows = readProgressRows(root);
7929
7960
  // 1.9.396 (6번째 외부평가/codex P1-B): 없는 task drop 시 가짜 row(request undefined) 생성 = 데이터 손상 → task update 와 동일하게 존재 확인 후 fail(no-op).
7930
- if (!rows.find(r => r.id === id)) { fail(`task ${id} not found in progress-tracker.md`); return; }
7961
+ if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
7931
7962
  upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
7932
7963
  ok(`task dropped: ${id}`);
7933
7964
  _autoRoadmap(absRoot(root), 'data-change');
@@ -8125,6 +8156,12 @@ function memoryRestoreCmd(root, surface, target) {
8125
8156
  for (const blk of restoredBlocks) {
8126
8157
  append(activePath, '\n' + blk + '\n');
8127
8158
  }
8159
+ // codex 버그헌트 P1: decisions/lessons 는 canonical JSON 이 진실소스라(list/save 가 JSON 기준),
8160
+ // MD 프로젝션에만 append 하면 복원분이 list 에 안 보이고 다음 add/save 가 JSON→MD 재생성으로 영구 삭제.
8161
+ // 방금 복원된 MD 를 canonical 파서로 재파싱해 JSON 을 재빌드 → 복원분이 후속 write 에도 생존.
8162
+ if (surface === 'decisions') _saveDecisions(root, _decisionsFromMd(read(activePath)));
8163
+ else if (surface === 'lessons') _saveLessons(root, _parseLessonEntries(read(activePath)));
8164
+ // (plan 은 plan.md 가 진실소스라 append 로 충분)
8128
8165
  // archive 재작성 — 모두 제거되면 파일 비움 (헤더만 남김 또는 삭제)
8129
8166
  if (kept.length === 0) {
8130
8167
  // archive 헤더만 남겨도 의미 있음 — 향후 다시 사용 가능
@@ -8189,19 +8226,21 @@ function lessonDropCmd(root, target) {
8189
8226
  const kept = [];
8190
8227
  const removed = [];
8191
8228
  const all = _loadLessons(root);
8192
- if (!all.length) return fail('lessons 없음');
8229
+ if (!all.length) return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
8193
8230
  for (const l of all) {
8194
8231
  if (l.date === target || (l.text || '').includes(target)) removed.push(l);
8195
8232
  else kept.push(l);
8196
8233
  }
8197
- if (!removed.length) return fail(`매칭 lesson 없음: "${target}"`);
8234
+ if (!removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
8198
8235
  // archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
8199
8236
  const archivePath = path.join(root, '.harness/lessons.archive.md');
8200
8237
  const archiveHeader = exists(archivePath) ? '' : '# Lessons archive\n\n';
8238
+ // codex 버그헌트 P2: 사용자 텍스트(l.text/l.tag/target)를 archive MD 에 raw 삽입하면 개행으로
8239
+ // `## 제거 …` 델리미터를 위조해 가짜 archive 엔트리를 만들 수 있다(archive list/restore 오염). _lineSafe 로 개행 무력화.
8201
8240
  const archiveBlocks = removed.map(l =>
8202
- `\n### ${l.date}\n- Lesson: ${l.text}\n${l.tag ? `- Tag: ${l.tag}\n` : ''}`
8241
+ `\n### ${_lineSafe(l.date)}\n- Lesson: ${_lineSafe(l.text)}\n${l.tag ? `- Tag: ${_lineSafe(l.tag)}\n` : ''}`
8203
8242
  ).join('');
8204
- append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
8243
+ append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${_lineSafe(target)}")\n${archiveBlocks}\n`);
8205
8244
  _saveLessons(root, kept);
8206
8245
  ok(`lesson dropped: ${removed.length}건 (보존: .harness/lessons.archive.md)`);
8207
8246
  _autoRoadmap(absRoot(root), 'data-change');
@@ -8278,22 +8317,23 @@ function decisionDropCmd(root, target) {
8278
8317
  if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
8279
8318
  // 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
8280
8319
  const all = _loadDecisions(root);
8281
- if (!all.length) return fail('decisions 없음');
8320
+ if (!all.length) return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
8282
8321
  const kept = [];
8283
8322
  const removed = [];
8284
8323
  for (const d of all) {
8285
8324
  if (d.date === target || (d.title || '').includes(target)) removed.push(d);
8286
8325
  else kept.push(d);
8287
8326
  }
8288
- if (!removed.length) return fail(`매칭 decision 없음: "${target}"`);
8327
+ if (!removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
8289
8328
  // archive 보존 (projection MD 블록 형태)
8290
8329
  const archivePath = path.join(root, '.harness/decisions.archive.md');
8291
8330
  const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
8331
+ // codex 버그헌트 P2: 사용자 필드 raw 삽입 → 개행으로 `## 제거` 델리미터 위조 가능. _lineSafe 로 무력화.
8292
8332
  const archiveBlocks = removed.map(d => {
8293
- const head = d.date ? `${d.date} — ${d.title}` : d.title;
8294
- return `\n### ${head}\n- Decision: ${d.decision || ''}\n- Reason: ${d.reason || ''}\n- Alternatives: ${d.alternatives || ''}\n- Impact: ${d.impact || ''}\n`;
8333
+ const head = d.date ? `${_lineSafe(d.date)} — ${_lineSafe(d.title)}` : _lineSafe(d.title);
8334
+ return `\n### ${head}\n- Decision: ${_lineSafe(d.decision || '')}\n- Reason: ${_lineSafe(d.reason || '')}\n- Alternatives: ${_lineSafe(d.alternatives || '')}\n- Impact: ${_lineSafe(d.impact || '')}\n`;
8295
8335
  }).join('');
8296
- append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
8336
+ append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${_lineSafe(target)}")\n${archiveBlocks}\n`);
8297
8337
  _saveDecisions(root, kept);
8298
8338
  ok(`decision dropped: ${removed.length}건 (보존: .harness/decisions.archive.md)`);
8299
8339
  _autoRoadmap(absRoot(root), 'data-change');
@@ -10962,7 +11002,10 @@ function verifyClaimCmd(root, taskId, opts = {}) {
10962
11002
  // 1.36.10 (선재 백로그): Windows 백슬래시 경로 지원 — 'src\\inventory.mjs' 가 'inventory.mjs' 로,
10963
11003
  // 'db\\migrations\\001_create.sql' 이 'create.sql' 로 잘려 추출돼 존재 검사가 false-fail 하던 결함.
10964
11004
  // 구분자에 \\ 허용 + 추출 후 / 로 정규화(하류의 git diff 비교·렌즈 매핑은 / 기준).
10965
- const FILE_RE = new RegExp(`(?:[A-Za-z][A-Za-z0-9_-]*[\\/\\\\])?[A-Za-z][\\w./\\\\-]*\\.(?:${FILE_EXTS})\\b`, 'g');
11005
+ // codex 버그헌트 P2: basename 첫 글자를 [A-Za-z] 로만 잡아 '123.js'·'2fa.ts' 같은 숫자시작 실파일이
11006
+ // 추출 안 돼 존재검사가 vacuous-pass → 없는 파일 인용한 done 주장이 통과(gate FN). 첫 글자에 숫자 허용.
11007
+ // (prev-separator 가드가 UNC/드라이브/URL 꼬리 오탐은 계속 차단하므로 FP 억제 유지)
11008
+ const FILE_RE = new RegExp(`(?:[A-Za-z][A-Za-z0-9_-]*[\\/\\\\])?[A-Za-z0-9][\\w./\\\\-]*\\.(?:${FILE_EXTS})\\b`, 'g');
10966
11009
  // 1.36.11 (클린룸 P1-B): 매치 직전 문자가 경로 구분자면 더 긴 경로의 "꼬리"만 잡은 것(UNC \\\\server\\..., 드라이브 X:\\...,
10967
11010
  // 숫자 시작 선두 세그먼트 등) — 잘린 꼬리를 루트-상대 경로로 검증하면 무관한 로컬 파일이 통과(우회성). 그런 매치는 버린다.
10968
11011
  const filePatterns = [];
@@ -14538,7 +14581,7 @@ function ruleRemove(root, id) {
14538
14581
  if (!id) return fail('id required');
14539
14582
  const rules = readRules(root);
14540
14583
  const i = rules.findIndex(r => r.id === id);
14541
- if (i < 0) return fail(`rule not found: ${id}`);
14584
+ if (i < 0) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
14542
14585
  const removed = rules.splice(i, 1)[0];
14543
14586
  writeRules(root, rules);
14544
14587
  const archive = exists(rulesArchivePath(root)) ? read(rulesArchivePath(root)) : '# Rules archive\n\n| ID | Trigger | Rule | Added | Status | Removed |\n|---|---|---|---|---|---|\n';
package/lib/analyzers.js CHANGED
@@ -36,7 +36,8 @@ function _shellGuardAnalyze(cmd, ctx) {
36
36
  }
37
37
  function _evidenceQuality(evidence) {
38
38
  const e = String(evidence || '');
39
- const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|prisma|sh)\b/i.test(e);
39
+ // codex 버그헌트 P2: basename 첫 글자에 숫자 허용 ('123.js' 등 숫자시작 실파일) — bin FILE_RE 와 정합.
40
+ const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z0-9][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|prisma|sh)\b/i.test(e);
40
41
  const hasTest = /(\d+)\s*(?:\/\s*\d+\s*)?(?:통과|passed|passing|개\s*테스트)|\btests?\b\s*[:=]?\s*\d|Tests?:\s*\d|\b\d+\s*tests?\b/i.test(e);
41
42
  const hasLog = /Exit\s*[:=]|exit\s*code|Command\s*[:=]|npm\s+(?:test|run)|pytest|cargo\s+test|go\s+test/i.test(e);
42
43
  const missing = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.15",
3
+ "version": "1.36.17",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",