relio
요약. relio: 자율 개선 회차 28회, 릴리즈 12건. 최근 릴리즈 v1.11.19 (자산 1개). 건강 D
- 28회차
- 1프로젝트
- 8배포 준비 완료
- 4릴리즈 진행 중
- 6병합 완료
- 8검토 대기
- 2검증 실패
- 0변경 없음
- 0실행 오류
- $52.85비용
- 2시간 5분에이전트 시간
현황
- 저장소
- https://github.com/hkjang/relio
- 마지막 회차
- 2026-09-12 08:51 KST — ✅ 머지 merged PR #18 (approved)
- 최근 릴리즈
- v1.11.19 — released · 자산 1개 (이전 v1.11.14: 1개) 전체 릴리즈 →
회차 이력
| 일시 | 프로젝트 | 결과 |
|---|---|---|
| 2026-09-12 08:51 | relio | 병합 완료 merged PR #18 (approved) |
| 2026-09-11 18:11 | relio | 병합 완료 fix-round: merged PR #19 |
| 2026-09-11 12:49 | relio | 검토 대기 needs approval (risk=low, files=48), PR open PR #18 |
| 2026-09-11 08:32 | relio | 검증 실패 verify failed: 실패한 검증: go build ./... (exit 1) |
| 2026-09-10 15:43 | relio | 검토 대기 needs approval (risk=medium, files=3), PR open PR #17 |
| 2026-09-10 01:10 | relio | 병합 완료 merged PR #16 |
| 2026-09-09 13:11 | relio | 병합 완료 merged PR #15 (approved) |
| 2026-09-09 12:41 | relio | 검토 대기 review held, PR open PR #15 |
| 2026-09-09 03:15 | relio | 병합 완료 merged PR #14 |
| 2026-09-08 22:19 | relio | 병합 완료 merged PR #13 |
| 2026-09-08 09:53 | relio | 배포 준비 완료 release-only, released v1.11.19 |
| 2026-09-07 11:44 | relio | 검토 대기 approved PR #12 |
| 2026-09-07 11:14 | relio | 검토 대기 approved PR #12 |
| 2026-09-07 10:59 | relio | 검토 대기 approved PR #12 |
| 2026-09-07 10:45 | relio | 검토 대기 approved PR #12 |
| 2026-09-07 04:09 | relio | 검증 실패 verify failed: secrets in diff |
| 2026-09-06 19:19 | relio | 검토 대기 fix-round: guarded files, PR open PR #12, rollback PR |
| 2026-09-06 19:02 | relio | 릴리즈 진행 중 merged PR #11, released v1.11.18, ASSETS MISSING (release workflow FAILED), queued for fix |
| 2026-09-05 08:12 | relio | 릴리즈 진행 중 merged PR #10, released v1.11.17, ASSETS MISSING |
| 2026-09-05 07:33 | relio | 릴리즈 진행 중 merged PR #9, released v1.11.16, ASSETS MISSING |
| 2026-09-04 16:19 | relio | 릴리즈 진행 중 merged PR #8, released v1.11.15, ASSETS MISSING |
| 2026-09-04 15:28 | relio | 배포 준비 완료 merged PR #7, released v1.11.14 |
| 2026-09-04 00:50 | relio | 배포 준비 완료 merged PR #6, released v1.11.13 |
| 2026-09-03 17:17 | relio | 배포 준비 완료 merged PR #5, released v1.11.12 |
| 2026-09-03 10:16 | relio | 배포 준비 완료 merged PR #4, released v1.11.11 |
| 2026-09-03 04:47 | relio | 배포 준비 완료 merged PR #3, released v1.11.10 |
| 2026-09-02 22:29 | relio | 배포 준비 완료 merged PR #2, released v1.11.9 |
| 2026-09-02 16:17 | relio | 배포 준비 완료 merged PR #1, released v1.11.8 |
비용·사용량
| 시각 | 프로젝트 | 단계 | 시간 | 턴 | 비용 | 토큰 입력/출력 | 종료 |
|---|---|---|---|---|---|---|---|
| 18:08 | relio | review | 1분 | 7 | $0.35 | 189K / 4K | success |
| 18:07 | relio | 개선 | 8분 | 49 | $3.59 | 3.8M / 30K | success |
| 12:49 | relio | review | 3분 | 24 | $1.83 | 1.6M / 10K | success |
| 12:47 | relio | 개선 | 6분 | 57 | $3.86 | 4.7M / 21K | success |
| 08:32 | relio | 개선 | 23분 | 103 | $10.52 | 12.6M / 82K | success |
| 15:43 | relio | review | 5분 | 26 | $1.56 | 1.3M / 17K | success |
| 15:38 | relio | 개선 | 8분 | 42 | $2.97 | 2.6M / 35K | success |
| 01:09 | relio | review | 3분 | 16 | $0.85 | 563K / 10K | success |
| 01:06 | relio | 개선 | 5분 | 36 | $2.28 | 1.9M / 25K | success |
| 12:41 | relio | review | 2분 | 14 | $0.95 | 476K / 10K | success |
| 12:37 | relio | 개선 | 7분 | 60 | $3.46 | 3.4M / 32K | success |
| 03:14 | relio | review | 4분 | 22 | $1.29 | 826K / 15K | success |
| 03:10 | relio | 개선 | 11분 | 83 | $4.79 | 5.2M / 44K | success |
| 22:18 | relio | review | 2분 | 12 | $0.71 | 432K / 7K | success |
| 22:16 | relio | 개선 | 6분 | 34 | $2.03 | 1.6M / 22K | success |
| 09:46 | relio | 릴리즈 | 3분 | 23 | $1.13 | 855K / 12K | success |
| 04:09 | relio | 개선 | 9분 | 47 | $3.40 | 2.9M / 36K | success |
| 19:18 | relio | 개선 | 9분 | 45 | $3.25 | 3.2M / 31K | success |
| 18:42 | relio | 릴리즈 | 2분 | 19 | $0.90 | 578K / 8K | success |
| 18:39 | relio | review | 3분 | 12 | $0.73 | 394K / 10K | success |
| 18:36 | relio | 개선 | 6분 | 45 | $2.40 | 2.2M / 26K | success |
아이디어 백로그 — 대기 14 / 전체 17
| 아이디어 | 가치/위험/크기 | 상태 | 메모 | 갱신 |
|---|---|---|---|---|
| ClientIP 가 X-Forwarded-For/Forwarded 를 읽지 않아 리버스 프록시 뒤에서 로그인 리미터가 프록시 주소 하나로 묶이고 감사 IP 가 무의미함 | 4/2/M | 대기 | 2026-09-11 가이드 작성 중 확인(internal/platform/httpx/httpx.go ClientIP 는 RemoteAddr 만 읽음). 신뢰 프록시 목록 없이 헤더를 믿으면 스푸핑이 되므로 관리자 설정(예: security.trusted_proxies)과 함께 붙여야 하고, 환경 변수 계약(4개)은 넓히지 않는다. 문서에는 현 동작을 그대로 적어 둠. | 2026-09-11 |
| security.allowed_origins 설정은 관리자 화면에 있으나 Go 코드 어디에서도 읽지 않음 — 적용하거나 화면에서 제거 | 3/1/S | 대기 | 2026-09-11 확인: grep 결과 mcp.allowed_origins 만 internal/mcp/server.go 에서 읽힘. 001_initial.sql 시드와 admin 화면(보안 · 파일 · 접속)에는 존재. 관리자가 값을 넣어도 아무 효과가 없어 잘못된 안전감을 준다. ADMIN_GUIDE 설정 표에 '읽지 않음' 으로 적어 둠 — 고치면 문서도 갱신. | 2026-09-11 |
| 네 intelligence 필터의 Cursor 필드는 죽은 코드 — 커서 페이징을 붙이거나 제거 | 3/1/M | 대기 | internal/intelligence/signals.go 의 SignalFilter·RiskFilter·InsightFilter·RecommendationFilter 가 Cursor 를 선언하지만 queries.go 의 어떤 질의도 쓰지 않고 crm_intelligence.go 의 어떤 핸들러도 채우지 않는다. clampLimit 상한 200. crm.Page/parseCursor 패턴이 있다. | 2026-09-11 |
| DealsAtRisk·Coaching 은 열린 딜 200건만 보고 나머지는 조용히 빠짐 | 3/2/M | 대기 | 둘 다 ListOpportunities(Status:OPEN, Limit:200) 로 '가장 최근 갱신된' 200건만 받는다. 2026-09-10 에 묶음 채점이 왕복 7회로 싸졌으므로 커서로 페이지를 돌며 전체를 덮어도 비용이 감당된다 — 상한(예: 2000건)과 메모리·응답 크기를 함께 본다. | 2026-09-11 |
| list_activities·get_contracts·list_quotations 등 나머지 MCP 목록 도구는 페이징 자체가 없음 | 3/2/M | 대기 | 서비스가 슬라이스만 돌려주어 상위 N건 뒤는 에이전트가 볼 수 없다. search_customers·list_opportunities 에만 커서가 있다(2026-09-04). | 2026-09-11 |
| 로컬 로그인이 꺼져 있을 때 /auth/login 이 403 과 401 로 bootstrap 계정 여부를 알려줌 | 3/2/S | 대기 | internal/server/public.go: local_login_enabled=false 면 bootstrap 이 아닌 사용자명(없는 것 포함)은 403 local_login_disabled, bootstrap 만 401 로 넘어가 구분된다. 응답을 합치면 SSO 전용 인스턴스에서 사용자가 왜 막혔는지 모르게 되므로 UX 절충 필요. | 2026-09-11 |
| CI 에 gofmt -l 검사 추가 (shellcheck 도 함께) | 2/1/S | 대기 | .github/workflows/ci.yml 의 Backend static analysis 단계 옆에 붙이면 된다. 지금은 포맷 위반이 CI 를 통과한다. scripts/ 에 bash 가 늘고 있다. | 2026-09-11 |
| audit.Record 는 Exec 실패를 로그 한 줄로 흘림 — 운영자가 볼 수 있는 곳으로 올리기 | 2/1/S | 대기 | 2026-09-08 에 nullableJSON 과 ClientIP 는 고쳤으나 Record 자체의 실패 처리는 그대로. '감사 쓰기 실패' 를 Admin Command Center 진단에 노출하는 것이 남은 값이며 DB 의존이라 작업량이 더 크다. | 2026-09-11 |
| minScore 등 다른 좁히기 필터에도 ClampQuery 적용 검토 | 2/1/S | 대기 | crm_intelligence.go 의 minScore 는 fallback 0 이 필터를 끄는 expiringDays 와 같은 구조. year(fallback 0, min 2000) 처럼 fallback 이 sentinel 인 경우는 클램프가 의미를 바꾸므로 케이스별 판단. | 2026-09-11 |
| 관리자 가이드 부록의 캡처 절차가 compose 기본 포트 8080·기본 초기 비밀번호를 그대로 쓰라고 안내함 — 임시 포트·임시 비밀번호 override 예시로 바꾸기 | 2/1/S | 대기 | 2026-09-11 회차는 /tmp 의 compose override(127.0.0.1:18080, BOOTSTRAP_ADMIN_PASSWORD 를 openssl rand 로) 로 띄웠음. 부록에 그 방식을 적으면 8080 을 쓰는 다른 스택과 충돌하지 않고 compose.yaml 의 공개된 초기 비밀번호로 로그인하는 일도 없음. 문서만의 변경. | 2026-09-11 |
| PR #18 리뷰가 남긴 screenshots.mjs 의 두 결함 — 헤더 주석이 '복원할 것이 없다' 고 하지만 rate limit 을 바꿨다 되돌리고, Chrome 후보 목록이 .find(Boolean) 이라 존재 확인 없이 첫 경로만 씀 | 2/1/S | 대기 | PR #18 브랜치(auto/2026-09-11-1241)의 scripts/guide/screenshots.mjs:7 과 :46. 머지 뒤에 고친다 — 지금 main 에는 그 파일이 없다. fs.existsSync 로 후보를 걸러 첫 존재 경로를 쓰고, 없으면 RELIO_GUIDE_CHROME 을 요구하는 오류로 멈춘다. docs/index_en.html 의 가이드 설명 문구도 한국어 페이지처럼 갱신. | 2026-09-11 |
| make test 와 Makefile smoke 의 정리 — smoke 타깃에 초기 비밀번호가 글자로 적혀 있고, test 타깃은 web build 없이 go test 를 먼저 돌림 | 2/1/S | 대기 | Makefile smoke: ./scripts/offline-smoke.sh http://127.0.0.1:8080 admin ChangeMe-Relio-2026 — compose.yaml 의 초기 비밀번호를 그대로 적음. GUIDE-STANDARD 는 캡처·시드 스크립트에만 적용되지만 러너의 비밀정보 검사 대상이 될 수 있으니 환경 변수(예: RELIO_SMOKE_PASSWORD)로 받게 바꾼다. test 타깃의 순서 문제는 2026-09-11 anchor 로 해소되었으나 build 타깃처럼 web 의존을 명시하면 더 분명하다. | 2026-09-11 |
| 계약번호의 무작위 꼬리가 24비트뿐이라 하루 수백 건이면 충돌 가능 | 2/2/S | 대기 | contractNumber 는 ids.HexToken(3). 하루 500건이면 약 0.75% 충돌. HexToken(4~5) 또는 충돌 시 재시도 — 번호 형식이 바뀌므로 기존 데이터·문서(ADMIN/USER_GUIDE 에 C-<날짜>-<식별자> 로 적힘)와의 정합 확인. | 2026-09-11 |
| GetOpportunity 는 행이 없어도 시간대 조회와 건강도 계산을 계속 진행 | 1/1/S | 대기 | internal/crm/service.go 의 GetOpportunity 가 scanOpportunity 의 err 확인 전에 s.Clock.DateAt 과 opportunityHealth 를 부른다. 동작 차이 없음, err 검사를 앞으로 옮기면 끝. | 2026-09-11 |
| 사용자·관리자 가이드를 실제 화면 캡처가 실린 완성본으로 다시 쓰기 (캠페인 guides-2026-09) | 5/1/M | 완료 | docs/USER_GUIDE.md·ADMIN_GUIDE.md 와 PDF, 캡처 32장(docs/assets/guide), 캡처 도구 scripts/guide/screenshots.mjs(전용 env var·DISPOSABLE 가드·rate limit 복원). admin-guide.md 대체 안내, HTML 사본 제거, README·랜딩 링크 정리. 커밋 880adef. | 08:11 회차 커밋 880adef 는 러너 검증 go build 가 internal/webui/dist 부재로 실패해 푸시되지 않았음. 12:41 회차가 a73db96 으로 cherry-pick 하고 web build 를 먼저 돌려 검증 통과. | 17:59 회차 확인: PR #18(auto/2026-09-11-1241) 로 열려 검증·CI·리뷰 통과, low-risk 파일 수 제한(48>12)으로 사람 승인 대기 중. 중복 PR 을 피하려 이 회차에는 다시 올리지 않음. | 2026-09-11 |
| 새 worktree 에서 go build ./... 가 web build 없이는 실패함 — internal/webui/dist 자리표시자 또는 검증 순서 | 4/1/S | 완료 | 2026-09-11 17:59 회차(수정 과제)에서 해결: internal/webui/dist/README 를 커밋해 embed 패턴이 빌드 전에도 매치되고, 같은 파일을 web/public/README 에 두어 vite(emptyOutDir) 가 비운 뒤 그대로 복사해 되돌림. .gitignore 는 /dist/ 와 internal/webui/dist/* + 예외로 분리. 두 사본의 바이트 동일·커밋 여부를 assets_test.go 와 check-static-assets.sh 가 검사. 숨어 있던 두 번째 의존(server 테스트가 실제 embed 의 index.html 요구)도 spaHandlerFor + fstest.MapFS 로 제거. 러너 순서(go build → go test → web build)로 새 checkout 에서 통과 확인. | 2026-09-11 |
| 캡처 도구가 전략 고객 계획을 채우지 못해 고객 360 의 계획 섹션이 빈 폼으로 찍힘 | 1/1/S | 완료 | 2026-09-11 해결: GetAccountPlan 은 행이 없어도 status=DRAFT 빈 템플릿을 돌려주므로 status 판정이 항상 건너뛰었음. 저장된 행만 갖는 id 로 판단하도록 바꾸고 customer-360-relationships.png 재촬영, 전략 고객 계획 패널 전용 customer-360-plan.png 를 새로 찍어 사용자 가이드 3.3 절에 실음. 커밋 9318481. | 2026-09-11 |
교훈 (깨졌던 변경)
- 2026-09-06 rolled-back — 머지 60601bd 이 릴리즈를 반복해서 깨뜨려 되돌림 PR 을 열었다. 같은 접근은 피할 것.
- 2026-09-06 demoted — 자율화 단계 release → low-risk: 롤백 PR
- 2026-09-06 release-workflow-failed — 릴리즈 워크플로가 2회 실패(Verify upgrade from previous release). 릴리즈 관련 검증은 머지 전에 로컬에서 재현할 것.
원장 (에이전트가 남긴 기록)
2026-09-02
- 선택: 검색어의
%·_를 와일드카드가 아닌 글자로 다루기 (가치 4 / 위험 2 / 작업량 M) - 결과: 성공
- 요약: 고객·영업기회·리드·제품·담당자·협업자 검색과 관리자 Audit Log 검색이 사용자가 입력한 문자열을 그대로
LIKE '%'||lower($n)||'%'에 이어 붙여, Postgres 가%와_를 와일드카드로 읽었습니다. “50%” 로 검색하면 “50” 이 든 모든 고객이,_는 임의의 한 글자가,%하나는 테이블 전체가 나왔습니다.crm.SearchPattern이 Go 쪽에서 백슬래시·%·_를 이스케이프한 패턴을 만들고 7개 질의가 모두ESCAPE '\'를 선언하도록 바꿨습니다. 빈 질의는 빈 패턴을 그대로 돌려주므로 기존$n=''무필터 가드가 유지됩니다. 검증은 새 단위 테스트 3개(이스케이프 결과, 공백 질의,internal/전체를 훑어ESCAPE없는 LIKE/ILIKE 를 잡는 회귀 가드)와go test -race ./...,go vet ./...,webtypecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 a49fd95. - 보류 아이디어:
- MCP 요청 본문이 1MB 를 넘으면 조용히 잘려 “Parse error” 가 되므로 413 으로 구분 (가치 3 / 위험 1 / S)
internal/api(OpenAPI 문서),internal/audit,internal/job은 테스트가 하나도 없음 (가치 3 / 위험 1 / M)internal/server는 파일 23개에 테스트 4개뿐 — 순수 헬퍼(projection, 필터 파싱)부터 보강 (가치 3 / 위험 1 / M)- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 릴리즈: v1.11.8 (2026-09-02)
2026-09-02
- 선택: OpenAPI 문서를 실제 라우터와 일치시키고 양방향 회귀 가드 추가 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공
- 요약:
/api/openapi.json이 REST·MCP 클라이언트의 유일한 계약인데 라우터와 비교하는 장치가 없어 어긋나 있었습니다. 문서는PUT /opportunities/{id}/playbook을 약속했지만 실제 경로는PUT /opportunities/{id}/playbook/{itemId}라 405 가 났고, 로그인 흐름 전체(/auth/status,/auth/login,/auth/logout,/auth/me, OIDC start·callback)와/me/password,/dashboard등 8개 엔드포인트가 문서에 아예 없어 문서만 읽는 클라이언트는 인증 방법조차 알 수 없었습니다. 문서를 고치고,internal/server/openapi_contract_test.go가 go/ast 로 패키지 소스에서mux.Handle*라우트 표를 읽어 문서와 양방향 비교하도록 했습니다(새/api/v1라우트는 문서화 전까지, 문서에만 있는 경로는 라우팅 전까지 빌드 실패). 검증은 문서 한 줄을 지워 테스트가 실제로 실패하는지 확인한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 4daf66f. - 추가: CSP 보고에 directive 가 하나도 없으면 엔드포인트가 죽던 문제 (가치 4 / 위험 1 / 작업량 S)
internal/server/analytics.go가strings.Fields(violated + " ")[0]로 첫 단어를 꺼냈는데, 뒤의 공백은 가드처럼 보이지만 아무 역할도 못 합니다 —strings.Fields는 공백뿐인 문자열에 빈 슬라이스를 돌려주므로blocked-uri만 있고effective-directive·violated-directive가 둘 다 없는 보고(브라우저가 실제로 보낼 수 있는 형태)에서 index out of range 로 패닉했습니다./api/v1/csp-report는 인증이 없어 누구나 500 과 스택트레이스 로그를 반복 유발할 수 있었습니다. 테스트로 패닉을 먼저 재현한 뒤reportedDirective헬퍼로 분리해 고쳤고(빈 값은RecordViolation이 이미 버림), 단위 테스트 4케이스와 핸들러 테스트를 추가했습니다. 커밋 68529d3.
- 보류 아이디어:
internal/intelligence/queries.go의GetSignal/GetRisk/GetRecommendation등은 ID 조건 없이 상위 200건만 받아 선형 탐색하므로, 레코드가 200건을 넘으면 조회뿐 아니라IgnoreSignal·AcceptRisk·AcceptRecommendation같은 쓰기까지 “not found” 로 실패 (가치 5 / 위험 2 / M) — 다음 세션 1순위internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는 UTC 자정이라 KST 배포에서 하루 지난 연체 건이 HIGH 대신 WARNING 으로 분류됨 (가치 4 / 위험 2 / S)internal/intelligence/engine.go:333계약 만료D-N라벨이 정수 절삭 탓에 하루 짧고, D-90 창이 실제로는 D-91 까지 걸림 (가치 3 / 위험 2 / S)internal/approval/service.go:214의CONTAINS는 양쪽이 숫자처럼 보이면 숫자 분기 default 로 빠져==로 평가됨 → 승인 정책이 조용히 무시되고 결재가 우회됨 (가치 4 / 위험 2 / M)- MCP 요청 본문이 1MB 를 넘으면 조용히 잘려 “Parse error” 가 되므로 413 으로 구분 (가치 3 / 위험 1 / S)
- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 릴리즈: v1.11.9 (2026-09-02)
2026-09-03
- 선택: intelligence 단건 조회를 상위 200건 선형 탐색이 아닌 SQL id 조건으로 (가치 5 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
GetSignal/GetRisk/GetInsight/GetRecommendation이List*(Limit: 200)을 부른 뒤 Go 에서 ID 를 훑었습니다. 목록은 심각도·점수 순 정렬이라 레코드가 200건을 넘는 계정에서는 그 뒤의 항목이 조회뿐 아니라IgnoreSignal·AcceptRisk·AcceptRecommendation·DismissRecommendation(모두 쓰기 전에 단건 조회를 함) 까지 “not found” 로 실패했습니다 — 분석이 가장 많이 쌓이는 바쁜 계정에서 정확히 그 조언들을 처리할 수 없었습니다. 네 필터에ID를 추가하고 각 질의에($n='' OR x.id::text=$n)선택 조건을 넣어 목록과 단건이 같은 문장(같은 Data Scope 조인)을 쓰게 했고, 단건은Limit: 1로 받습니다. 테스트를 위해 각 목록 질의를(sql, args)를 돌려주는 순수 빌더로 분리했습니다. 검증은 새 테스트 3개(단건 질의의 id 플레이스홀더와 인자 대응, 빈/공백 id 는 무필터, 모든$n이 인자와 1:1 인지 검사하는 회귀 가드)와 id 조건을 지우면 실제로 실패하는지 확인, 그리고go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 cee08a9. - 보류 아이디어:
internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는 UTC 자정이라 KST 배포에서 하루 지난 연체 건이 HIGH 대신 WARNING 으로 분류됨 (가치 4 / 위험 2 / S)internal/approval/service.go:214의CONTAINS는 양쪽이 숫자처럼 보이면 숫자 분기 default 로 빠져==로 평가됨 → 승인 정책이 조용히 무시되고 결재가 우회됨 (가치 4 / 위험 2 / M)internal/intelligence/engine.go:333계약 만료D-N라벨이 정수 절삭 탓에 하루 짧고, D-90 창이 실제로는 D-91 까지 걸림 (가치 3 / 위험 2 / S)- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) - MCP 요청 본문이 1MB 를 넘으면 조용히 잘려 “Parse error” 가 되므로 413 으로 구분 (가치 3 / 위험 1 / S)
- 릴리즈: v1.11.10 (2026-09-03)
2026-09-03
- 선택: 승인 정책의 문자 조건이 조용히 무시되던 문제 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
internal/approval/service.go의matches가 양쪽이 숫자로 파싱되면 무조건 숫자 분기로 갔습니다.amount CONTAINS 50은 500000000 과 50 을 숫자로 비교해 절대 일치하지 않았고, 그 정책이 지키던 결재가 흔적 없이 건너뛰어졌습니다. CONTAINS 를 항상 텍스트 비교로 두고, 스냅샷의 float64 를fmt.Sprint의 “5e+08” 대신 온전한 자릿수로 렌더링하는conditionText를 넣었습니다. 문자에 대한 GT/LT 도 기존에는 동등 비교로 떨어져status GT OPEN이 배제해야 할 바로 그 값에서 발동했는데, 이제 사전순으로 비교합니다. 관리자 화면은 조건 항목으로status를 제시하면서 값 입력은type=number+Number(value)라 저장 가능한 값이 null 뿐이었고 null 은 어떤 것과도 일치하지 않아 정책이 무력화됐습니다 — 값을 텍스트로 받아 숫자로 읽힐 때만 숫자로 보내고, 서버가 지원하는 LT·NE·CONTAINS 를 연산자 목록에 추가했습니다. 검증은matches20케이스·conditionText6케이스 단위 테스트(옛 동작이면 실패하는 케이스 포함)와go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 c06d8dd. - 보류 아이디어:
internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는 UTC 자정이라 KST 배포에서 하루 지난 연체 건이 HIGH 대신 WARNING 으로 분류됨 (가치 4 / 위험 2 / S)internal/intelligence/engine.go:333계약 만료D-N라벨이 정수 절삭 탓에 하루 짧고, D-90 창이 실제로는 D-91 까지 걸림 (가치 3 / 위험 2 / S)- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) - MCP 요청 본문이 1MB 를 넘으면 조용히 잘려 “Parse error” 가 되므로 413 으로 구분 (가치 3 / 위험 1 / S)
internal/audit,internal/job은 테스트가 하나도 없음 (가치 3 / 위험 1 / M)
- 릴리즈: v1.11.11 (2026-09-03)
2026-09-03
- 선택: 계약 만료·예상 종료일 신호의 날짜 계산을 달력 기준으로 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
end_date·expected_close_date는 DATE 컬럼이라 pgx 가 자정으로 돌려주는데, 엔진의now는 시각을 갖습니다. 두 값을 빼서 24 로 나누면 하루의 대부분이 사라지고 나머지는 0 방향으로 잘려, 날짜 기반 신호가 전부 하루씩 밀렸습니다 — 내일 만료되는 계약이 “만료 D-0” 으로, 91일 남은 계약이 “D-90” 으로(공지 창 밖이어야 하는데 안으로) 나왔고, 열린 영업기회는 예상 종료일 당일 자정부터 “예상 종료일이 0일 지났습니다” 로 CLOSE_DATE_PASSED 오탐이 떴습니다.calendarDays가 경과 시간이 아니라 두 날짜를 비교하도록 바꿨고, 경과 시간 질문(단계 정체 일수 등)은 기존daysSince를 그대로 씁니다. 기존 테스트가 이를 놓친 이유는now.AddDate로 날짜를 만들어 시각이 보존돼 뺄셈이 정확히 24h 배수가 됐기 때문이라, 새 케이스는 DB 처럼 자정 날짜를 만듭니다. 검증은 새 테스트 3개(calendarDays5케이스, 계약 만료 D-1/D-0/만료됨/D-90/D-91, 종료일 당일 무신호·1일 초과 신호)를 옛 구현으로 되돌리면 실제로 실패하는지 확인한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 95f247a. - 보류 아이디어:
internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는 UTC 자정 — 로직 자체는 UTC 기준으로 맞으나system.timezone(기본 Asia/Seoul) 설정을 무시하므로 KST 00~09시 사이에는 하루 지난 건이 HIGH 대신 WARNING (가치 3 / 위험 3 / M, 타임존 배선 필요)- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) - MCP 요청 본문이 1MB 를 넘으면 조용히 잘려 “Parse error” 가 되므로 413 으로 구분 (가치 3 / 위험 1 / S)
internal/audit,internal/job은 테스트가 하나도 없음 (가치 3 / 위험 1 / M)
- 릴리즈: v1.11.12 (2026-09-03)
2026-09-04
- 선택: 유지보수 작업의 Advisory Lock 을 커넥션 하나에 고정 (가치 5 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
internal/job/runner.go의 1분 주기 유지보수가pg_try_advisory_lock을 커넥션 풀(r.DB)에 대고 걸었습니다. 세션 Advisory Lock 은 잠금을 건 커넥션의 것인데 뒤따르는 정리 구문과pg_advisory_unlock은 풀에서 다시 커넥션을 빌리므로, 해제가 엉뚱한 커넥션에서 실행되고(경고와 함께 false, 반환값은 버려짐) 잠금은 원래 세션에 그대로 남았습니다. 그래서 그 다음 회차부터는 풀이 마침 그 커넥션을 내줄 때만 정리가 돌고 나머지는 “다른 인스턴스가 잠갔다”로 조용히 종료 — Personal Key 만료, 세션·OIDC 로그인 상태·멱등성 키 삭제, 예측 스냅샷, 인텔리전스 분석이 로그 한 줄 없이 멈췄습니다.Migrate가 이미 쓰던 방식대로Acquire로 커넥션 하나를 빌려 잠금·작업·해제를 모두 그 위에서 실행하도록 바꾸고, 잠금 질의 실패는 정상적인 경합과 분리해 로그로 남깁니다. 테스트가 없던 패키지라 커넥션 대역으로 구문 순서를 확인하는 테스트 3개(잠금 후 해제가 마지막인지, 잠금 실패 시 아무 구문도 안 도는지, 질의 오류 시 즉시 중단)와internal/전체에서 세션 Advisory Lock 이 풀 위에서 실행되면 실패시키는 회귀 가드를 넣었고, 가드가 옛 코드 형태를 실제로 잡는지 확인했습니다. 검증은go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 03972e3. - 보류 아이디어:
- MCP 요청 본문이 1MB 를 넘으면 조용히 잘려 “Parse error” 가 되므로 413 으로 구분 (가치 3 / 위험 1 / S)
- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) internal/audit는 테스트가 하나도 없음 — 감사 로그 기록 실패가 조용히 무시되는지 포함해 확인 (가치 3 / 위험 1 / M)internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는system.timezone(기본 Asia/Seoul) 설정을 무시 (가치 3 / 위험 3 / M, 타임존 배선 필요)- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 릴리즈: v1.11.13 (2026-09-04)
- 릴리즈: v1.11.13 (2026-09-04)
2026-09-04
- 선택: 1MB 를 넘는 MCP 요청 본문을 413 으로 구분 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
internal/mcp/server.go가 본문을io.ReadAll(io.LimitReader(r.Body, 1<<20))로 읽었는데,io.LimitReader는 초과 입력을 오류로 만들지 않고 상한에서 잘라 EOF 를 돌려줍니다. 그래서 1MB 를 넘는 메시지는 닫히지 않은 JSON 으로 잘려parseRequests에서 실패했고, 클라이언트는 자기가 올바르게 만든 메시지에 대해 400 “Parse error” 를 받았습니다 — 크기가 원인이라는 단서가 응답 어디에도 없어 긴 노트나 큰arguments를 담은tools/call이 재시도해도 똑같이 실패했습니다. REST 쪽은httpx.DecodeJSON의MaxBytesReader와serveIdempotent의(2<<20)+1로 이미 이 상황을 413 으로 구분하고 있었으므로 같은 방식을 따랐습니다:readRequestBody가 상한보다 한 바이트 더 읽어 초과를 판별하고, 초과면 413 + JSON-RPC 오류(data.maxBytes), 읽기 오류는 기존대로 400 Parse error. 상한 값 1MB 자체는 그대로입니다. 검증은 새 테스트 2개(상한 정확히 통과·한 바이트 초과 거절·초과tools/call판정·자른 본문이 실제로 파싱 실패하는지·읽기 오류 구분, 그리고 413 응답이 JSON-RPC 형식과data.maxBytes를 유지하는지)를 옛 구현으로 되돌리면 실제로 실패하는지 확인한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 ab833e1. - 보류 아이디어:
- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) - MCP 목록 도구에는 커서 인자가 전혀 없어 에이전트가 상위 N건 뒤를 볼 수 없음 —
crm.Page의nextCursor를 도구 스키마에 노출 (가치 3 / 위험 2 / M) internal/audit는 테스트가 하나도 없음 — 감사 로그 기록 실패가 조용히 무시되는지 포함해 확인 (가치 3 / 위험 1 / M)internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는system.timezone(기본 Asia/Seoul) 설정을 무시 (가치 3 / 위험 3 / M, 타임존 배선 필요)- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 네 intelligence 필터의
- 릴리즈: v1.11.14 (2026-09-04)
- 릴리즈: v1.11.14 (2026-09-04)
2026-09-04
- 선택: MCP 목록 도구에 커서 인자 노출 + 발급하지 않은 커서 거절 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
search_customers와list_opportunities는 응답에hasMore와nextCursor를 담아 “뒤에 더 있다”고 알려주면서 정작cursor인자를 스키마에 선언하지 않았고 핸들러도 빈 커서를 넘겼습니다. 두 스키마 모두additionalProperties:false라 클라이언트가 임의로 보낼 수도 없어, 기본 50건·최대 200건을 넘는 계정에서는 첫 페이지 뒤가 MCP 로는 아예 보이지 않았습니다. 두 도구에cursor를 넣고, 인자→서비스 필터 변환을customerSearchArgs·opportunityFilterArgs로 한곳에 모아 선언한 인자가 질의로 가는 길에 누락될 수 없게 했습니다. 같은 함수에서status를 대문자로 정규화해, 산문에서 읽은 대로"open"을 보낸 모델이 빈 목록 대신 실제 결과를 받도록 했습니다. 또pageOffset을parseCursor로 바꿔 이 서버가 발급하지 않은 커서를 offset 0 으로 해석하지 않고 거절합니다 — 조용히 1페이지로 되돌리면nextCursor와 함께 첫 페이지를 계속 돌려주는 끝나지 않는 목록이 됩니다. 메시지는serviceError의 substring 분류를 타고 REST 에서 400 이 됩니다. 검증은 새 테스트 8개(커서 왕복·빈 커서·위조 커서 7종 거절·오류 문구가 404/403/409 로 오분류되지 않는지, 두 도구의 cursor 선언·스키마가 광고한 모든 인자가 필터에 전달되는지·status 정규화·기본 limit)를 옛 구현으로 되돌리면 실제로 실패하는지 4가지 방식으로 확인한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 b217c85. - 보류 아이디어:
- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) internal/audit는 테스트가 하나도 없음 —Record는Log가 nil 이면 패닉하고nullableJSON은 marshal 오류를 버림 (가치 3 / 위험 1 / M)list_activities,get_contracts,list_quotations등 나머지 MCP 목록 도구는 서비스가 슬라이스만 돌려주어 페이징 자체가 없음 — 상위 N건 뒤는 여전히 안 보임 (가치 3 / 위험 2 / M)internal/api/openapi.go는 경로와 요약만 담고 query parameter 를 전혀 문서화하지 않음 —cursor·limit·sort가 계약에 없음 (가치 3 / 위험 1 / M)- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 네 intelligence 필터의
- 릴리즈: v1.11.15 (2026-09-04)
2026-09-05
- 선택: OpenAPI 문서에 경로·질의 파라미터를 넣고 핸들러와 양방향으로 묶기 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공
- 요약:
/api/openapi.json은 경로와 요약만 담고 있어서, 클라이언트가 스스로 알아낼 수 없는 두 가지가 비어 있었습니다. 하나는 사양 위반입니다 — OpenAPI 는 경로의 모든{id}에 대응하는 path parameter 를 요구하는데 약 50개 경로 중 선언한 곳이 하나도 없어, 생성기는 id 를 넣을 인자가 없는 메서드를 만들고 린터는 문서를 거절합니다. 다른 하나는 질의 문자열 전체입니다 —/customers의cursor·sort,/opportunities의 다섯 필터, 엔드포인트별limit상한, 인증된 GET 이 모두 받는fields가 어디에도 없어 Go 소스를 읽어야만 알 수 있었습니다. 특히sort는 화이트리스트 밖의 값을 오류가 아니라 조용히 기본 정렬로 되돌리므로, 값 목록을 모르는 클라이언트는 정렬이 무시된 사실조차 알 수 없었습니다. 경로 파라미터는 템플릿에서 직접 파생시켜 나중에 추가되는 경로가 빠뜨릴 수 없게 했고, 질의 파라미터는 enum 을 DB CHECK 제약에서·정수 범위와 기본값을 그 값을 읽는httpx.IntQuery호출에서 가져와 표로 적었습니다.fields는requireAuth가 핸들러 실행 전에 적용하므로components.parameters공유 항목으로 두고 인증된 GET 마다$ref로 붙입니다. 검증은 새 테스트 4개(경로{var}↔path parameter 양방향, 문서의 질의 파라미터↔라우터가 그 경로에 연결한 핸들러가 실제로 읽는 질의 키 양방향,fields가 인증된 GET 에만·전부에 붙었는지,sortenum 이internal/crm정렬 화이트리스트와 값·개수까지 일치하는지)를 문서를 6가지로 어긋나게 만들어 실제로 실패하는지 확인한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 edb6ed3. - 보류 아이디어:
- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) internal/audit는 테스트가 하나도 없음 —Record는Log가 nil 이면 패닉하고nullableJSON은 marshal 오류를 버려 감사 데이터를 조용히 NULL 로 만듦 (가치 3 / 위험 1 / M)list_activities,get_contracts,list_quotations등 나머지 MCP 목록 도구는 서비스가 슬라이스만 돌려주어 페이징 자체가 없음 — 상위 N건 뒤는 여전히 안 보임 (가치 3 / 위험 2 / M)internal/server/today.go:87의time.Now().Truncate(24*time.Hour)는system.timezone(기본 Asia/Seoul) 설정을 무시 (가치 3 / 위험 3 / M, 타임존 배선 필요)- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 네 intelligence 필터의
- 릴리즈: v1.11.16 (2026-09-05)
- 릴리즈: v1.11.16 (2026-09-05)
2026-09-05
- 선택: 계약 기간이 만들어낼 수 있는 매출 인식 일정 수를 제한 (가치 4 / 위험 2 / 작업량 S)
- 결과: 성공
- 요약: 계약의 기간과 인식 주기가 함께 활성화 한 번이 쓰는 행 수를 정하는데 그 조합에 상한이 없었습니다. 종료일을 2025 대신 2205 로 잘못 적어도 그대로 통과했고, 9999-12-31 까지 가는 월별 계약은
buildScheduleDates가 95,712 개의 날짜를 만들어 그 하나하나를 활성화 트랜잭션 안의 개별 INSERT 로 썼습니다.ListRevenueSchedules는 자체 LIMIT 이 없어 그 행을 한꺼번에 돌려주므로, 해당 계약의 일정 탭은 이후 영영 열리지 않고 실수는 이미 기록된 뒤에야 드러났습니다. 이제 600건(월별 50년 — 실제 계약보다 훨씬 긴 값)을 넘기면 두 입력 중 무엇을 바꿔야 하는지 밝히며 거절합니다. 일정을 쓰는 두 경로(ACTIVE 로 생성, DRAFT 활성화)가 모두 이 함수를 지나므로 사전 검증과 실제 쓰기가 같은 기준을 씁니다. 검증은 새 테스트 3개(정상 3년 월별 36건 통과, 9999년까지의 월별·분기·연간과 상한 +1 거절 및 오류 문구가 404/403/409 로 오분류되지 않는지, 정확히 600건은 통과)를 상한을 빼면 실제로 실패하는지(95,712건이 통과됨을 출력) 확인한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 3959cbb. - 보류 아이디어:
system.timezone(관리자 화면에서 Asia/Seoul·UTC·Asia/Tokyo 중 고를 수 있음)을 읽는 Go 코드가 한 줄도 없음 — 설정이 순전히 장식 (가치 4 / 위험 3 / M)- 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 실제 커서 페이징을 붙이거나 제거 (가치 3 / 위험 1 / M) internal/audit는 테스트가 하나도 없음 —nullableJSON이 marshal 오류를 버려 감사 데이터를 조용히 NULL 로 만듦 (가치 3 / 위험 1 / M)crm.Contracts의expiringDays는 3650 을 넘으면 0 으로 떨어져 필터가 사라짐 — 좁히라는 요청이 오히려 전체를 반환 (가치 2 / 위험 1 / S)- CI 에
gofmt -l검사 추가 — 지금은 포맷 위반이 통과함 (가치 2 / 위험 1 / S)
- 릴리즈: v1.11.17 (2026-09-05)
2026-09-06
- 선택: 범위가 정해진 정수 질의 파라미터를 보낸 그대로 읽기 (가치 4 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
httpx.IntQuery가fmt.Sscan으로 파싱했는데, Sscan 은 읽을 수 있는 만큼만 소비하고 남은 부분에 대해 아무 오류도 내지 않습니다. 그래서 32곳의 호출부 전부에서1e3은 1,0x10은 16,030은 8진수 24,50abc는 50 으로 읽혀, 이 서버가type: integer+ minimum/maximum 으로 공표한 계약 아래에서 클라이언트가 보낸 것과 다른 숫자로 응답이 나갔습니다(limit=1e3→ 1건).strconv.Atoi로 엄격하게 파싱하되, 질의 문자열이+를 공백으로 실어 나르므로 앞뒤 공백은 계속 허용합니다.expiringDays는 반대 방향의 문제였습니다 — fallback 이 0 인데 계약 질의에서 0 은 “종료일로 좁히지 않음”을 뜻하므로, 범위를 벗어난 값이 필터 자체를 꺼버렸습니다.get_expiring_contracts에 days=5000 을 준 에이전트는 종료일이 아예 없는 계약까지 포함한 전체 목록을 “만료 예정”으로 받았습니다. 이런 필터는 이제 핸들러(httpx.ClampQuery)와 서비스(boundExpiringDays) 양쪽에서 상한으로 줄여 적용해 REST 와 MCP 경로가 같은 기준을 쓰고, OpenAPI 설명에도 적었습니다. 검증은 새 테스트 6개(정수가 아닌 8가지 값이 fallback 인지·질의 문자열이 실을 수 있는 모든 형태(+30→” 30”,030→30)를 그대로 읽는지·범위 밖 fallback·클램프가 필터를 켠 채로 두는지·읽을 수 없는 값은 여전히 fallback·서비스 쪽 상한)를 옛 구현으로 되돌리면 실제로 실패하는지 확인(1e3→1,0x10→16,030→24,boundExpiringDays(5000)→0 로 6건 실패)한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 38255ae. -
보류 아이디어:
system.timezone설정을 읽는 Go 코드가 한 줄도 없어 관리자 화면의 선택이 순전히 장식 (가치 4 / 위험 3 / M) · 네 intelligence 필터의Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 (가치 3 / 위험 1 / M) ·internal/audit는 테스트가 하나도 없고nullableJSON이 marshal 오류를 버려 감사 데이터를 조용히 NULL 로 만듦 (가치 3 / 위험 1 / M) ·minScore도 fallback 0 이 필터를 끄는 같은 구조 — 다른 좁히기 필터에도ClampQuery를 적용할지 검토 (가치 2 / 위험 1 / S) · CI 에gofmt -l검사 추가 (가치 2 / 위험 1 / S) - 릴리즈: v1.11.18 (2026-09-06, run 2026-09-06-183046-relio-improve)
2026-09-06
- 선택: 릴리즈 워크플로 수정 과제 — 실제로 발행된 릴리즈에서 업그레이드하기 (가치 5 / 위험 1 / 작업량 M)
- 결과: 성공
- 요약:
Verify upgrade from previous release가 “직전 태그”를 “직전 릴리즈”로 취급했습니다. 워크플로가Publish GitHub Release전에 멈추면 릴리즈도 이미지도 없는 태그만 남는데, 그 태그를 업그레이드 원본으로 골라 존재하지 않는 asset 을 요청하고 1초 만에 실패했습니다. 그 실패가 다시 발행을 건너뛰므로 고장이 스스로 이어집니다 — GitHub API 로 확인한 결과 v1.11.15 는Test source에서 실패했고, v1.11.16·v1.11.17·v1.11.18 은 각각gh release download relio-v1.11.1{5,6,7}.tar.gz에서 실패했으며, 지금 설치 가능한 마지막 이미지는 여전히 v1.11.14 입니다. 이제scripts/previous-release-tag.sh가 릴리즈가 없는 태그와 이미지가 없는 릴리즈를 건너뛰며 실제로 발행된 가장 최신 태그를 찾습니다. 느슨하게 만든 곳은 없습니다: 업그레이드 테스트는 여전히 진짜 이전 이미지로 전부 실행되고(어느 것인지 로그에 남김), “release not found” 가 아닌 조회 실패는 더 과거로 물러나거나 “이전 릴리즈 없음”으로 처리하지 않고 릴리즈를 중단시킵니다. 옛 순서는 현재 태그가 아닌 가장 최신 태그를 골랐으므로 과거 태그를 재실행하면 미래 릴리즈에서 업그레이드할 수도 있었는데 그것도 함께 막았습니다. 검증은 (1) 새 테스트 8건(scripts/previous-release-tag-test.sh, CI 에 연결)을 옛 한 줄 로직으로 되돌리면 8건 중 7건이 실제로 실패함을 확인, (2) 실제 GitHub 릴리즈 목록을 그대로 스텁에 넣고 v1.11.18 기준으로 돌려 v1.11.14 를 고르는지 재현, (3) 실패한 단계 자체를 로컬에서 끝까지 재현 — 현재 소스로 이미지를 빌드하고 v1.11.14 asset 을 내려받아./scripts/run-upgrade-container-test.sh relio:v1.11.14 relio:v1.11.19통과(012_personal_key_access_version.sql -> 012_personal_key_access_version.sql), (4)go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 3e85fcf. - 보류 아이디어:
system.timezone설정을 읽는 Go 코드가 한 줄도 없어 관리자 화면의 선택이 순전히 장식 (가치 4 / 위험 3 / M) · 네 intelligence 필터의Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 (가치 3 / 위험 1 / M) ·internal/audit는 테스트가 하나도 없고nullableJSON이 marshal 오류를 버려 감사 데이터를 조용히 NULL 로 만듦 (가치 3 / 위험 1 / M) ·list_activities·get_contracts·list_quotations등 나머지 MCP 목록 도구는 페이징 자체가 없음 (가치 3 / 위험 2 / M) · CI 에gofmt -l검사 추가 — 지금은 포맷 위반이 CI 를 통과함 (가치 2 / 위험 1 / S)
2026-09-07
- 선택: 로그인 결과가 무엇이든 같은 비밀번호 검증 비용을 치르게 하기 (가치 5 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
auth.Login의 조건이err != nil || !active || !VerifyPassword(hash, password)였는데 Go 의||는 단락 평가라, 존재하지 않는 사용자명(그리고 존재하지만 비활성인 계정)은 argon2id 에 아예 도달하지 못하고 수십 나노초 만에 거절됐습니다. 실제 계정은 m=64MiB,t=3 이 요구하는 ~100ms 를 쓰므로, 요청 두 번의 시간만 재면 비밀번호를 한 번도 맞히지 않고 어떤 사용자명이 실재하는지·그중 무엇이 비활성인지 가려낼 수 있었습니다 — 로그인 리미터는 주소당 시도 횟수를 셀 뿐 두 응답의 시간 차이를 없애지 못합니다. 이제VerifyLoginPassword가 계정을 쓸 수 없을 때 프로세스당 한 번 만드는 decoy 해시(지금HashPassword가 쓰는 파라미터 그대로)로 파생을 수행하고, 파생이 끝난 뒤에야 계정 판정을 적용합니다. “쓸 수 없는 계정”에 파싱되지 않는 저장 해시도 포함시키려니VerifyPassword가 퇴화한 인코딩에 정직해져야 했습니다 — 키 길이가 0이면 argon2 가 빈 키를 파생하고ConstantTimeCompare가 그것을 모든 비밀번호와 같다고 보고하며, 실제로는 거기 닿기 전에 blake2b 에서 패닉합니다.parseArgon2id가 8바이트 미만 salt 와 16바이트 미만 키를 거절하고, 이 코드가 써온 모든 해시는 16·32 바이트입니다. 검증은 새 테스트 4개(다섯 가지 로그인 결과가 모두 5ms 이상 걸리는지, 일곱 가지 계정 상태의 수락·거절, decoy 가 실제 해시와 같은 파라미터·모양이고 한 번만 만들어지며 어떤 추측도 통과시키지 않는지, 퇴화한 네 가지 인코딩이 임의 비밀번호를 받아들이지 않는지)를 옛 구현으로 되돌리면 실제로 실패하는지 확인 — 단락 평가를 되살리면 존재하지 않는 사용자명이 55ns 에 답해 실패하고, 길이 하한을 빼면 빈 키 인코딩이 blake2b nil 역참조로 패닉 — 한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh전체 통과. 커밋 253c768. -
보류 아이디어: 로컬 로그인이 꺼져 있을 때
/auth/login이 사용자명 존재 여부에 따라 403 과 401 을 갈라 돌려주는 두 번째 열거 통로 (가치 3 / 위험 2 / S) ·system.timezone설정을 읽는 Go 코드가 한 줄도 없어 관리자 화면의 선택이 순전히 장식이고 계약·견적 번호의 날짜까지 UTC 로 찍힘 (가치 4 / 위험 3 / M) · 네 intelligence 필터의Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 상위 200건 뒤를 볼 방법이 없음 (가치 3 / 위험 1 / M) ·internal/audit는 테스트가 하나도 없고nullableJSON이 marshal 오류를 버려 감사 데이터를 조용히 NULL 로 만듦 (가치 3 / 위험 1 / M) · CI 에gofmt -l검사 추가 — 지금은 포맷 위반이 CI 를 통과함 (가치 2 / 위험 1 / S) - 릴리즈: v1.11.19 (2026-09-08, run 2026-09-08-094239-relio-release)
2026-09-08
- 선택: 감사 이벤트가 그 이벤트를 만든 주소 때문에 통째로 사라지지 않게 하기 (가치 4 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
httpx.ClientIP은net.SplitHostPort가 돌려준 것을 그대로 반환했고, 이를 저장하는 6곳 전부가 그 문자열을NULLIF($n,'')::inet에 그대로 바인딩합니다. PostgreSQL 의inet은 zone 이 붙은 주소를 거절하므로 IPv6 link-local 로 접속한 클라이언트([fe80::1%eth0]:52000)는 ip 컬럼 하나를 잃는 것이 아니라 INSERT 문 전체가 실패했습니다 — 감사 이벤트는LOGIN_FAILED를 포함해 로그 한 줄만 남기고 사라지고,auth.Login은 같은 값으로 세션 행을 쓰므로 그 클라이언트는 아예 로그인할 수 없었습니다. 주소가 아닌 피어(Unix 소켓은@)도 같은 결과였습니다. 이제 zone 을 떼고, IPv4-mapped 형태(::ffff:192.0.2.10)를 unmap 해 dual-stack 리스너에서 한 클라이언트가 로그인 리미터 버킷과 감사 로그 표기를 하나로 유지하며, 주소가 아닌 값은""로 만들어 기존NULLIF가 NULL 로 쓰게 합니다(행을 함께 죽이지 않음). 같은 주제로audit.nullableJSON도 고쳤습니다 — json 이 거절하는 payload(NaN 으로 스캔된 numeric, 순환 참조)에서 오류를 버리고 nil 을 돌려주어 컬럼이 조용히 SQL NULL 이 되었고, 그 행은 “무엇이 바뀌었다”고만 말한 채 “어떻게” 를 잃어 payload 가 원래 없던 이벤트와 구분되지 않았습니다. 이제 인코딩 실패 자체를 기록합니다. 검증은 새 테스트 9개(두 파일,internal/audit의 첫 테스트)를 옛 구현으로 되돌리면 실제로 실패하는지 확인 — ClientIP 20건 실패(zone 4종, IPv4-mapped 2종, 주소 아님 5종, 계약 검사 6종), nullableJSON 5건 실패 — 한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh전체 통과. 커밋 d9ee0c8. - 보류 아이디어:
system.timezone설정을 읽는 Go 코드가 한 줄도 없어 관리자 화면의 선택이 순전히 장식이고 계약·견적 번호의 날짜까지 UTC 로 찍힘 (가치 4 / 위험 3 / M) · 네 intelligence 필터의Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 상위 200건 뒤를 볼 방법이 없음 (가치 3 / 위험 1 / M) ·list_activities·get_contracts·list_quotations등 나머지 MCP 목록 도구는 페이징 자체가 없음 (가치 3 / 위험 2 / M) · 로컬 로그인이 꺼져 있을 때/auth/login이 403 과 401 을 갈라 돌려주는 두 번째 열거 통로 (가치 3 / 위험 2 / S) · CI 에gofmt -l검사 추가 — 지금은 포맷 위반이 CI 를 통과함 (가치 2 / 위험 1 / S)
2026-09-09
- 선택:
system.timezone설정을 실제로 읽어 날짜를 그 시간대에서 뽑기 (가치 4 / 위험 2 / 작업량 M) - 결과: 성공
- 요약: 관리자 화면은 첫 마이그레이션부터 Asia/Seoul·UTC·Asia/Tokyo 를 고를 수 있게 해 두었지만 그 값을 읽는 Go 코드가 한 줄도 없어서, 서버가 “지금”에서 뽑아내는 모든 날짜가 프로세스 시계(컨테이너에서 UTC)에서 나왔습니다. 서울 기준 00~09시에는 그 시계가 아직 어제이므로 08시에 만든 계약은
C-<어제>로 번호가 붙고, 견적도 같으며, 고객의 소리 CSV 파일명도 어제 날짜였고, 날짜를 지정하지 않은 매출 인식은 어제로 기록되었으며,오늘큐는 오늘 마감인 다음 행동을 어제 자정과 비교해 이미 지연으로 보고했습니다(time.Now().Truncate(24*time.Hour)는 절대 시각을 자르므로 설정과 무관하게 언제나 UTC 자정입니다). 새internal/platform/timezone이 설정을*time.Location으로 해석해 30초 캐시하므로 관리자의 변경이 재시작 없이 반영되면서 계약번호 하나가 설정 질의를 치르지 않고,DateOf는 PostgreSQL DATE 컬럼이 도착하는 모양 그대로 자정 UTC 로 답해 기존 값과 비교·바인딩이 바뀌지 않습니다. 읽기 실패는 이미 쥔 시간대를 유지해 호출자 밑에서 달력이 움직이지 않게 하고, 쓸 수 없는 값("", 알 수 없는 이름,Local, JSON 문자열이 아닌 값)은 UTC 가 아니라 Asia/Seoul 로 물러납니다 — UTC 로 물러나면 지금 없애려는 동작을 그대로 되살리기 때문입니다. 존 데이터베이스는time/tzdata로 내장해 베이스 이미지가 tzdata 를 갖고 있는지에 설정이 좌우되지 않게 했습니다. 배선은main.go에서 만든 loader 하나를 crm·relationship·server 가 공유하고, 필드가 nil 이면 기본 시간대로 답하므로 loader 를 세우지 않는 기존 테스트가 그대로 통과합니다. 검증은 새 테스트 9개(Resolve 10 케이스, 기본값이 서울이고 +9시간인지, DateOf 가 UTC+14/UTC-11 을 포함해 존의 달력일을 쓰고 옛Truncate와 실제로 다른지, 캐시가 질의를 1회로 묶는지, 삭제·비문자열·미지의 존·빈 값 4종 폴백과 실패 시 캐시 유지, nil loader 무해성, 질의가 관리자 화면이 쓰는system·timezone을 가리키는지, 계약·견적 번호가 UTC+14 와 UTC-11 에서 항상 서로 다른 날짜를 찍는지, Clock 없이도 서울 날짜인지)를 옛 구현으로 되돌리면 실제로 실패하는지 확인 — 번호 생성을time.Now().Format으로 되돌리자 두 존이 같은20260909를 찍어 실패하고 서울 폴백 테스트도20260908을 기대하며 실패,DateOf를Truncate(24h)로 되돌리자2026-01-01을 답해 실패 — 한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh전체 통과. 커밋 9571cbd. - 보류 아이디어:
internal/intelligence의 D-day 계산(calendarDays,opportunityHealth)은 여전히now.UTC()의 달력일을 써서 만료·마감 임박 판정이 서울 새벽에 하루 어긋남 — 새 timezone loader 를 붙일 다음 자리 (가치 3 / 위험 2 / M) · 네 intelligence 필터의Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 상위 200건 뒤를 볼 방법이 없음 (가치 3 / 위험 1 / M) ·list_activities·get_contracts·list_quotations등 나머지 MCP 목록 도구는 페이징 자체가 없음 (가치 3 / 위험 2 / M) · 로컬 로그인이 꺼져 있을 때/auth/login이 403 과 401 을 갈라 돌려주는 두 번째 열거 통로 (가치 3 / 위험 2 / S) · CI 에gofmt -l검사 추가 — 지금은 포맷 위반이 CI 를 통과함 (가치 2 / 위험 1 / S)
2026-09-09
- 선택: intelligence 의 D-day 를 설정된 시간대의 달력에서 세기 (가치 3 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약: 지난 회차가 만든 timezone loader 를 남은 날짜 질문들에 붙였고, 그 과정에서 두 곳은 시간대와 무관한 이유로도 하루씩 어긋나 있었습니다.
expected_close_date와end_date는 DATE 컬럼이라 pgx 가 자정 UTC 로 돌려주는데now는 시각을 품고 있습니다 — 엔진은 이를 알고calendarDays를 쓰면서도 시각을 그대로 넣어 UTC 달력에서 셌고, 서울 00~09시에는 그 달력이 아직 어제이므로 이틀 남은 계약이 “만료 D-3” 으로 공지되고 직전 자정에 종료일을 넘긴 딜은 아홉 시간 뒤에야 보고됐습니다.crm.opportunityHealth와intelligence.DealHealth는 더 단순한 형태의 같은 버그였습니다 — DATE 를 시각과 직접Before로 비교해 오늘 마감인 모든 딜이 자정 1초 뒤부터CLOSE_DATE_OVERDUE/CLOSE_DATE_PASSED였고, 그것도 프로세스 시계가 있던 달력 기준이었습니다. 이제 신호 함수들이 시각과 달력일을 함께 받고, 경과시간 질문(단계 정체, 접촉 공백)은 어느 존에서나 같은 길이이므로 시각을 계속 읽습니다.calendarDays는 의미를 유지한 채 “양쪽 모두 이미 자정 UTC 의 달력일이어야 한다”를 문서화했고, 새Loader.DateAt은 호출자가 이미 쥔 시각을 줄여주어 경과시간 답과 달력 답이 자정을 사이에 두고 갈라지지 않게 합니다. dedupe 키는 (type, entity) 에서 나오므로 움직이지 않고 제목·evidence 의 일수만 바뀝니다. 검증은 새 테스트 8개(engine: 존별 계약 D-day 와 종료일 경과 판정, 미배선 Clock 의 서울 폴백 · crm: 마감 당일/내일/어제와 WON, 존별 판정, 경과시간 플래그 3종이 달력일에 영향받지 않음 · timezone: DateAt 의 서울/UTC/nil)를 옛 구현으로 되돌리면 실제로 실패하는지 확인 —Before(now)로 되돌리자 “due today” 가 CLOSE_DATE_OVERDUE 로 나오고,calendarDays(now, …)로 되돌리자 서울에서 “만료 D-3” 이 나와 4건 실패 — 한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh전체 통과. 커밋 2c282f4. - 보류 아이디어: 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 상위 200건 뒤를 볼 방법이 없음 (가치 3 / 위험 1 / M) ·list_activities·get_contracts·list_quotations등 나머지 MCP 목록 도구는 페이징 자체가 없음 (가치 3 / 위험 2 / M) · 로컬 로그인이 꺼져 있을 때/auth/login이 403 과 401 을 갈라 돌려주는 두 번째 열거 통로 (가치 3 / 위험 2 / S) ·intelligence.DealHealth는 DB 를 직접 잡아 규칙 평가를 테스트할 수 없음 — 순수 함수로 떼어내면 이번에 고친 CLOSE_DATE_PASSED 를 포함해 규칙 8종을 DB 없이 검증할 수 있음 (가치 3 / 위험 1 / M) · CI 에gofmt -l검사 추가 — 이번 회차에도 커밋 직전 포맷 위반이 있었고 CI 는 잡지 못함 (가치 2 / 위험 1 / S)
2026-09-10
- 선택: 금액·확률 급락 규칙을 설정된 기간 안에서만 세기 (가치 3 / 위험 1 / 작업량 M)
- 결과: 성공
- 요약: 보류 아이디어였던 “DealHealth 규칙을 순수 함수로 떼어내기”를 하다가 그 규칙 안에서 실제 버그를 찾았습니다. 시드 데이터는
AMOUNT_DROP에{"percent":30,"days":90},PROBABILITY_DROP에{"points":20,"days":90}로 기간을 선언하고 관리자 콘솔은 그 JSON 을 편집하게 하며 “임계값을 변경하면 화면, API 와 MCP 분석에 즉시 반영됩니다” 라고 적혀 있지만, 두 규칙의days를 읽는 코드가 없어 질의가 돌려준 1년치 이력 전부를 훑었습니다(같은 이력을 쓰는CLOSE_DATE_SLIPPAGE만 기간을 지켰습니다). 그래서 열 달 전에 금액이 반토막 났다가 그 뒤로 안정된 딜은 오늘도 “금액 급감” 으로 남았고, 콘솔에서 기간을 좁혀도 아무 변화가 없었습니다. 이제 두 규칙이 자기 기간 안의 변경만 세고, 이력 조회 자체도 규칙들이 요구하는 가장 넓은 기간까지 물러나므로(기존 365일이 하한이라 좁아지는 경우는 없음) 1년보다 긴 기간이 조용히 잘리지 않습니다. 기간을 한정할 수 없는 값(0·음수·숫자가 아닌 값)은 규칙을 꺼버리는 대신 시드 기본값으로 물러납니다. 규칙 switch 는DealHealth밖으로 나와(rule, facts)의 순수 함수evaluateHealthRule이 되었고,healthFacts가 규칙이 읽는 나머지(이력·의사결정자 수·챔피언 수·단계 상한·now·today)를 모읍니다. 검증은 새 테스트 11개(규칙 8종 전부의 발화·침묵 경계, 단계 상한이 기본값을 이기고 0 은 이기지 못함, 앞당긴 날짜는 지연이 아님, 기간 밖 변경 무시와 좁힌 기간 반영, 알 수 없는 규칙 유형,windowDays폴백 4종, 이력 조회 기간이 가장 넓은 설정을 덮는지)를 옛 구현으로 되돌리면 실제로 실패하는지 확인 — 전체 이력 훑기·365일 고정·폴백 제거로 되돌리자 3건 실패, 리팩터링 회귀 확인용으로Before(facts.Today)→Before(now)와 단계 상한 조건을 망가뜨리자 2건 실패 — 한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh전체 통과. 커밋 574c081. - 보류 아이디어: 네 intelligence 필터의
Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 상위 200건 뒤를 볼 방법이 없음 (가치 3 / 위험 1 / M) ·list_activities·get_contracts·list_quotations등 나머지 MCP 목록 도구는 페이징 자체가 없음 (가치 3 / 위험 2 / M) ·DealsAtRisk는 영업기회 200건 각각에DealHealth를 돌려 딜마다 질의 4번을 쳐 최대 800회 왕복 — 이번에 뺀 순수 함수 덕에 팩트를 한 번에 모아 넣는 구조가 가능해짐 (가치 3 / 위험 2 / M) · 로컬 로그인이 꺼져 있을 때/auth/login이 403 과 401 을 갈라 돌려주는 두 번째 열거 통로 (가치 3 / 위험 2 / S) · CI 에gofmt -l검사 추가 — 지금은 포맷 위반이 CI 를 통과함 (가치 2 / 위험 1 / S)
2026-09-10
- 선택: 딜 헬스 팩트를 딜 묶음마다 한 번씩만 읽기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
DealsAtRisk와Coaching은 각각 열린 영업기회 200건을 받아 하나하나에DealHealth를 돌렸고,DealHealth한 번이 영업기회·규칙·연락처 역할·단계 상한·이력·스냅샷 쓰기로 왕복 6회를 칩니다 — 두 대시보드 모두 요청 하나에 약 1,200 왕복이었고, 딜마다 같은 규칙 집합을 200번 다시 읽었습니다(find_deals_at_riskMCP 도구도 같은 경로입니다). 새healthOf가 묶음 전체를 한 번에 채점합니다: 규칙·연락처 역할·단계 상한·이력을 집합 전체에 대해 한 번씩만 읽고(id 목록은ANY($1::text[]::uuid[])로 넘깁니다 — pgx 는[]string을uuid[]바이너리로 인코딩하지 못하지만 이 캐스트는 인덱스를 그대로 씁니다. 두 형식을 실제로 인코딩해 확인했습니다), 영업기회 행은 목록 질의가 이미 돌려준 것을 쓰며, 스냅샷은SendBatch로 한 번에 나갑니다 — 약 7회로 줄었습니다. 단건DealHealth도 같은 경로를 지나므로 대시보드와 상세가 갈라질 수 없고, 묶음이 시각 하나를 공유하므로 한 대시보드의 두 딜이 서로 다른 날짜로 판정되지 않습니다. 채점 자체는(rules, facts)의 순수 함수scoreDealHealth로 빠져나와, 그동안 DB 없이는 검증할 수 없던 합산·상한·위험등급 경계·권고 중복제거·종료된 딜 단축경로가 처음으로 테스트 가능해졌습니다. 검증은 새 테스트 7개(규칙별 가중치 합산과 evidence·신원 전달, 위험등급 8개 경계값, 130점이 100/0 으로 잘리되 요인은 둘 다 남는지, 같은 권고의 첫 순서 유지 중복제거, WON·LOST 딜이 규칙과 무관하게 100/HEALTHY 이고 빈 배열을 유지하는지, 고객·단계 id 중복 제거와 빈 id 제외)를 옛 동작으로 되돌리면 실제로 실패하는지 확인 — 상한 제거 시 130/-30, 종료딜 단축경로 제거 시 WON 이 80/20/CRITICAL, 고객 id 중복제거 제거 시[c1 c1 c2 c2], 중복제거 무력화 시 권고 3개, 등급 경계 20→21 시 20점이 HEALTHY 로 5건 실패 — 한 뒤go test -race ./...,go vet ./...,gofmt -l, web typecheck·build,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh전체 통과. 커밋 6e90880. - 보류 아이디어:
DealsAtRisk·Coaching은 여전히 가장 최근 갱신된 열린 딜 200건만 보고 그 뒤는 조용히 빠짐 — 이제 묶음 채점이 싸졌으니 페이지를 돌며 전체를 덮을 수 있음 (가치 3 / 위험 2 / M) · 네 intelligence 필터의Cursor필드는 어떤 질의도 쓰지 않고 어떤 핸들러도 채우지 않는 죽은 코드 — 상위 200건 뒤를 볼 방법이 없음 (가치 3 / 위험 1 / M) ·list_activities·get_contracts·list_quotations등 나머지 MCP 목록 도구는 페이징 자체가 없음 (가치 3 / 위험 2 / M) · 로컬 로그인이 꺼져 있을 때/auth/login이 403 과 401 을 갈라 돌려주는 두 번째 열거 통로 (가치 3 / 위험 2 / S) · CI 에gofmt -l검사 추가 — 지금은 포맷 위반이 CI 를 통과함 (가치 2 / 위험 1 / S)
2026-09-11
- 선택: 사용자·관리자 가이드를 실제 화면 캡처가 실린 완성본으로 다시 쓰기 (캠페인 guides-2026-09) (가치 5 / 위험 1 / 작업량 M)
- 결과: 성공
- 요약: 두 가이드는 v1.6.0 시절의 개요 수준에 ASCII 목업만 있고 캡처는 한 장도 없었으며, 관리자 가이드는
ADMIN_GUIDE.md와admin-guide.md두 벌이었습니다. 이제docs/USER_GUIDE.md·ADMIN_GUIDE.md가 GUIDE-STANDARD 의 구성을 따르고 32장의 캡처(docs/assets/guide/*.png, 1440×900 headless Chrome)를 싣습니다 — 전부 이 소스를 compose 로 띄워 가짜 데이터(데모전자(주),hong@example.com)를 채운 실제 화면입니다. 캡처 도구scripts/guide/screenshots.mjs(puppeteer-core) 는RELIO_GUIDE_DISPOSABLE=1없이는 시작하지 않고, 자격 증명은 전용 환경 변수로만 받으며(스크립트에 글자로 적힌 비밀번호 없음), 실행 중 API 요청 한도(120/분 — 화면당 호출이 여러 개라 30개 화면을 돌면 절반이 로그인 화면으로 떨어졌음)를 잠시 0 으로 올렸다가 finally 에서 원래 값으로 되돌리고, 그 한도를 표시하는 보안 화면은 복원 뒤에 찍습니다. 관리자 가이드의 환경 변수 표는internal/config/config.go(정확히 4개), 설정 표는 migrations 의system_settings시드, Role 은007_default_signin_role.sql, API 메서드는server.go의 등록 자리에서 읽었고, 그 과정에서security.allowed_origins는 서버 코드가 읽지 않는 설정이며ClientIP가X-Forwarded-For를 읽지 않아 프록시 뒤에서는 로그인 제한·감사 IP 가 프록시 주소가 된다는 사실을 문서에 그대로 적었습니다. 정본 정리:admin-guide.md는 대체 안내로, README 는 두 가이드를 가리키고, 예전 bespoke HTML 사본(USER_GUIDE.html·ADMIN_GUIDE.html)과 랜딩 페이지의 HTML 뷰어 링크를 지웠으며generate_docs.py가 다시 만들지 않게 했습니다. PDF 는 공용md2pdf.mjs로 생성(사용자 22쪽·그림 18, 관리자 24쪽·그림 14)하고 pymupdf 로 표지·표·그림 페이지를 렌더해 확인했습니다. 검증:check-env-contract.sh,check-static-assets.sh,node --check, 커밋 대상 전체에 대한 자격 증명 리터럴 검사(문서의 예시 비밀번호도<db-password>형 자리표시자로 교체), 캡처 32장 전부가 로그인 폴백이 아님을 픽셀 검사로 확인. Go·web 코드 변경 없음. 임시 compose 스택은 볼륨까지 폐기. 커밋 880adef. - 보류 아이디어:
ClientIP가X-Forwarded-For/Forwarded를 읽지 않아 리버스 프록시 뒤에서는 로그인 리미터가 프록시 주소 하나로 묶이고 감사 로그 IP 가 무의미함 — 신뢰 프록시 설정과 함께 붙여야 하며 환경 변수 계약(4개)을 넓히지 않고 관리자 설정으로 두는 설계가 필요 (가치 4 / 위험 2 / M) ·security.allowed_origins설정은 관리자 화면에 있으나 Go 코드 어디에서도 읽지 않음 — 실제로 적용하거나 화면에서 빼야 함 (가치 3 / 위험 1 / S) ·DealsAtRisk·Coaching은 여전히 최근 갱신 열린 딜 200건만 보고 그 뒤는 조용히 빠짐 (가치 3 / 위험 2 / M) · 네 intelligence 필터의Cursor필드는 죽은 코드 (가치 3 / 위험 1 / M) · CI 에gofmt -l검사 추가 (가치 2 / 위험 1 / S)
2026-09-11
- 선택: 가이드 캡처 커밋을 검증 통과 상태로 다시 올리고, 전략 고객 계획 캡처를 채워 넣기 (캠페인 guides-2026-09) (가치 5 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약: 08:11 회차의 가이드 커밋 880adef 는 러너 검증
go build ./...이internal/webui/assets.go:8:12: pattern dist/*: no matching files found로 실패해 브랜치auto/2026-09-11-0811에만 남고 푸시되지 않았습니다 — 원인은 문서가 아니라 환경입니다.internal/webui는//go:embed dist/*로 React 빌드 결과를 박아 넣는데dist/는 gitignore 대상이라 새로 만든 worktree 에는 없고, 러너의 auto 검증은 web build 보다go build를 먼저 돌립니다. 이전 회차들은 에이전트가 web build 를 직접 돌려 놓았기 때문에 통과했고, 08:11 회차는 Go·web 코드를 건드리지 않아 그 단계를 생략했습니다. 이번 회차는 그 커밋을 이 브랜치에 cherry-pick 하고(a73db96, 작성자·메시지 그대로)cd web && npm ci && npm run build로internal/webui/dist를 만들어 둔 뒤go build ./...,go vet ./...,gofmt -l,go test ./..., web typecheck·build,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh를 모두 통과시켰습니다. 그 위에 보류 아이디어였던 캡처 결함을 고쳤습니다:screenshots.mjs는 계획 시드 여부를status로 판단했는데 서버(internal/relationship/account.goGetAccountPlan)는 행이 없어도status: "DRAFT"인 빈 템플릿을 돌려주므로 PUT 이 항상 건너뛰어져 고객 360 캡처의 계획 섹션이 빈 초안 폼이었습니다. 저장된 행만 갖는id로 판단하도록 바꾸고, 개발 compose 를 임시 포트(18080)·임시 비밀번호(파일에 적지 않고openssl rand로 생성)로 띄워customer-360-relationships.png를 다시 찍고전략 고객 계획패널로 스크롤한customer-360-plan.png를 새로 찍어 사용자 가이드 3.3 절에 실었습니다(상태·미판매 영역 상태 이름은 web/src 의 label 매핑에서 확인: 초안/사용 중/보관, 미제안/요구 파악/영업기회 전환/거래 고객/해당 없음).customer-360.png는 다시 찍어 픽셀 비교한 결과 동일해 그대로 두었습니다. USER_GUIDE.pdf 는 공용 md2pdf 로 재생성(23쪽)해 pymupdf 로 새 그림 페이지를 렌더해 확인했고, ADMIN_GUIDE 는 변경이 없어 손대지 않았습니다. 임시 스택은 볼륨·이미지까지 폐기. 커밋 a73db96(cherry-pick), 9318481. - 보류 아이디어:
ClientIP가X-Forwarded-For/Forwarded를 읽지 않아 리버스 프록시 뒤에서 로그인 리미터·감사 IP 가 프록시 주소 하나로 묶임 — 신뢰 프록시 설정과 함께 (가치 4 / 위험 2 / M) · 새 worktree 에서go build ./...가 web build 없이는 실패함 —internal/webui/dist에 커밋된 자리표시자를 두거나 러너 검증 순서를 web build 먼저로 바꿔야 문서만 바꾼 회차가 검증에서 떨어지지 않음 (가치 3 / 위험 1 / S) ·security.allowed_origins설정은 관리자 화면에 있으나 Go 코드 어디에서도 읽지 않음 (가치 3 / 위험 1 / S) ·DealsAtRisk·Coaching은 최근 갱신 열린 딜 200건만 보고 그 뒤는 조용히 빠짐 (가치 3 / 위험 2 / M) · CI 에gofmt -l검사 추가 (가치 2 / 위험 1 / S)
2026-09-11
- 선택: 수정 과제 — 새 checkout 에서 web build 없이도
go build ./...·go test ./...가 통과하게 하기 (가치 4 / 위험 1 / 작업량 S) - 결과: 성공
- 요약: 배정된 실패 두 건을 먼저 확인했습니다. (1) 가이드 캠페인 08:11 회차의 검증 실패는
internal/webui/assets.go:8:12: pattern dist/*: no matching files found—//go:embed dist/*가 gitignore 된 React 빌드 결과를 요구하는데 러너의 auto 검증은 새 worktree 에서go build ./...를 web build 보다 먼저 돌립니다(bin/run.shrun_verify 순서로 확인). 그 회차의 가이드는 12:41 회차가 cherry-pick 하고 캡처를 보완해 PR #18(auto/2026-09-11-1241)로 열려 있고, 검증 7개·CI·리뷰(risk=low)를 모두 통과한 채 low-risk 단계의 파일 수 제한(48 > 12)으로 사람 승인을 기다리는 중입니다 — GitHub API 로 상태 open·미머지를 확인했습니다. 같은 48개 파일을 이 브랜치에 다시 올리면 PR 만 두 벌이 되므로 가이드는 중복하지 않고, 이 회차는 그 실패의 근본 원인을 고쳤습니다. (2) “릴리즈 워크플로 2회 실패”는 2026-09-06 의Verify upgrade from previous release건으로, 같은 날 3e85fcf 가 고쳤고 이후 v1.11.19 가 자산relio-v1.11.19.tar.gz와 함께 발행되었으며(releases API 확인) 그 뒤 release.yml 은 바뀌지 않았습니다 — 워크플로에 남은 결함은 없어 손대지 않았고,previous-release-tag-test.sh8건은 이번에도 통과합니다. 고친 내용:internal/webui/dist/README하나를 커밋해 embed 패턴이 빌드 전에도 매치되게 했고, vite 가emptyOutDir: true로 그 디렉터리를 비우므로 같은 파일을web/public/README에 두어 모든 web build 가 그대로 복사해 되돌려 놓게 했습니다(빌드 뒤git status가 깨끗함을cmp로 확인)..gitignore의dist/는 Makefile 출력인/dist/와internal/webui/dist/*+ 예외로 나눴습니다. 두 사본이 갈라지면 빌드가 checkout 을 더럽히므로internal/webui/assets_test.go와check-static-assets.sh(커밋 여부 + 바이트 동일)가 막습니다. 그러자go test ./...가 두 번째 숨은 의존을 드러냈습니다 —TestSPAShellOnlyAnswersNavigation이 실제 embed 의index.html을 요구해 빌드 전에는 500 으로 실패했으므로,spaHandler에서 fs 를 받는spaHandlerFor를 떼어내fstest.MapFS가짜 빌드로 테스트하고, 빌드 파일은 immutable 로 서빙되고 점 없는 anchor 경로(/README)는 shell 로 떨어지며 빌드가 없으면 500 인 것을 새 테스트로 고정했습니다. 검증: anchor 를 빼면 옛 오류가 그대로 재현되고 public 사본을 바꾸면 Go 테스트와 스크립트가 실제로 실패함을 확인한 뒤, 러너와 같은 순서로 web build 없이go build ./...·go vet ./...·go test ./...통과 → web typecheck·build → anchor 가 바이트 동일하게 복원되고 tree 가 깨끗함 →go test -race ./...,gofmt -l,check-env-contract.sh,check-static-assets.sh,previous-release-tag-test.sh, 그리고 CI 의 offline-image 와 같은docker build전체 통과. 워크플로 파일은 건드리지 않았습니다. - 보류 아이디어:
ClientIP가X-Forwarded-For/Forwarded를 읽지 않아 리버스 프록시 뒤에서 로그인 리미터·감사 IP 가 프록시 주소 하나로 묶임 — 신뢰 프록시 설정과 함께 (가치 4 / 위험 2 / M) ·security.allowed_origins설정은 관리자 화면에 있으나 Go 코드 어디에서도 읽지 않음 (가치 3 / 위험 1 / S) · PR #18 리뷰가 남긴screenshots.mjs의 두 결함 — 헤더 주석이 “복원할 것이 없다” 고 하지만 rate limit 을 바꿨다 되돌리고, Chrome 후보 목록이.find(Boolean)이라 존재 확인 없이 첫 경로만 씀 (PR #18 머지 뒤) (가치 2 / 위험 1 / S) ·DealsAtRisk·Coaching은 최근 갱신 열린 딜 200건만 보고 그 뒤는 조용히 빠짐 (가치 3 / 위험 2 / M) · CI 에gofmt -l검사 추가 (가치 2 / 위험 1 / S)