Vendra
요약. Vendra: 자율 개선 회차 21회, 릴리즈 13건. 최근 릴리즈 v0.7.50 (자산 1개). 건강 C
- 21회차
- 1프로젝트
- 13배포 준비 완료
- 0릴리즈 진행 중
- 1병합 완료
- 6검토 대기
- 1검증 실패
- 0변경 없음
- 0실행 오류
- $123.44비용
- 3시간 51분에이전트 시간
현황
- 저장소
- https://github.com/hkjang/Vendra
- 마지막 회차
- 2026-09-12 13:09 KST — • 기타 guarded files, PR open PR #120
- 최근 릴리즈
- v0.7.50 — released · 자산 1개 (이전 v0.7.49: 1개) 전체 릴리즈 →
회차 이력
| 일시 | 프로젝트 | 결과 |
|---|---|---|
| 2026-09-12 13:09 | Vendra | 검토 대기 guarded files, PR open PR #120 |
| 2026-09-11 12:13 | Vendra | 검토 대기 review held, PR open PR #119 |
| 2026-09-11 00:35 | Vendra | 검증 실패 verify failed: secrets in diff |
| 2026-09-10 18:35 | Vendra | 검토 대기 guarded files, PR open PR #118 |
| 2026-09-10 10:01 | Vendra | 검토 대기 review held, PR open PR #117 |
| 2026-09-09 14:21 | Vendra | 검토 대기 guarded files, PR open PR #116 |
| 2026-09-09 06:17 | Vendra | 배포 준비 완료 merged PR #115, released v0.7.48 |
| 2026-09-08 23:46 | Vendra | 검토 대기 review held, PR open PR #114 |
| 2026-09-08 18:55 | Vendra | 배포 준비 완료 merged PR #113, released v0.7.47 |
| 2026-09-08 14:52 | Vendra | 배포 준비 완료 merged PR #112, released v0.7.46 |
| 2026-09-07 08:04 | Vendra | 배포 준비 완료 merged PR #111, released v0.7.45 |
| 2026-09-06 23:09 | Vendra | 배포 준비 완료 merged PR #110, released v0.7.44 |
| 2026-09-06 11:37 | Vendra | 배포 준비 완료 merged PR #109, released v0.7.43 |
| 2026-09-06 01:18 | Vendra | 배포 준비 완료 merged PR #108, released v0.7.42 |
| 2026-09-04 23:39 | Vendra | 배포 준비 완료 merged PR #107, released v0.7.41 |
| 2026-09-03 21:14 | Vendra | 배포 준비 완료 merged PR #106, released v0.7.40 |
| 2026-09-03 13:03 | Vendra | 배포 준비 완료 merged PR #105, released v0.7.39 |
| 2026-09-03 07:21 | Vendra | 배포 준비 완료 merged PR #104, released v0.7.38 |
| 2026-09-03 01:22 | Vendra | 배포 준비 완료 merged PR #103, released v0.7.37 |
| 2026-09-02 19:01 | Vendra | 배포 준비 완료 merged PR #102, released v0.7.36 |
| 2026-09-02 13:08 | Vendra | 병합 완료 merged PR #101, release released |
비용·사용량
| 시각 | 프로젝트 | 단계 | 시간 | 턴 | 비용 | 토큰 입력/출력 | 종료 |
|---|---|---|---|---|---|---|---|
| 13:08 | Vendra | 개선 | 20분 | 97 | $11.63 | 14.9M / 84K | success |
| 08:36 | Vendra | 릴리즈 | 1분 | 9 | $0.46 | 258K / 4K | success |
| 12:13 | Vendra | review | 4분 | 32 | $2.49 | 2.6M / 13K | success |
| 12:09 | Vendra | 개선 | 8분 | 64 | $4.12 | 5.1M / 25K | success |
| 00:35 | Vendra | 개선 | 0분 | 1 | $16.08 | 0 / 0 | error_max_budget_usd |
| 18:35 | Vendra | 개선 | 15분 | 95 | $8.05 | 10.0M / 57K | error_max_budget_usd |
| 10:01 | Vendra | review | 6분 | 40 | $2.38 | 2.3M / 22K | success |
| 09:54 | Vendra | 개선 | 14분 | 94 | $7.12 | 8.6M / 55K | success |
| 14:53 | Vendra | 릴리즈 | 2분 | 30 | $0.84 | 590K / 10K | success |
| 14:21 | Vendra | 개선 | 10분 | 88 | $5.76 | 6.7M / 43K | success |
| 06:16 | Vendra | 릴리즈 | 1분 | 14 | $0.41 | 217K / 4K | success |
| 06:14 | Vendra | review | 4분 | 21 | $1.10 | 768K / 12K | success |
| 06:10 | Vendra | 개선 | 11분 | 87 | $4.96 | 5.9M / 40K | success |
| 23:46 | Vendra | review | 3분 | 19 | $1.24 | 872K / 12K | success |
| 23:43 | Vendra | 개선 | 13분 | 88 | $7.23 | 9.0M / 52K | success |
| 18:54 | Vendra | 릴리즈 | 2분 | 16 | $0.54 | 312K / 6K | success |
| 18:52 | Vendra | review | 6분 | 40 | $2.59 | 2.6M / 20K | success |
| 18:46 | Vendra | 개선 | 17분 | 91 | $7.10 | 8.6M / 55K | success |
| 14:51 | Vendra | 릴리즈 | 2분 | 15 | $0.53 | 322K / 5K | success |
| 14:48 | Vendra | review | 5분 | 43 | $2.48 | 2.6M / 18K | success |
| 14:43 | Vendra | 개선 | 14분 | 81 | $5.84 | 6.2M / 55K | success |
| 08:03 | Vendra | 릴리즈 | 1분 | 12 | $0.45 | 274K / 4K | success |
| 08:01 | Vendra | review | 4분 | 32 | $1.63 | 1.5M / 16K | success |
| 07:57 | Vendra | 개선 | 17분 | 83 | $6.05 | 7.1M / 50K | success |
| 23:08 | Vendra | 릴리즈 | 2분 | 19 | $0.61 | 372K / 6K | success |
| 23:05 | Vendra | review | 5분 | 35 | $2.16 | 2.1M / 17K | success |
| 23:00 | Vendra | 개선 | 11분 | 74 | $5.44 | 6.6M / 38K | success |
| 11:36 | Vendra | 릴리즈 | 1분 | 11 | $0.41 | 214K / 3K | success |
| 11:35 | Vendra | review | 3분 | 20 | $1.31 | 995K / 12K | success |
| 11:31 | Vendra | 개선 | 12분 | 77 | $4.83 | 5.1M / 46K | success |
아이디어 백로그 — 대기 14 / 전체 15
| 아이디어 | 가치/위험/크기 | 상태 | 메모 | 갱신 |
|---|---|---|---|---|
| 가이드 표류 가드 — 참조된 그림 전수와 환경 변수 표를 코드에 묶기 | 3/1/S | 대기 | deployment_docs_test.go 옆에서 docs/images/guide/*.png 양방향 대조와 ADMIN_GUIDE 3.1 표의 변수 이름을 internal/config 의 os.Getenv 전수와 대조. 이번 회차도 손으로 대조했다(누락·미사용 0) — 테스트로 옮길 만하다. | 2026-09-12 |
| 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft) | 3/1/S | 대기 | productivity.go 의 ORDER BY updated_at DESC LIMIT 50 이 동률에서 순서를 정하지 않는다. 고치는 법은 AND draft_key<>$3. 실효는 주로 테스트 안정성. | 2026-09-12 |
| web 프론트엔드 Sourcing.tsx(비교표·평가위원·낙찰)에 테스트 없음 | 3/1/L | 대기 | sourcing-standing.test.tsx 만 있고 비교 Matrix·평가위원·낙찰 경로는 덮이지 않았다. | 2026-09-12 |
| 견적 통화가 검증되지 않아 비교 Matrix 가 다른 통화의 금액을 한 척도에 올림 | 3/2/M | 대기 | portalSourcingResponse 는 통화를 글자로만 받고 기본 KRW, recalculateSourcing 은 통화를 보지 않는다. 2026-09-10 의 같은 접근(2e1abb1)이 사람에게 반려됐으니 접근을 바꿔야 한다. | 2026-09-12 |
| 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 | 3/2/M | 대기 | 폼이 쓰는 키가 API 로는 무제한, 목록 응답이 블롭 전체를 반환. workflowMatches 가 읽는 네 키는 결재 라우팅에도 닿는다. | 2026-09-12 |
| 공급업체 담당자·조직 이관이 API 로 불가능 | 3/2/M | 대기 | updateSupplier 의 SET 에 owner_id/organization_id 가 없어 PATCH 가 200 으로 답하고 아무것도 바꾸지 않는다. own 범위 계정에게 영영 보이지 않는다. | 2026-09-12 |
| 공급업체 연간 지출과 지출 리포트가 통화를 섞어 더함 | 3/3/M | 대기 | annual_spend·집중도 리포트·대시보드 합계가 통화를 무시한다. 기준통화(spend.currency, 아무도 읽지 않음)로 묶거나 통화별 집계 — 그 결정이 위험의 대부분. | 2026-09-12 |
| 업무 객체 status 가 여전히 임의 문자열 | 3/3/M | 대기 | 결재 소유 상태 넷 말고는 무검사. analytics 의 status IN(...) 집계가 오타 하나로 조용히 빠진다. Lifecycle 관리 화면의 유형별 상태 목록이 어휘의 출발점. | 2026-09-12 |
| 구매자 화면에는 통화 선택이 없어 모든 업무 객체가 KRW 로만 생성됨 | 2/1/S | 대기 | Objects.tsx 가 currency:"KRW" 를 하드코딩. 통화 검증 항목과 함께 다루는 편이 낫다. | 2026-09-12 |
| 이미 계정이 있는 주소로 초대를 발급해도 발급 시점에는 아무 말이 없음 | 2/1/S | 대기 | createInvitation 이 users.email 을 보지 않는다. 발급 응답에 notice 를 실으면 끝. | 2026-09-12 |
| CI 의 go job 이 go test ./internal/... 만 돌려 ./cmd/... 를 빼놓음 | 2/1/S | 대기 | ci.yml 은 ./internal/... 만, Makefile·README 는 ./cmd/... 포함. cmd/vendra 에 테스트가 없어 실효는 없지만 불일치. | 2026-09-12 |
| 방문 추적 설정 조회를 짧게 캐시하기 | 2/2/S | 대기 | index.html 과 /momento/* 요청마다 settings 를 한 번 읽는다(PK 조회 하나). 트래커가 페이지마다 이벤트를 여러 개 보내는 배포에서는 몇 초짜리 캐시가 값싸다. 다만 '저장 즉시 반영' 문구와의 균형이 필요. | 2026-09-12 |
| 차단 출처 기록을 프로세스 재기동 뒤에도 남기기 / 다중 레플리카에서 합치기 | 2/2/M | 대기 | Recorder 는 표준대로 메모리 고리 버퍼라 레플리카가 여럿이면 관리자가 보는 목록이 어느 인스턴스에 닿았느냐에 따라 다르다. 표준이 메모리를 택한 이유(감사 기록이 아님)가 있어 필요해질 때만. | 2026-09-12 |
| 캡처 스크립트에 진입점이 없음 — make guide-screenshots 타깃 | 1/1/S | 대기 | Makefile·package.json 어디에도 guide-screenshots 가 없다. 필요한 환경 변수 넷을 한 자리에서 요구할 수 있다. 실효는 문서 관리자 편의. | 2026-09-12 |
| 관리자가 화면에서 방문 추적 스크립트를 붙일 수 있게 하되 잠긴 CSP 는 그대로 두기 (캠페인 tracking-2026-09) | 4/2/M | 완료 | commit b6d1883. internal/tracking 패키지, 요청마다 nonce, 스니펫 출처 추출, csp-report 기록·허용, Momento 우선·같은 오리진 프록시, 마이그레이션 017 꺼짐 기본, 관리 화면 패널, 가이드 3.5·PDF. 실제 Chrome 으로 수집 도착 확인. | 2026-09-12 |
교훈 (깨졌던 변경)
- 2026-09-10 rejected-by-human — 사람이 PR 을 반려함. 같은 접근은 피할 것. (링크)
원장 (에이전트가 남긴 기록)
2026-09-02
- 선택: 요청 본문의 날짜를 검증 없이
$n::date로 캐스팅하던 네 개의 쓰기 경로 수정 (가치 4 / 위험 1 / 작업량 M) - 결과: 성공 (commit e5cef01)
- 요약:
validDateFields는 business object의 세 날짜에만 적용돼 있었고, 같은 결함이 견적 유효일(포털 입찰), 예정일(포털 납품/인보이스/문의), 거래 시작일(공급업체 등록), 거래일(구매 원장) 네 곳에 남아 있었다. 잘못된 날짜가 PostgreSQL 캐스트까지 도달해 “…저장하지 못했습니다”라는 필드를 지목하지 않는 오류로 돌아왔고, 특히 포털 입찰은 마감 직전에 견적 전체(금액·품목·조건)가 저장되지 않으면서 공급업체에게 아무 단서도 주지 못했다. 철자가 아니라 연산(요청 파라미터의 date 캐스트)을 기준으로 훑어 찾았고, 같은 기준으로 패키지를 파싱해 검증 누락을 잡는TestEveryDateCastIsGuarded와 네 엔드포인트를 실제로 호출하는 통합 테스트를 추가했다. 검증: docker postgres:16-alpine을 띄워 CI와 동일한 세 DSN(VENDRA_TEST_DSN / MIGRATE / UPGRADE)으로go test ./internal/... ./cmd/...전체 통과,gofmt -l·go vet무결. 가드 테스트는 수정 하나를 되돌리면 실패하는 것까지 확인했다. - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) web프론트엔드 테스트 커버리지가 9개 파일뿐 — Sourcing/Objects/Admin 페이지에 테스트 없음 (가치 3 / 위험 1 / L)- 공급업체 수정(PATCH /suppliers/{id})은 tradingSince·annualSpend를 아예 갱신하지 않음 — 의도인지 누락인지 확인 필요 (가치 3 / 위험 2 / S)
POST /spend/transactions의 quantity·unitPrice·amount에 상한이 없음 — 폼에만 존재하는 제약 (가치 3 / 위험 2 / M)Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)
- CI의 go job이
- 릴리즈: v0.7.35 (2026-09-02, 태그 사후 푸시)
2026-09-02
- 선택: 요청 본문의 숫자를 범위 검사 없이 numeric 컬럼에 넣던 쓰기 경로 전부 수정 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (commit 1be68e5)
- 요약: 날짜 스윕과 같은 기준(철자가 아니라 연산 — 요청 본문의 숫자가 numeric 컬럼에 도달)으로 훑어, 범위 검사가 있는 곳이 리스크 핸들러 하나뿐임을 확인하고 나머지를 채웠다. 업무 객체의 금액·점수, 공급업체 연간 거래금액, 구매 원장의 금액·수량·단가, 포털 입찰의 총 견적금액·납기, 포털 제출 금액이 대상이다. 범위를 넘으면 numeric(20,2)/integer가 거부해 “저장하지 못했습니다”로 필드 없이 돌아왔고, 범위 안이면 더 나빴다 — 폼은 전부 min=”0”인데 API는 음수를 받아 연간 지출 합계와 집중도 분모를 깎고 승인 라우팅의 minAmount 아래로 빠졌으며, 음수 입찰은 200을 받고도 가격 비교에서 제외됐고, 99,999점 객체는 Supplier 360 품질 평균에 섞였다. 상한은 10^15 — numeric(20,4)의 10^16과 float64의 2^53 둘 다 아래라 받은 값이 그대로 저장된다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결. 구매 원장 가드를 되돌리면 통합 테스트와 파싱 가드 테스트가 모두 실패하는 것까지 확인했다. - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) web프론트엔드 테스트 커버리지가 9개 파일뿐 — Sourcing/Objects/Admin 페이지에 테스트 없음 (가치 3 / 위험 1 / L)- 공급업체 수정(PATCH /suppliers/{id})은 tradingSince·annualSpend·businessNumber를 갱신하지 않음 — 편집 폼이 보내지 않으므로 의도로 보이나 API 전용 클라이언트에는 무응답 (가치 2 / 위험 2 / S)
Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)- 문자열 길이 상한(maxIdentifierLen)이 공급업체·리스크·문서에만 적용됨 — 업무 객체 제목·통화 등은 미적용 (가치 3 / 위험 1 / M)
- CI의 go job이
- 릴리즈: v0.7.36 (2026-09-02)
2026-09-03
- 선택: 요청 본문의 짧은 자유 텍스트(레코드 이름·제목·코드)를 길이 검사 없이 text 컬럼에 넣던 쓰기 경로 전부 수정 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (commit ba6e366)
- 요약: 날짜·숫자 스윕과 같은 기준(철자가 아니라 연산 — 요청 본문의 짧은 문자열이 레코드를 표시하는 text 컬럼에 도달)으로 훑었다. maxIdentifierLen은 공급업체 10개 필드, 리스크 유형, 문서 이름에만 걸려 있었고 나머지는 전부 무방비였다. text에는 길이가 없어 20,000자 계약 제목이 그대로 저장된 뒤 레코드를 열지도 않은 사람의 목록·드롭다운·내보내기·감사 로그를 전부 밀어냈고, 구매 원장의 품목명·분류는 Spend 차트의 그룹 키라 범례가 그 길이에 맞춰졌으며, 표시 이름은 그 계정이 남기는 모든 감사 줄과 결재 단계의 서명이었다. 특히 포털 자가등록은 createSupplier가 처음부터 검사해 온 suppliers.name 컬럼을 아무도 재지 않은 두 번째 문으로 썼는데, 그 문이 바로 외부인이 지나는 문이다. rows.go에 validDateFields·validNumberFields 옆에 validTextFields를 두어 거절이 고칠 입력란을 지목하게 했고, 호출자가 하나뿐이던 overlongField를 대체했다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결. 패키지를 파싱하는 TestEveryRequestLabelIsBounded(검사를 뺀 필드의 사유는 unboundedByDesign에 명시)와 17개 엔드포인트를 실제로 호출하는 TestEveryLabelOnAWriteIsBounded를 추가했고, 포털 프로필 가드를 되돌리면 둘 다 실패하는 것까지 확인했다. - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) web프론트엔드 테스트 커버리지가 9개 파일뿐 — Sourcing/Objects/Admin 페이지에 테스트 없음 (가치 3 / 위험 1 / L)- 업무 객체의 data jsonb 블롭에는 어떤 검증도 없음 — 폼이 쓰는 키(품목·수량·단가)가 API로는 무제한 (가치 3 / 위험 2 / M)
Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)- 공급업체 수정(PATCH /suppliers/{id})은 tradingSince·annualSpend·businessNumber를 갱신하지 않음 — 편집 폼이 보내지 않으므로 의도로 보이나 API 전용 클라이언트에는 무응답 (가치 2 / 위험 2 / S)
- CI의 go job이
- 릴리즈: v0.7.37 (2026-09-03)
2026-09-03
- 선택: 요청 값이 리스크 등급(LOW/MEDIUM/HIGH/CRITICAL)으로 들어가는 모든 쓰기 경로에 어휘 검사 추가 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (commit d36cc9f)
- 요약: 날짜·숫자·문자열 스윕과 같은 기준(철자가 아니라 연산 — 요청 값이 리스크 등급 컬럼이나 그 등급으로 라우팅하는 결재 규칙에 도달)으로 훑었다. 여섯 개 문 중 검사하던 곳은 createRisk의 severity 하나뿐이었다. 등급은 자유 텍스트가 아니라 질의가 분기하는 단어다 — 낙찰 계산은
CASE risk_level WHEN 'LOW' THEN 100 … ELSE 0이라 “high”로 저장된 업체는 고위험이 아니라 CRITICAL보다 나쁜 값으로 읽혀 입찰에서 지고, 대시보드의IN('HIGH','CRITICAL')집계에서는 빠지며,NOT IN('CRITICAL')추천 목록에는 남는다. 업무 객체 쪽이 더 나쁘다: “High”는 HIGH를 요구하는 결재 규칙과 매칭되지 않아 matchingWorkflow가 아무것도 찾지 못하고, 제출은 결재자 없이 그 자리에서 승인된다(no_matching_workflow). 워크플로 조건은 등급과 함께 형태도 검사한다 — workflowConditions로 파싱되지 않는 조건 하나가 저장되면 그 객체 유형의 모든 제출이 영구히 500이 된다. rows.go에 riskGrades/enumField/validEnumFields를 두고 createRisk의 switch를 대체했다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결. 패키지를 파싱하는 TestEveryRiskGradeIsInTheVocabulary와 여섯 엔드포인트를 실제로 호출하는 TestEveryRiskGradeOnAWriteIsInTheVocabulary를 추가했고, updateSupplier 가드와 updateWorkflow 가드를 각각 되돌리면 둘 다 실패하는 것까지 확인했다. - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) - 업무 객체의 status는 여전히 임의 문자열 —
status IN('completed','accepted','closed')같은 집계가 오타 하나로 조용히 빠짐(유형별 어휘가 어디에도 정의돼 있지 않아 위험은 큼) (가치 3 / 위험 3 / M) - 업무 객체의 data jsonb 블롭에는 어떤 검증도 없음 — 폼이 쓰는 키(품목·수량·단가)가 API로는 무제한이고 목록 응답이 블롭 전체를 반환 (가치 3 / 위험 2 / M)
- 워크플로 조건 폼 라벨은 “공급업체 Risk 조건”인데 실제로는 업무 객체의 risk_level과 매칭됨 — 라벨과 동작 불일치 (가치 3 / 위험 2 / S)
Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)
- CI의 go job이
- 릴리즈: v0.7.38 (2026-09-03)
2026-09-03
- 선택: 요청 본문이 실어 나르는 레코드 id를 형식 검사 없이
$n::uuid로 넘기던 쓰기 경로 전부 수정 (가치 4 / 위험 1 / 작업량 M) - 결과: 성공 (commit 5cdf29e)
- 요약: 날짜·숫자·문자열·어휘 스윕과 같은 기준(철자가 아니라 연산 — 호출자가 준 id가 uuid 컬럼에 쓰이거나 그 id로 레코드를 조회)으로 훑었다. id가 들어오는 문은 셋인데 둘만 지키고 있었다 — 경로 id는 app.go의 라우터가, 질의 필터는 uuidParam이. 본문이 실어 나르는 id는 그냥 통과했고 열여섯 개 핸들러가 무방비였다. 문제는 메시지만이 아니라 답이 사람마다 달랐다는 것이다: 조회를 먼저 하는 핸들러는 실패한 질의를 거부로 읽어(supplierScopeAllowed는 어떤 오류든 false) 공급업체 번호를 붙여넣은 요청에 403 “데이터 접근 범위를 벗어났습니다”라고 답했고 — 존재하지도 않는 레코드를 볼 권한이 없다고 말한 셈 — company 스코프 계정은 그 조회를 건너뛰고 캐스트에 닿아 필드를 지목하지 않는 400을 받았다. 문서 업로드는 파일을 이미 디스크에 스트리밍·해싱한 뒤 insert가 도는 자리라 재시도마다 업로드를 다시 쓰게 했고, 포털의 납품·인보이스 등록(공급업체가 메일에서 id를 복사해 붙이는 곳)이 외부인이 지나는 문이다. rows.go에 validDateFields·validNumberFields·validTextFields·validEnumFields 옆에 validUUIDFields/validRecordID를 두고 스코프 검사보다 앞에 놓았다. MCP의 mcpObjects와 compare_suppliers도 캐스트 실패를 로그로 남기고 “도구를 실행하지 못했습니다”를 모델에 돌려주는 대신 도구 오류로 답한다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결. 패키지를 파싱하는 TestEveryRequestRecordIDIsChecked(검사에서 뺀 erpVendorId의 사유는 notARecordID에 명시)와 열여섯 엔드포인트를 실제로 호출하는 TestEveryRecordIDOnAWriteIsChecked, 포털 쪽 TestAPortalWriteNamesAMalformedRecordID를 추가했고, createObject 가드와 uploadDocument 가드를 각각 되돌리면 셋 다 실패하는 것까지 확인했다. 덤으로 newPortalFixture가 감사 로그보다 계정을 먼저 지워 정리가 조용히 실패하고 다음 실행이 공급업체 번호 중복으로 죽던 것을 고쳤다(같은 정리 순서 함정이 세 번째다). - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) - 업무 객체의 status는 여전히 임의 문자열 —
status IN('completed','accepted','closed')같은 집계가 오타 하나로 조용히 빠짐(유형별 어휘가 어디에도 정의돼 있지 않아 위험은 큼) (가치 3 / 위험 3 / M) - 업무 객체의 data jsonb 블롭에는 어떤 검증도 없음 — 폼이 쓰는 키(품목·수량·단가)가 API로는 무제한이고 목록 응답이 블롭 전체를 반환 (가치 3 / 위험 2 / M)
- 이메일 형식은 어디서도 검사되지 않음 — 초대·사용자 생성·공급업체 담당자가 “김구매”를 이메일로 저장하고, 그 주소로 알림이 나감 (가치 3 / 위험 1 / M)
Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)
- CI의 go job이
- 릴리즈: v0.7.39 (2026-09-03)
2026-09-03
- 선택: 요청이 실어 나르는 이메일 주소를 형식·정규화 없이 email 컬럼에 넣던 쓰기 경로 전부 수정 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (commit 6e88674)
- 요약: 날짜·숫자·문자열·어휘·레코드id 스윕과 같은 기준(철자가 아니라 연산 — 요청 값이 email 컬럼에 도달)으로 훑었고, 이메일은 애플리케이션 어디에서도 검사된 적이 없었다. 일곱 개 문이 무방비였다: 공급업체 등록·수정(대표 이메일과 taxInfo에 중첩된 세금계산서 이메일), 담당자 두 문, 포털 프로필·담당자, 관리자 계정 생성, 초대. 결함은 두 갈래다. (1) 형식 미검사 — “김구매”가 모든 표면에 주소로 저장됐고 폼의 type=”email” 말고는 아무도 막지 않아, 포털·API·붙여넣기는 그냥 통과했다. 거절이 없으니 보고도 없고, 메일이 안 온 뒤에야 알게 된다. (2) 정규화 미적용 — users.email은 계정의 필드가 아니라 정체성인데 INSERT는 lower만 하고 trim은 하지 않고 login은
WHERE email=$1을 자기가 trim한 값으로 조회한다. 스프레드시트 셀에서 공백째 붙여넣은 주소는 어떤 비밀번호로도 열리지 않는 계정이 되고 증상은 영원히 “자격 증명이 올바르지 않습니다”뿐이며, 같은 공백이 ON CONFLICT(email)을 빗나가게 해 Keycloak 로그인이 두 번째 계정을 조용히 만든다. 초대 주소는 가입이 공급업체 레코드와 포털 계정 양쪽에 복사하는 값이라 오타 하나가 연락 안 되는 업체와 못 들어가는 계정 둘 다가 된다. rows.go에 validEmailFields/validEmail/isEmailAddress를 기존 다섯 검사 옆에 두었고, 로컬 파트는 의도적으로 ASCII로 제한했다(한글 로컬 파트는 한 칸 아래 상자에 들어간 이름이다). 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결. 패키지를 파싱하되 요청 필드명이 아니라 statement의 컬럼 목록을 읽는 TestEveryStoredEmailIsAnAddress(주소 출처가 설정·로그인 잠금 키·OIDC 클레임·담당자 레코드·초대장인 다섯 문은 notFromTheCaller에 사유 명시)와, 엔드포인트를 실제로 호출하고 붙여넣은 주소로 만든 계정으로 로그인까지 해보는 TestEveryEmailOnAWriteIsAnAddress·TestAPortalWriteNamesAMalformedEmail을 추가했다. updateSupplier와 portalCreateContact 가드를 각각 되돌리면 셋 다 실패하는 것까지 확인했다. 덤으로 기존 TestInvitationExpiryStaysWithinTheWindowTheFormOffers가 케이스 라벨(공백 포함)을 그대로 로컬 파트에 넣던 것을 슬러그로 바꿨다. - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) - 업무 객체의 status는 여전히 임의 문자열 —
status IN('completed','accepted','closed')같은 집계가 오타 하나로 조용히 빠짐(유형별 어휘가 어디에도 정의돼 있지 않아 위험은 큼) (가치 3 / 위험 3 / M) - 업무 객체의 data jsonb 블롭에는 어떤 검증도 없음 — 폼이 쓰는 키(품목·수량·단가)가 API로는 무제한이고 목록 응답이 블롭 전체를 반환 (가치 3 / 위험 2 / M)
- 전화번호·웹사이트도 형식 검사가 없음 — 길이만 볼 뿐이라 “내선 3번”이 전화번호로, 사내 위키 제목이 웹사이트로 저장됨 (가치 3 / 위험 1 / M)
Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)
- CI의 go job이
- 릴리즈: v0.7.40 (2026-09-03)
2026-09-04
- 선택: 요청 값이 공급업체 거래 상태로 들어가는 쓰기 경로에 어휘 검사 추가, 그리고 폼과 API가 서로 다른 단어를 쓰던 것을 하나로 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit 71e618a)
- 요약: 날짜·숫자·문자열·리스크등급·레코드id·이메일 스윕과 같은 기준(철자가 아니라 연산 — 요청 값이 질의가 분기하는 컬럼에 도달)으로, 리스크 등급 스윕이 riskLevel만 가져가고 옆에 두고 온 status를 훑었다. 거래 상태는 라벨이 아니라 질의가 읽는 단어다 — 대시보드의 거래 가능 타일은 status=’active’를, 심사 대기 타일은 status=’screening’을 세고, MCP 추천 도구는 status IN(‘active’,’approved’)만 추리며, 목록 필터는 철자를 그대로 비교한다. createSupplier·updateSupplier 둘 다 들어온 status를 길이 말고는 보지 않았다. 그리고 그 결함은 이미 제품 안에서, 가장 눈에 안 띄는 자리에서 일어나 있었다: 웹앱이 어휘를 세 번(목록 필터, 배지 라벨 맵, 편집 폼 드롭다운) 따로 적어 두었고, 편집 폼만 “registered”라고 적혀 있었다 — 저장소 어디에도 없는 철자다. 결과는 둘이고 두 번째가 나쁘다. (1) 편집 폼에서 등록을 고르면 “registered”가 저장돼 등록 필터에 영영 안 잡히고, 라벨 맵에 한국어가 없어 영문이 그대로 배지에 뜨며, 색도 warning이 아닌 기본색이 된다. (2) 포털 자가등록이 넣는 “registration”이 옵션에 없어서 select가 첫 옵션으로 떨어진다 — 자가등록 업체를 열어 전화번호만 고치고 저장하면 상태 칸을 건드린 적도 없이 후보로 되돌아간다. 누가 보고 있던 등록 큐에서 빠져 아무도 안 보는 목록으로 가고, 화면에는 아무 흔적도 없다. rows.go의 riskGrades 옆에 supplierStatuses를, web/src/status.ts에 같은 목록을 두고 필터·드롭다운·라벨 맵이 모두 그것을 읽게 했다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트·빌드·린트 통과. 가드는 넷 — 패키지를 파싱하는 TestEverySupplierStatusIsInTheVocabulary, 요청 필드가 아니라 statement를 읽는 TestEverySupplierStatusInTheSQLIsInTheVocabulary, status.ts와 rows.go를 서로에게 묶는 TestTheStatusListTheFormOffersIsTheOneTheAPIAccepts, 엔드포인트를 실제로 호출하는 TestEverySupplierStatusOnAWriteIsInTheVocabulary — 여기에 웹 쪽 supplier-status.test.tsx가 “registration” 업체를 편집 폼에 띄워 그대로 저장한다. updateSupplier 가드를 되돌리면 파싱·통합 테스트가, status.ts에 “registered”를 되돌리면 교차 검사와 웹 테스트가 실패하는 것까지 확인했다. - 보류 아이디어:
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S) - 업무 객체의 status는 여전히 임의 문자열 —
status IN('completed','accepted','closed')같은 집계가 오타 하나로 조용히 빠짐(유형별 어휘가 어디에도 정의돼 있지 않아 위험은 큼) (가치 3 / 위험 3 / M) - 전화번호·웹사이트는 길이만 볼 뿐 형식 검사가 없음 — “내선 3번”이 전화번호로, 사내 위키 제목이 웹사이트로 저장됨 (가치 3 / 위험 1 / M)
- 업무 객체의 data jsonb 블롭에는 어떤 검증도 없음 — 폼이 쓰는 키(품목·수량·단가)가 API로는 무제한이고 목록 응답이 블롭 전체를 반환 (가치 3 / 위험 2 / M)
Makefile의 VERSION(0.6.21)이 README의 릴리스 예시(0.7.26)와 어긋남 — 릴리스 문서 정합성 (가치 2 / 위험 1 / S)
- CI의 go job이
- 릴리즈: v0.7.41 (2026-09-04)
2026-09-06
- 선택: 결재에서 「보완 요청」으로 되돌아온 건을 다시 상신할 수 있게 하고, 업무 객체 상태 어휘를 한 곳에 모으기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit 87510e2)
- 요약: 공급업체 거래 상태 스윕이 status.ts에 만든 “어휘는 한 곳에” 원칙을 업무 객체 쪽에 적용하다가, 그 페이지가 어휘를 따로 적어 두었기 때문에 생긴 기능 구멍을 찾았다. 결재자의 결정은 셋이다 — 승인은 넘기고, 반려는 끝내고, 보완 요청은 고쳐서 다시 올리라고 돌려보낸다. 마지막 하나가 둘째와 다른 점의 전부인데, 목록이 되돌아가는 길을 주지 않았다: 행의 승인 요청 버튼도, 체크박스도, 일괄 승인 요청도 전부
status === "draft"를 물었으므로 returned 건은 셋을 한꺼번에 잃었고, 상태 필터에 보완 요청 옵션이 없어 찾을 수조차 없었으며, 배지는 모르는 상태가 떨어지는 중립 파랑에 영문 “returned”를 그대로 띄웠다(할 일이 없다는 뜻으로 읽힌다). 남은 길은 계약을 처음부터 다시 입력하는 것뿐 — 그건 아무도 통보받지 않은 반려이고, 바로 그것을 막으려고 있는 버튼을 통해 도달한다. API는 처음부터 재상신을 허용했다(submitObject는 상태가 아니라 열린 결재가 없는지를 본다). status.ts에 approvalStatuses·submittableStatuses·canSubmitForApproval·objectStatusFilters·objectStatusLabel을 공급업체 어휘 옆에 두고, 페이지의 네 번째 목록과 세 군데 흩어진 한 단어 비교를 대체했다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(10파일 40개)·tsc·eslint·빌드 통과. 가드는 넷 — status.ts를 workflowOwnedStatus에 양방향으로 묶고 이미 결착된 상태를 상신 가능 목록에 넣지 못하게 하는 TestTheApprovalStatusesTheListShowsAreTheOnesTheWorkflowAwards, 페이지의 두 번째 목록과status === "draft"형태를 거부하는 TestTheObjectListDoesNotWriteItsStatusesOutAgain, 상신→보완요청→수정→재상신 전체를 API로 걷고 결재가 2건 중 1건만 열려 있는지 확인하는 TestAReturnedRequestCanBeSubmittedAgain, returned 계약을 실제로 렌더링해 버튼·체크박스·일괄 액션을 누르는 object-status.test.tsx. 양쪽 수정을 각각 되돌리면 Go와 웹 가드가 모두 실패하는 것까지 확인했다. - 보류 아이디어:
- 업무 객체 status는 여전히 임의 문자열 — createObject/updateObject가 결재 소유 상태만 막고 나머지는 무검사라 오타 하나가 집계에서 조용히 빠짐(유형별 어휘가 어디에도 정의돼 있지 않아 위험은 큼) (가치 3 / 위험 3 / M)
- 낙찰(awardSourcing)이 sourcing_participants.status에 ‘preferred_negotiation’을 쓰는데 그 표의 어휘(invited/draft/submitted/declined/selected/not_selected)에 없는 단어이고, 이후 포털 응답 저장이 참가 상태를 draft/submitted로 되돌려 낙찰 기록을 지움 (가치 3 / 위험 2 / M)
- 전화번호·웹사이트는 길이만 볼 뿐 형식 검사가 없음 — “내선 3번”이 전화번호로, 사내 위키 제목이 웹사이트로 저장됨 (가치 3 / 위험 1 / M)
- 업무 객체의 data jsonb 블롭에는 어떤 검증도 없음 — 폼이 쓰는 키(품목·수량·단가)가 API로는 무제한이고 목록 응답이 블롭 전체를 반환 (가치 3 / 위험 2 / M)
- CI의 go job이
go test ./internal/...만 돌려./cmd/...를 빼놓음 — Makefile/README와 불일치 (가치 2 / 위험 1 / S)
- 릴리즈: v0.7.42 (2026-09-06, run 2026-09-06-010043-Vendra-improve)
2026-09-06
- 선택: 낙찰이 기록한 참가자 상태를 그 표의 어휘로 쓰고, 이후 응답 저장이 그 기록을 덮지 못하게 하기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit 5a089f2)
- 요약: 리스크등급·이메일·공급업체 거래 상태 스윕과 같은 기준(철자가 아니라 연산 — 값이 질의가 분기하는 컬럼에 도달)으로 sourcing_participants.status를 훑었다. 결함은 둘이고 두 번째가 나쁘다. (1) 낙찰(selectSourcingResponse)이 참가자 갱신에 업무 객체의 status를 그대로 넘겨, 우선협상 선정이면 그 표가 한 번도 가진 적 없는 ‘preferred_negotiation’이 저장됐다 — 거절 허용(IN(‘invited’,’draft’))도 질문 허용(status<>’declined’)도 이 단어를 모르고, 두 화면은 영문 그대로 띄웠다. (2) 포털 응답 저장이 끝에서 참가자 상태를 응답 상태로 도장 찍는데 그 앞을 지키는 건 마감일뿐이었고, 마감일은 낙찰일이 아니다. 탈락한 업체가 마감 전에 견적 폼을 다시 열면 not_selected가 draft로 지워져 구매자의 참여 업체 목록에 다시 후보로 돌아왔고 — 낙찰이 있었다는 흔적은 어디에도 없다 — 제출까지 하면 recalculateSourcing이 이미 결정을 내린 그 비교표를 결정 이후에 다시 계산했다. sourcing.go에 sourcingParticipantStatuses(invited/draft/submitted/declined/preferred/selected/not_selected)와 sourcingStandingIsTheCommittees·sourcingBiddingClosed를 두고, 낙찰은 그 어휘로 쓰고, 확정(선정·미선정)된 건의 응답 저장은 409 sourcing_awarded로 막고, 읽음 표시는 위원회가 쓴 상태를 덮지 않게 CASE로 감쌌다. 우선협상은 일부러 예외다 — 견적을 다시 다듬는 것이 그 선정의 목적이라 저장은 통과하고 상태는 살아남는다. 포털 목록도 마감일보다 낙찰 상태를 우선해 보고한다(낙찰됐다는 말 없이 마감만 알리는 것이 그 목록이 존재하는 이유를 지운다). 화면 쪽은 두 곳 다 저장된 영단어를 그대로 찍고 있어서 미선정과 선정이 같은 파란 배지였다 — status.ts에 sourcingParticipantLabel을 두고 Sourcing.tsx의 참여 업체 배지와 Portal.tsx의 카드가 읽게 했고, 확정된 건은 응답 폼 버튼을 닫는다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(11파일 44개)·tsc·eslint·빌드 통과. 가드는 넷 — 참가자 표를 다루는 statement의 리터럴과 마이그레이션 기본값을 어휘에 묶는 TestEverySourcingStandingIsInTheVocabulary, status.ts의 라벨 맵과 Go 어휘를 양방향으로 묶는 TestTheStandingsTheAPIWritesAreTheOnesTheScreensName, 우선협상→수정→최종낙찰→409→마감 후 포털 목록까지 API로 걷는 TestAnAwardedBidderCannotWriteOverTheDecision, 미선정 카드를 실제로 렌더링하는 sourcing-standing.test.tsx. 세 군데 Go 수정과 Portal 배지를 각각 되돌리면 해당 가드가 모두 실패하는 것까지 확인했다. -
보류 아이디어: 업무 객체 status는 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 전화번호·웹사이트는 길이만 볼 뿐 형식 검사가 없음 (3/1/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S) / 워크플로 조건 폼 라벨은 “공급업체 Risk 조건”인데 실제로는 업무 객체의 risk_level과 매칭됨 (3/2/S)
- 릴리즈: v0.7.43 (2026-09-06, run 2026-09-06-112045-Vendra-improve)
2026-09-06
- 선택: 승인 워크플로의 대상 유형을 실제로 제출되는 업무 유형에 묶기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit 0d72e4b)
- 요약: 리스크등급·이메일·공급업체 거래 상태·참가자 상태 스윕과 같은 기준(철자가 아니라 연산 — 요청 값이 질의가 분기하는 컬럼에 도달)으로 workflow_definitions.object_type을 훑었다. 결재 규칙을 고르는 것은 matchingWorkflow의
WHERE object_type=$1이고 비교 상대는 라우트가 지닌 유형 이름인데, createWorkflow는 그 단어를 보지 않았고 규칙을 만들 수 있는 유일한 화면이 이미 목록에서 벗어나 있었다. 폼의 업무 유형 드롭다운이 네 개를 따로 적어 두었고 양쪽으로 틀렸다. (1) 「공급업체」는 “supplier”를 저장하는데 이 앱의 어떤 제출 경로도 쓰지 않는 단어다 — 규칙은 201로 저장되고 목록에 활성으로 뜨고 영원히 아무것과도 매칭되지 않는다. 쓴 사람은 공급업체 등록에 결재가 걸려 있다고 믿는다. (2) 빠진 여덟 개가 더 나쁘다 — 납품·검수·품질·이슈·RFQ·RFP·Invoice·지급은 전부 목록에 승인 요청 버튼이 있는데 그 앞에 세울 규칙을 아예 만들 수 없어서, 모든 제출이 no_matching_workflow로 떨어져 보낸 즉시 승인 도장이 찍혔다. 결재자 없는 승인된 인보이스이고, 아무 함에도 아무것도 남지 않는다. workflows.go에 workflowObjectTypes()를 두어 열한 개를 objectRoutes에서 읽고(따로 적지 않는다 — 유형이 추가되면 그 순간 submit 엔드포인트가 생기므로 규칙도 쓸 수 있어야 한다) supplier_bank_change를 더했으며(라우트 없이 updateSupplier가 객체와 결재를 한 문장에서 열고, 스키마와 함께 설치되는 규칙이 이 유형으로 쓰여 있다), createWorkflow가 그 밖의 단어를 필드명과 함께 400으로 거절한다. status.ts가 같은 목록을 들고 폼과 Workflow 목록이 읽는다 — 목록은 저장된 유형 이름을 그대로 찍고 있어서 기본 규칙이 “supplier_bank_change”로 보였다. 덤으로 같은 폼의 Risk 조건 라벨이 「공급업체 Risk 조건」이었는데 실제 매칭 대상은 제출된 업무 객체의 risk_level이라 라벨을 바로잡았다(보류 아이디어 하나 해소). 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(12파일 47개)·tsc·eslint·빌드 통과. 가드는 넷 — 어휘를 objectRoutes에 양방향으로 묶고 예외(supplier_bank_change)의 사유가 아직 유효한지 원본에서 확인하는 TestEveryWorkflowObjectTypeIsOneTheApplicationSubmits, status.ts와 Go 어휘를 순서까지 맞추고 NewWorkflow 함수 본문이 목록을 다시 적지 못하게 하는 TestTheWorkflowTypesTheFormOffersAreTheOnesTheAPIAccepts, 거절 메시지가 상자와 선택지를 말하게 하는 TestARejectedWorkflowTypeNamesTheBoxAndTheChoices, “supplier” 규칙이 400으로 거절되고 지급 규칙을 만든 뒤 실제로 지급을 제출해 pending_approval과 결재 1건을 확인하는 TestAnApprovalRuleReachesTheSubmissionItWasWrittenFor. 여기에 웹 쪽 admin-workflow.test.tsx가 드롭다운 옵션·전송되는 objectType·목록 라벨을 실제 렌더링으로 검증한다. Go 가드와 폼 세 군데를 각각 되돌리면 해당 테스트가 모두 실패하는 것까지 확인했다. -
보류 아이디어: 업무 객체 status는 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 전화번호·웹사이트는 길이만 볼 뿐 형식 검사가 없음 (3/1/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S) / 워크플로 정의는 만든 뒤 조건·단계를 화면에서 고칠 수 없음 — PATCH는 있는데 UI가 없어 오타 하나면 새로 만드는 수밖에 (3/2/M)
- 릴리즈: v0.7.44 (2026-09-06, run 2026-09-06-225047-Vendra-improve)
2026-09-07
- 선택: 잘못 쓴 승인 규칙을 쓴 자리에서 고치고 끌 수 있게 하고, 그 수정을 생성이 통과한 것과 같은 검사에 묶기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit d1337ad)
- 요약: 보류 아이디어 「워크플로 정의는 만든 뒤 화면에서 고칠 수 없음」을 집었는데, PATCH에 호출자를 붙이자 그 엔드포인트가 한 번도 검사한 적 없는 것이 드러났다. workflow_definitions.steps를 쓰는 문은 둘인데 하나만 지키고 있었다 — createWorkflow는 빈 목록과 이름 없는 단계를 거절하고 순번을 찍는데, updateWorkflow는 들어온 것을 그대로 저장했다(
[]도, 애초에 목록이 아닌 값도). 그 대가는 쓰는 자리에서 나지 않는다. 그 규칙에 걸리는 다음 제출에서 난다: 객체는 결재중으로 옮겨지고 결재는 설 단계 없이 열리므로, listApprovals는 current_step이 목록 끝을 넘은 인스턴스를 모든 승인함에서 빼고 workflowAction은 409 invalid_workflow로 답한다. 다시 상신할 수도 없다 — 두 번째 제출을 막는 것이 바로 그 열린 인스턴스다. 애플리케이션의 어떤 화면도 그 객체를 다시 움직일 수 없다. 그리고 규칙 자체가 write-once였다: 최소 금액에 0을 하나 덜 친 규칙은 고칠 수도 끌 수도 없이 활성인 채로 계속 라우팅했고, 답은 옆에 규칙을 하나 더 만드는 것뿐이라 matchingWorkflow가 둘 중 먼저 닿는 쪽을 집었다. validWorkflowSteps를 두 문 앞에 놓고, Workflow 패널이 이름·활성·최소 금액·Risk 조건·단계를 수정하게 했다 — PATCH는 conditions 전체를 갈아치우므로 폼이 보여주지 않는 조건(securityLevel 등)은 그대로 실어 보낸다. 업무 유형은 규칙이 등록된 이름이라 고정이고, 잘못 쓴 유형은 여기서 끄고 다시 쓴다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(12파일 51개)·tsc·eslint·빌드 통과. 가드는 넷 — 패키지를 파싱해 steps 컬럼을 쓰는 문을 전부 찾아 검사 호출을 요구하는 TestEveryStatementThatWritesApprovalStepsChecksThem, 거절이 상자를 지목하는지 읽는 TestAnApprovalRuleWithNoStepsIsRefused, 거절되는 수정→고쳐진 규칙이 승인함에 닿는 것→끈 규칙이 다음 제출을 통과시키는 것까지 API로 걷는 TestAnApprovalRuleCanBeCorrectedWhereItWasWritten, 그리고 수정 모달을 실제로 렌더링하는 admin-workflow.test.tsx 4개. updateWorkflow의 가드를 되돌리면 앞의 둘이 실패하는 것까지 확인했다. -
보류 아이디어: 업무 객체 status는 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 통합 테스트가 하나의 DB를 공유해 순서에 따라 산발적으로 깨짐 — 같은 실행에서 TestLoginLocksAccountAfterRepeatedFailures(가장 최근 세션을 ORDER BY created_at으로 고름)와 TestFormDraftsAreBoundedPerUser(가장 최근 초안이 축출됨)가 각각 한 번씩 실패했고 단독 실행은 통과 (3/1/M) / 전화번호·웹사이트는 길이만 볼 뿐 형식 검사가 없음 (3/1/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S)
- 릴리즈: v0.7.45 (2026-09-07, run 2026-09-07-074049-Vendra-improve)
2026-09-08
- 선택: 공급업체에 닿는 두 값(전화번호·웹사이트)을 그 값이게 하고, 폼과 API가 서로 다른 모양을 요구하던 것을 하나로 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (commit eef34dc)
- 요약: 날짜·숫자·라벨·어휘·레코드id·이메일 스윕과 같은 기준(철자가 아니라 연산 — 요청 값이 phone/website 컬럼에 도달)으로 훑었다. 두 값은 라벨을 재는 목록에 들어 있었고 그게 전부였는데, 이 상자에 잘못 들어가는 값은 짧다 — “내선 3번”이 걸어야 할 번호로, 사내 위키 제목이 회사 홈페이지로, 다섯 문(등록·수정·포털 프로필·담당자 두 문) 모두에서 저장됐다. 빈 칸은 아무도 안 채웠다는 뜻이지만 문장이 들어 있으면 누가 채웠다는 뜻이고, 전화를 걸어야 할 때까지 아무도 모른다. 나쁜 쪽은 두 번째다: 구매자 폼은 아무 텍스트나 받아 등록부가 “www.acme.co.kr”(명함에 인쇄된 모양)로 가득한데, 포털의 같은 컬럼 폼은 type=”url”이라 브라우저가 스킴을 요구했다. 전화번호를 고치러 「회사 연락정보 수정」을 연 공급업체는 자기가 건드린 적 없는, 구매자가 써 넣은 웹사이트 칸에서 막혔고 — 남의 값을 고치는 것 말고는 길이 없었으며 — 게다가 submit 핸들러에 catch가 없어 API 거절은 핸들러 밖으로 던져지고 모달은 아무 말 없이 그대로 떠 있었다. rows.go에 validPhoneFields·validWebsiteFields를 기존 여섯 검사 옆에 두고 다섯 문 전부에 세웠다. 번호는 국가별 형식이 아니라 걸 수 있는 자릿수로 본다(해외 업체를 등록하는 제품이라 02-1234-5678도 +82 2 1234 5678도 1588-0000도 같은 것이다). 웹사이트는 사람들이 쓰는 대로의 호스트를 받아 스킴을 붙여 저장하므로 컬럼이 한 모양이 되고 두 폼이 같은 것을 제안한다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(13파일 54개)·tsc·eslint·빌드 통과. 가드는 넷 — 패키지를 파싱해 요청 필드명이 아니라 statement의 컬럼 목록으로 문을 찾는 TestEveryStoredContactDetailIsOne, 거절이 상자를 지목하고 저장 모양이 하나임을 고정하는 TestARejectedContactDetailNamesTheBox, 다섯 문을 API로 걷고 포털이 막혀 있던 그 전화번호 수정까지 해보는 TestEveryContactDetailOnAWriteIsOne, 사내 호스트가 들어 있는 모달을 실제로 렌더링해 브라우저의 checkValidity를 읽는 portal-profile.test.tsx. portalUpdateProfile의 가드와 폼의 type=”url”을 각각 되돌리면 해당 테스트가 모두 실패하는 것까지 확인했다. -
보류 아이디어: 업무 객체 status는 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 통합 테스트가 하나의 DB를 공유해 순서에 따라 산발적으로 깨짐 — 초안 축출은 ORDER BY updated_at DESC의 동률에서 방금 저장한 키를 버릴 수 있음 (3/1/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / 공급업체 수정(PATCH)은 tradingSince·annualSpend·businessNumber를 갱신하지 않아 오타난 사업자번호를 영영 고칠 수 없음 (3/2/S) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S)
- 릴리즈: v0.7.46 (2026-09-08, run 2026-09-08-143053-Vendra-improve)
2026-09-08
- 선택: 공급업체가 등록된 번호(사업자번호·법인번호)와 거래 시작일·연간 거래금액을 등록 이후에 고칠 수 있게 하기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit b9019b5)
- 요약: 보류 아이디어 「PATCH가 tradingSince·annualSpend·businessNumber·corporateNumber를 갱신하지 않음」을 집었다. 등록은 사람이 타이핑하는 값을 받고 사람은 언젠가 하나를 틀리는데, 편집이 되쓰는 컬럼 목록에서 그 넷이 빠져 있었다 — PATCH는 그 이름을 담은 본문을 받아 200으로 답하고 아무것도 바꾸지 않았다. 거절이 아니라 침묵이라 호출자는 정정이 일어나지 않았다는 것을 알 방법이 없다. 넷 중 사업자번호가 최악이다: 등록부가 그 컬럼을 키로 삼고 검색 상자가 그 번호로 레코드를 찾으므로 한 자리가 틀리면 진짜 번호를 가진 모두에게서 그 업체가 숨고, 중복 검사는 아무 업체도 갖지 않은 번호를 지키게 되어 같은 회사가 두 번 등록될 수 있다. 남은 길은 업체를 지우고 다시 등록하는 것뿐 — 그 레코드에 달린 계약·발주·평가·리스크를 전부 데리고 간다. 포털은 심지어 공급업체에게 “사업자번호와 법적 정보 변경은 내부 승인 후 반영됩니다”라고 말하고 있는데 내부 쪽에는 반영할 상자가 없었다. UPDATE에 네 컬럼을 싣고, 정정도 등록이 통과한 검사(validDateFields·validNumberFields)를 통과하게 하고, 다른 회사가 이미 쓰는 번호는 알 수 없는 저장 실패가 아니라 409 duplicate_business_number로 답한다 — 정확히 그 일을 하면서 아무도 부르지 않던 supplierNumberConflict를 duplicateBusinessNumber로 고쳐 두 문이 함께 읽는다. 편집 폼에는 사업자번호·법인번호·거래 시작일 상자를 두었고, 연간 거래금액은 지출 집계가 다시 계산하는 값이라 API에만 남겼다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(14파일 57개)·tsc·eslint·빌드 통과. 가드는 셋 — 등록의 INSERT 컬럼 목록과 편집의 SET 목록을 파싱해 서로 묶고 예외 넷(supplier_number·owner_id·organization_id·created_by)에 사유를 적게 하는 TestEverySupplierColumnTheRegisterWritesCanBeCorrected, 거절→409→정정→무관한 수정 후에도 살아남는지→정정된 번호로 검색되는지까지 API로 걷는 TestASupplierRegistrationNumberCanBeCorrected, 폼을 실제로 렌더링해 값·전송 본문·409 표시를 읽는 supplier-register.test.tsx. UPDATE의 네 컬럼을 되돌리면 앞의 둘이 실패하는 것(파싱 가드는 네 컬럼을 모두 지목하고, 통합 가드는 409여야 할 자리에서 200과 원래 번호가 담긴 응답을 받는다)까지 확인했다. -
보류 아이디어: 업무 객체 status는 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 담당자·조직 이관이 API로 불가능 — owner_id/organization_id는 등록에서만 정해지고 편집이 건드리지 않아 퇴사자가 담당인 업체를 옮길 수 없음 (3/2/M) / 통합 테스트의 초안 축출이 ORDER BY updated_at DESC 동률에서 방금 저장한 키를 버릴 수 있음 (3/1/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S)
- 릴리즈: v0.7.47 (2026-09-08, run 2026-09-08-183058-Vendra-improve)
2026-09-08
- 선택: 공급업체를 지금 그 업체를 맡고 있는 사람에게 넘길 수 있게 하기 (담당자·조직 이관) (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit df479bf)
- 요약: 지난 회차 파싱 가드가 사유와 함께 남겨 둔 예외 둘(owner_id·organization_id)을 집었다 — 그 사유를 지우는 것이 완료 조건이었다. 두 컬럼은 등록에서 정해지고 그 뒤 어떤 문도 쓰지 않아서, 등록부가 이미 사실이 아닌 상태를 계속 말하고 있었다: 담당자가 퇴사한 업체는 그 사람 이름 그대로 남고, 포털이 스스로 등록한 업체는 애초에 담당자가 없다(portal.go의 INSERT가 두 컬럼을 적지 않는다). PATCH는 그 이름을 담은 본문을 받아 200으로 답하고 아무것도 바꾸지 않았다 — 거절이 아니라 침묵이다. 데이터 범위가 ‘own’인 계정에게는 장식이 아니다: 범위 필터가
owner_id=$me라서 정작 그 업체를 다루는 사람에게 레코드가 보이지 않고, 손에 쥐여 줄 방법은 업체를 지우고 다시 등록하는 것뿐 — 계약·발주·평가·리스크를 전부 데리고 간다. 다만 이관은 보통의 수정이 아니라 누가 볼 수 있는지를 정하는 조작이라, 이미 볼 수 있는 사람에게만 넘어가게 했다: rows.go의 internalUserInScope가 “일을 넘길 수 있는 동료”를 한 번만 정의하고 후보 목록(신설 GET /api/v1/user-candidates)과 쓰기 검사가 같은 술어를 읽으므로, 화면이 제안한 사람만 저장된다. 조직도 같은 방식으로 확인한다. 기존 평가위원 후보 조회도 같은 헬퍼를 읽게 정리했다. 화면은 Master 편집에 담당자 select를 두고, 현재 담당자가 후보에 없으면(퇴사·범위 밖) 빈 칸 대신 그 사정을 말하며, 새 담당자의 부서가 다르면 조직 이관을 가정하지 않고 물어본다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(15파일 62개)·tsc·eslint·빌드 통과. 가드는 넷 — 예외 둘을 지운 TestEverySupplierColumnTheRegisterWritesCanBeCorrected, 목록과 검사가 같은 술어를 읽는지 패키지를 파싱해 확인하는 TestThePeopleTheOwnerPickerOffersAreTheOnesTheTransferAccepts, 비활성 계정·포털 로그인·없는 조직을 각각 400으로 거절하고 이관 뒤 ‘own’ 범위 후임의 목록에 그 업체가 나타나는 것까지 API로 걷는 TestASupplierWithNoOwnerCanBeHandedToOne, 폼을 실제로 렌더링해 전송 본문과 조직 이관 체크박스를 읽는 supplier-owner.test.tsx 5개. UPDATE의 두 컬럼을 되돌리면 앞의 둘이 실패하는 것(파싱 가드는 두 컬럼을 지목하고, 통합 가드는 이관에서 400 save_failed를 받는다)까지 확인했다. - 보류 아이디어: 업무 객체 status는 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 포털 자가등록이 사업자번호·이메일 중복을 구분하지 않아 이미 등록된 회사·계정의 담당자가 알 수 없는 400을 받고 막힘 (3/1/S) / 통합 테스트의 초안 축출이 ORDER BY updated_at DESC 동률에서 방금 저장한 키를 버릴 수 있음 (3/1/S) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S)
2026-09-09
- 선택: 자가등록이 부딪히는 두 유일키를 각각 지목하게 하기 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공 (commit 7cf4d4b)
- 요약: 보류 아이디어 「공급업체 자가등록의 중복이 전부 같은 400으로 뭉개짐」을 집었다. portal.go의 가입 트랜잭션은 유일키 둘(suppliers.business_number, users.email)을 위반할 수 있는데 끝의 한 문장 “가입을 완료하지 못했습니다”로 합쳐져 있었고, 둘 다 그 폼에서 빠져나갈 수 없다 — 사업자번호는 맞는 값이라 다시 보내도 같고, 이메일은 폼에 있지도 않다(초대장이 지고 온다). 그 문장이 남기는 유일한 수는 아무도 갖지 않은 번호가 될 때까지 사업자번호를 틀리게 고치는 것이고, 등록부가 바로 그 컬럼을 키로 삼으므로 회사는 아무도 가진 적 없는 번호로 두 번째 등록되어 진짜 번호로도 찾히지 않게 된다. 이제 둘을 각각 지목한다: 409 duplicate_business_number는 회사가 이미 등록돼 있으니 기존 업체로 묶인 초대를 요청하라고 말하고, 409 email_registered는 계정이 이미 있으니 로그인하라고 말한다. 회사를 지목하되 붙이지는 않는다 — 초대는 이메일 한 줄일 뿐이라 남을 기존 레코드에 붙이면 그 회사의 계약·발주·평가를 넘겨주는 것이다. 거절된 시도는 초대장을 쓰지 않은 채 롤백되므로 재발급된 초대가 두 번째 문제가 되지 않는다. 같은 키의 다른 문인 createUser도 같은 뭉개짐이었고(복귀한 직원을 추가하는 관리자가 상자를 지목하지 않는 저장 실패를 받았다) duplicateUserEmail을 duplicateBusinessNumber 옆에 두어 두 문이 함께 읽는다. 화면 쪽은 거절이 갈 곳이 없었다 — APIError가 봉투의 code를 버려서 페이지가 한국어 문장으로만 분기할 수 있었다. 이제 code를 싣고, 등록 폼은 이미 계정이 있을 때 로그인 링크를 준다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(15파일 61개)·tsc·eslint·빌드 통과. 가드는 넷 — 패키지를 파싱해 users.email을 쓰는 모든 문에 duplicateUserEmail을 요구하고 예외 둘(bootstrapAdmin·oidcCallback, 둘 다 ON CONFLICT)에 사유를 적게 하는 TestEveryAccountCreatedFromARequestNamesTheAddressAlreadyTaken, 부딪히는 초대 둘과 업체에 묶인 초대 하나를 API로 걷고 거절된 시도가 주인 없는 회사도 소모된 초대장도 남기지 않았는지 확인하는 TestSelfRegistrationSaysWhichOfTheTwoIsAlreadyOnFile, 두 거절을 실제로 렌더링하는 self-register.test.tsx, code가 에러에 실리는지 고정하는 api.test.ts. portal.go의 두 분기, admin.go의 분기, App.tsx의 로그인 링크를 각각 되돌리면 해당 가드가 모두 실패하는 것까지 확인했다. -
보류 아이디어: 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft의 ORDER BY updated_at DESC 동률) — 고치는 법은 AND draft_key<>$3 (3/1/S) / 업무 객체 status가 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 — 폼이 쓰는 키가 API로는 무제한이고 네 키는 결재 라우팅에도 닿음 (3/2/M) / web 프론트엔드 Sourcing.tsx(비교표·평가위원·낙찰 화면)에 테스트 없음 (3/1/L) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S)
- 릴리즈: v0.7.48 (2026-09-09, run 2026-09-09-060104-Vendra-improve)
2026-09-09
- 선택: 잘못 나간 Self Registration 초대를 회수할 수 있게 하고, 무엇이 나가 있는지 볼 수 있게 하기 (가치 3 / 위험 1 / 작업량 M)
- 결과: 성공 (commit c29f64d)
- 요약: 보류 아이디어 「초대장은 발급 뒤 취소할 수 없음」을 집었다. 초대 링크는 bearer credential이다 — 그것을 든 사람이 그 공급업체로 묶인 포털 계정을 만들고, 그 계정은 업체의 계약·발주·납품·평가를 읽는다. 그런데 발급이 기능의 전부였다: 일회성 URL로 건네지고 모달이 닫히면 누구에게 나갔는지, 썼는지, 언제까지인지 말하는 화면이 애플리케이션 어디에도 없었다. 그래서 잘못 친 주소로 나간 초대, 또는 그 업체를 떠난 사람 앞으로 나간 초대는 expires_at(최대 14일)까지 살아 있었고 그것을 멈출 문장이 없었다. 목록이 없는 것도 같은 구멍이다 — “그거 예전 주소로 간 거 아닌가?”라는 질문에 찾아볼 데가 없다. invitations에 revoked_at을 accepted_at 옆에 두고 가입 시점의 조회가 둘 다 읽게 했다. GET /api/v1/invitations는 발급 이력을 답하고(공급업체를 지목하면 발급 엔드포인트와 같은 범위 검사, 지목하지 않으면 본인이 발급한 것 — 업체가 안 붙은 초대가 보이는 유일한 경로다), DELETE /api/v1/invitations/{id}가 회수한다. 이미 가입에 쓰인 초대는 409로 거절한다: 멈출 링크가 남아 있지 않은데 「회수되었습니다」라고 답하는 것은 실제로 무슨 일이 있었는지에 대한 거짓말이고, 대신 무엇이 그 계정을 막는지(사용자 비활성화)를 말한다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고
go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(16파일 65개)·tsc·eslint·빌드 통과. 가드는 셋 — invitationStanding이 읽는 종료 컬럼을 뽑아 가입 시점의 SELECT가 전부 읽는지 요구하는 TestEveryWayAnInvitationStopsWorkingIsReadAtRedemption(네 번째 종료가 생기면 여기서 걸린다), 발급→목록→회수→회수된 링크로 가입 시도 400→재회수 204→정상 가입→가입된 초대 회수 409까지 API로 걷는 TestARevokedInvitationCannotBeRegisteredWith, 모달을 실제로 렌더링해 네 상태 배지·유효한 것에만 붙는 회수 버튼·409 표시·목록 실패 시에도 발급이 되는지 읽는 supplier-invitation.test.tsx 4개. portal.go의 revoked_at 조건과 회수 후 목록 재조회를 각각 되돌리면 해당 가드가 모두 실패하는 것까지 확인했다. -
보류 아이디어: 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft의 ORDER BY updated_at DESC 동률) — 고치는 법은 AND draft_key<>$3 (3/1/S) / 업무 객체 status가 여전히 임의 문자열 — 유형별 어휘가 어디에도 정의돼 있지 않아 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 — 폼이 쓰는 키가 API로는 무제한이고 네 키는 결재 라우팅에도 닿음 (3/2/M) / 이미 계정이 있는 주소로 초대를 발급해도 발급 시점에는 아무 말이 없고, 상대가 링크를 눌러야 409 email_registered를 만남 (2/1/S) / CI의 go job이 ./cmd/…를 빼놓아 Makefile·README와 불일치 (2/1/S)
- 릴리즈: v0.7.49 (2026-09-09, run 2026-09-09-145013-Vendra-approve)
2026-09-10
- 선택: 견적을 그 요청이 나간 통화로 매기고, 모든 금액이 어떤 통화인지 말하게 하기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit 2e1abb1)
- 요약: 리스크등급·이메일·공급업체 거래 상태·참가자 상태·연락처 스윕과 같은 기준(철자가 아니라 연산 — 요청 값이 currency 컬럼에 도달)으로 훑었다. 통화 코드를 보는 문은 다섯인데 어느 것도 코드를 보지 않았고 전부 글자 수만 재고 있었으며, 이 상자에 들어가는 잘못된 값은 짧다. 나쁜 쪽은 낙찰이다: recalculateSourcing이 가격을
100*min_amount/total_amount로 제출된 견적 전체에 걸쳐 매기는데 두 통화를 한 척도에 올릴 환율이 애플리케이션 어디에도 없다(spend.currency가 base를 적어 두었지만 읽는 문이 없다). 그래서 68,000,000 KRW 견적들 옆에 선 50,000 USD 견적은 다른 통화의 견적이 아니라 그냥 가장 작은 숫자로 읽혀, 다섯 가중치 중 가장 큰 가격 배점을 통째로 가져가고 비교표 맨 위에 앉았다 — 그리고 그 표는 money()가 저장된 통화와 무관하게 전부 원화로 찍어서 ₩50,000으로 보였다. 무슨 일이 일어났는지 화면 어디에도 없고, 낙찰이 그대로 따라간다. 포털 견적 폼이 KRW/USD/EUR/JPY 넷을 제안하고 있었으므로 이건 API 전용 경로가 아니다. rows.go에 currencyCodes를 riskGrades·supplierStatuses 옆에 두고 다섯 문 전부에 validCurrencyFields/validCurrency를 세웠다(대문자화 — 주소를 소문자로, 웹사이트에 스킴을 붙여 하나의 모양으로 저장하는 것과 같다). 견적의 통화는 요청의 통화로 정하고 다른 코드는 400 currency_mismatch로 무엇으로 내야 하는지 말하며 거절한다. business_objects.currency를 편집이 되쓰는 컬럼에 넣었다 — 생성은 코드를 받는데 그 뒤 어떤 문도 쓰지 않아 잘못 들어간 통화가 정정 요청마다 200을 받고 그대로 남았다. money()는 레코드가 지닌 코드로 찍고 Intl이 모르는 코드에서는 패널을 내리는 대신 숫자와 코드를 같이 답한다. 포털 견적 폼은 넷을 제안하는 대신 그 요청의 통화를 말한다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN을 새 DB에 걸고go test ./internal/... ./cmd/...전체 통과, gofmt·go vet 무결, 웹 테스트(17파일 72개)·tsc·eslint·빌드 통과. 가드는 다섯 — 패키지를 파싱해 statement의 컬럼 목록에 currency가 있는 문마다 검사를 요구하는 TestEveryStoredCurrencyIsOneTheApplicationCanPrice, 거절이 상자를 지목하고 저장 모양이 하나임을 고정하는 TestARejectedCurrencyNamesTheBox, 두 문을 API로 걷는 TestABidIsPricedInTheCurrencyTheTenderWasPutOutIn(USD 요청에 KRW 견적 400 → 무기재는 USD로 저장 → 대소문자 → 포털 목록이 통화를 보고)과 TestACurrencyOnABusinessObjectIsOneAndCanBeCorrected, 견적 폼을 실제로 렌더링하는 sourcing-currency.test.tsx 3개. 포털 핸들러의 통화 판단, updateObject의 currency 컬럼, Portal.tsx의 폼과 금액 표시를 각각 되돌리면 해당 가드가 모두 실패하는 것까지 확인했다. 남긴 것 하나: spend_transactions의 sum(amount)는 여전히 통화를 섞어 더한다 — 환율이 놓일 자리를 정하는 것이 먼저라 별건으로 남겼다. - 보류 아이디어: 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft의 ORDER BY updated_at DESC 동률) — 고치는 법은 AND draft_key<>$3 (3/1/S) / 공급업체 연간 지출과 지출 리포트가 통화를 섞어 더함 — 환율이 없어 합계 자체가 합계가 아님, 기준통화 정책이나 통화별 집계가 필요 (3/3/M) / 업무 객체 status가 여전히 임의 문자열 — 유형별 어휘가 없어 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 — 폼이 쓰는 키가 API로는 무제한이고 네 키는 결재 라우팅에도 닿음 (3/2/M) / 구매자 화면에는 통화 선택이 없어 모든 업무 객체가 KRW로만 생성됨 (2/1/S)
2026-09-10
- 선택: 역할의 권한을 실제로 문이 요구하는 것에 묶기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit 8a9c4a2)
- 요약: 어휘 스윕과 같은 기준(값이 질의·게이트가 분기하는 자리에 도달)으로 roles.permissions와 data_scope를 훑었다. 역할의 권한은 오직 hasPermission의 wanted 쪽에서만 읽히므로, 어떤 문도 요구하지 않는 단어는 좁은 권한이 아니라 권한 없음인데 역할 목록에서는 작동하는 것과 똑같이 보인다. 쓰는 쪽에 검사가 전혀 없었고 배포된 역할 넷이 이미 그렇게 어긋나 있었다 — 보안 담당자의 risk.security.·evaluation.security., 계약 담당자의 risk.contract., 준법·법무의 contract.review·risk.compliance.. 보안 담당자는 보안 리스크 권한을 받고도 리스크를 등록할 수 없고(문은 risk.create), 계약 담당자는 계약 리스크 권한이 유일한 risk 단어라 리스크를 아예 볼 수 없다. permissionCodes()가 확인되는 권한 전부를 들고(11개 업무 유형의 네 문 — read/create/update와 금액을 여는
.amount.read — 은 objectRoutes에서 읽는다), createRole·updateRole이 아무 문도 열지 않는 권한을 400으로 거절하며 무엇을 뜻했을지 말한다. 옆의 data_scope도 같은 침묵이었다: 로그인 질의와 vendra_org_in_scope가 네 단어로 분기하고 ELSE가 'own'이라 오타는 거절되지 않고 조용히 가장 좁은 범위가 됐다. 권한 없이 만든 역할이 JSON null로 저장돼 로그인 질의의 jsonb_array_elements_text가 읽지 못하던 것도 빈 목록으로 고쳤다. 마이그레이션 017이 죽은 단어 다섯을 떼고 대신할 문을 붙인다(역할이 아직 그 단어를 들고 있을 때만). 화면은 권한 상자 아래에 목록을 칩으로 제시하고, API 거절을 던지는 대신 보여준다 — 이전에는 submit 핸들러 밖으로 던져져 모달이 말없이 떠 있었다. 검증: docker postgres:16-alpine으로 CI와 동일한 세 DSN(migrate·upgrade는 새 DB)을 걸고 `go test ./internal/... ./cmd/...` 전체 통과, gofmt·go vet 무결, 웹 테스트(17파일 67개)·tsc·eslint·빌드 통과. 가드는 다섯 — require/hasPermission 리터럴과 objectRoutes 파생 코드, MCP 도구 맵까지 모아 어휘와 양방향으로 묶는 TestEveryPermissionTheApplicationChecksIsOneARoleCanBeGiven(이 가드가 .amount.read라는 문 11개가 어휘에서 빠진 것을 잡아냈다), roles의 두 컬럼을 쓰는 문을 파싱해 검사를 요구하는 TestEveryStatementThatWritesRolePermissionsChecksThem, 거절이 상자·단어·대안을 말하는지 읽는 TestARejectedPermissionNamesTheBoxAndWhatItWouldTake, 마이그레이션 이후 카탈로그를 읽고 죽은 권한 하나라도 있으면 실패시키며 구매 관리자로 로그인해 여덟 화면을 실제로 열어 보는 TestTheRolesTheProductShipsWithReachTheScreensTheyName, 폼을 렌더링해 칩 선택·전송 본문·거절 표시를 읽는 admin-roles.test.tsx. - 보류 아이디어: 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft의 ORDER BY updated_at DESC 동률) — 고치는 법은 AND draft_key<>$3 (3/1/S) / 공급업체 연간 지출과 지출 리포트가 통화를 섞어 더함 — 환율이 없어 합계 자체가 합계가 아님 (3/3/M) / 업무 객체 status가 여전히 임의 문자열 — 유형별 어휘가 없어 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 (3/2/M) / 구매자 화면에는 통화 선택이 없어 모든 업무 객체가 KRW로만 생성됨 (2/1/S)
2026-09-11
- 선택: 사용자·관리자 가이드를 실제로 찍은 화면과 함께 완성본으로 (가치 4 / 위험 1 / 작업량 L)
- 결과: 성공 (commit 80c085f)
- 요약: 캠페인 guides-2026-09. 가이드 두 편이 각각 58줄짜리 요약이었고 캡처가 한 장도 없었으며, 사용자 가이드의 MCP 도구 이름이
vendra_search_suppliers로 적혀 있었으나 서버가 답하는 이름은search_suppliers라 그대로 따라 쓴 사람은 도구를 찾지 못한다.scripts/guide-screenshots.mjs를 두어 데모 데이터를 넣고 headless Chrome 을 CDP 로 몰아 1440x900 으로 30장을 찍었다(추가 의존성 없음 — Node 22 의 fetch·WebSocket). 표준의 안전 규칙대로 대상 주소는 전용 변수VENDRA_GUIDE_URL에서만 읽고 없으면 멈추며, 비로컬 호스트는VENDRA_GUIDE_ALLOW_REMOTE=1없이 거절하고, 전역 설정은workflow.approval_enabled하나만 — 꺼져 있으면 상신이 즉시 승인되어 승인함이 빈 화면으로 찍힌다 — 원래 값을 읽어 두었다가 실패 경로에서도 되돌린다. 이미 데이터가 있는 배포에는 쓰지 않고 캡처만 한다. 본문은 코드에서 읽어 썼다: 환경 변수 표는internal/config의 네 개 전수와 거부 조건(bcrypt 72바이트, base64 32바이트), 설정 표는 마이그레이션이 설치하는 24개 키의 실제 기본값, 역할표는 배포되는 열 개의 코드·이름·데이터 범위(company/division/department/own), MCP 도구표는tools/list가 답한 열한 개, 비교 Matrix 점수식은recalculateSourcing의 SQL. 문서를 쓰다 이 트리에는 통화 검증(currency_mismatch)과 담당자 이관이 없다는 것을 확인하고 그 문장을 지웠다 — 두 회차 분 원장이 반려된 PR 의 것이었다. 정본을 하나로 정리했다: 옛USER_GUIDE.html·ADMIN_GUIDE.html을 지우고docs/index.html·index_en.html의 HTML 뷰어 링크를 뗐다. 검증: 캡처 30장을 모두 눈으로 확인(빈 화면 두 장 — 승인함·통합 검색 — 은 데이터와 질의를 넣어 다시 찍음), 공용 md2pdf 로 PDF 생성(26쪽·15쪽), 참조된 그림과 파일 목록을 양방향 대조해 누락·미사용 0, docker postgres:16-alpine 으로go test ./cmd/... ./internal/...전체 통과(deployment_docs_test의 OIDC 콜백 경로 가드 포함), gofmt·go vet 무결, tsc·eslint 통과,node --check로 스크립트 문법 확인. - 보류 아이디어: 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft의 ORDER BY updated_at DESC 동률) — 고치는 법은 AND draft_key<>$3 (3/1/S) / 업무 객체 status가 여전히 임의 문자열 — 유형별 어휘가 없어 오타 하나가 집계에서 조용히 빠짐 (3/3/M) / 업무 객체의 data jsonb 블롭에 어떤 검증도 없음 — 네 키는 결재 라우팅에도 닿음 (3/2/M) / 공급업체 담당자·조직 이관이 API로 불가능 — owner_id/organization_id를 편집이 건드리지 않아 퇴사자가 담당인 업체를 옮길 수 없음 (3/2/M) / 견적 통화가 검증되지 않아 비교 Matrix가 서로 다른 통화의 금액을 한 척도에 올림 — 포털 폼은 KRW/USD/EUR/JPY를 제안한다 (3/2/M)
2026-09-11
- 선택: 사용자·관리자 가이드를 실제로 찍은 화면과 함께 완성본으로 — 앞선 회차의 산출물을 비밀정보 검사에 걸리지 않는 모양으로 다시 올림 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (commit bb833b5)
- 요약: 캠페인 guides-2026-09. 같은 날 00:11 회차(80c085f)가 가이드 두 편·캡처 30장·캡처 스크립트를 다 만들었지만 러너의 비밀정보 검사에서 통째로 반려됐다 — scripts/guide-screenshots.mjs 가 데모 계정 비밀번호를 상수로 적어 두었고 머리말의 사용 예시에도 값이 적혀 있었다. 표준(GUIDE-STANDARD.md)이 바로 그 함정을 적어 두고 있었다: 시드 데이터에 들어가는 비밀번호도 전용 환경 변수로만 받고 없으면 멈춘다. 그 커밋을 이 브랜치에 가져와 데모 비밀번호를 VENDRA_GUIDE_DEMO_PASSWORD 로 옮기고(관리자 것과 나란히, 없거나 10자 미만이면 종료), 관리자 가이드 5.8 절의 실행 예시에 그 변수를 더했으며, README 에 두 가이드로 가는 링크를 두었다(README 는 architecture·security·operations 만 가리키고 있어 그 길을 따라온 독자는 가이드를 보지 못했다). 이어 표준의 검증 항목을 다시 훑었다 — 참조된 그림 30장과 파일 목록 양방향 대조 누락·미사용 0, 환경 변수 표가 internal/config 의 os.Getenv 네 개와 일치(다른 패키지에는 Getenv 가 없다), 문서에 적은 경로(/health/live·/health/ready·/metrics·/api/version·/api/v1/openapi.json·/mcp)를 app.go 의 등록 자리에서 메서드까지 확인, compose.yaml 의 VENDRA_IMAGE·볼륨·포트와 Dockerfile 의 USER·HEALTHCHECK 대조, 기준 버전은 최신 태그 v0.7.49. 바뀐 관리자 가이드 PDF 를 공용 md2pdf 로 다시 구웠고(15쪽) 표지·표·그림 페이지를 렌더링해 눈으로 확인했다. 검증: 러너와 같은 gate.py secrets 를 전체 diff 에 돌려 clean, node –check, go build·go vet·go test ./… 통과, 웹 테스트(16파일 65개)·빌드 통과. 캡처는 다시 찍지 않았다 — 앞선 회차가 v0.7.49 를 실제로 띄워 찍은 것이고 그 뒤 화면 코드가 바뀌지 않았다.
-
보류 아이디어: 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft의 ORDER BY updated_at DESC 동률) — 고치는 법은 AND draft_key<>$3 (3/1/S) / 가이드 표류 가드 — 두 가이드가 참조하는 그림이 전부 있는지, 관리자 가이드 환경 변수 표가 internal/config 의 Getenv 전수와 같은지 deployment_docs_test 옆에서 지키기 (3/1/S) / 견적 통화가 검증되지 않아 비교 Matrix가 서로 다른 통화의 금액을 한 척도에 올림 (3/2/M) / 공급업체 담당자·조직 이관이 API로 불가능 — owner_id/organization_id를 편집이 건드리지 않음 (3/2/M) / 업무 객체 status가 여전히 임의 문자열 — 유형별 어휘가 없어 오타 하나가 집계에서 조용히 빠짐 (3/3/M)
- 릴리즈: v0.7.50 (2026-09-12, run 2026-09-12-083438-Vendra-approve)
2026-09-12
- 선택: 관리자가 화면에서 방문 추적 스크립트를 붙일 수 있게 하되, 잠긴 정책은 그대로 두기 (캠페인 tracking-2026-09) (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (commit b6d1883)
- 요약: 캠페인 tracking-2026-09. TRACKING-STANDARD.md 와 kanpic 의 analytics.go·violations.go·policyFor 를 읽고 이 저장소의 모양으로 옮겼다 — Vendra 는 설정을 settings 테이블의 JSON 한 행(camelCase)으로 두고 관리 화면이 전용 패널로 편집하므로(oidc·ai 와 같은 방식)
tracking키 하나에 열두 항목을 담았고, 마이그레이션 017 이 꺼진 채로 설치한다(새 설치는 아무것도 달라지지 않는다 — DB 없는&App{}로 띄운 페이지의 정책 헤더가 이전 것과 바이트 단위로 같음을 TestAFreshInstallationServesThePolicyItAlwaysDid 가 고정).internal/tracking이 설정 파싱·검증(provider 어휘, 8KB 상한, 허용 출처는 https://host 모양), provider 별 스니펫, 스니펫 문자열에서 http(s) 출처 긁기, 차단 기록(서로 다른 출처 100개 고리)을 맡는다. Momento 를 첫 자리·기본값에 두고 같은 오리진 프록시(/momento/* → 수집기)를 기본으로 삼아 정책에 외부 출처가 아예 등장하지 않게 했으며, 프록시는 Vendra 세션 쿠키·Authorization 을 떼고 넘기고 수집기의 Set-Cookie·CSP 헤더를 브라우저에 전하지 않으며 Momento-프록시가 켜진 때 말고는 404 다. index.html 을 내줄 때 요청마다 nonce 를 만들어 스니펫의 모든<script>에 붙이고 같은 nonce 와 스니펫 출처·report-uri 를 정책에 넣는다 —'unsafe-inline'은 script-src 에 절대 들어가지 않는다(테스트가 지시어를 잘라 확인). /api·/mcp·/health·/metrics·프록시는default-src 'none'으로 더 좁혔다(문서 미리보기는 자기 정책을 이미 갖고 있어 영향 없음). 신고 엔드포인트 /api/tracking/csp-report 는 로그인 화면도 신고하므로 인증 밖에 두었고 항상 204 다. 관리 화면 「방문 추적」 패널은 provider 별 상자, 바이트 카운터(8,192 초과 시 저장 버튼 비활성), 서버 거절 표시, 「정책이 막은 출처」 표와 한 번에 허용·기록 비우기를 갖는다. putSetting 이tracking키를 검증해 400 validation_error 로 상자를 지목한다. 관리자 가이드 3.5 절(설정표·CSP 세 가지 기법·프록시·붙지 않는 곳·확인법)과 7.5 데이터 반출 경로(넷째 항목), 설정 키 표를 더했고 PDF 를 다시 구웠다(16쪽). 검증: docker postgres:16-alpine 으로 CI 와 같은 세 DSN 을 걸고go test ./internal/... ./cmd/...전체 통과(신설 통합 테스트 TestTrackingIsOffUntilAnAdministratorTurnsItOn 이 시드→거절 셋→켜기→nonce 일치→관리 화면 제외→신고→목록→허용→정책 반영→비우기→끄기 후 원래 정책까지 API 로 걷는다), gofmt·go vet 무결, 웹 테스트 17파일 68개·tsc·eslint·빌드 통과, 러너의 gate.py secrets 로 diff clean. 표준의 「실제로 띄워 수집이 들어오는 것을 확인」 항목은 진짜로 했다 — 서버를 띄우고 가짜 Momento 수집기(tracker.js 가 data-endpoint 로 page_view 를 POST)를 두고 headless Chrome 을 CDP 로 몰아, 삽입된 스니펫이 엄격한 정책 아래 실행되어 /momento/* 를 거쳐 쿠키 없이 수집기에 이벤트가 도착하는 것, 동적으로 조립한 차단 출처가 신고되어 목록에 오르는 것, 끄면 스니펫이 사라지고 프록시가 404 가 되는 것을 확인했고 그 화면을 가이드 그림으로 실었다. - 보류 아이디어: 가이드 표류 가드 — 참조된 그림 전수와 환경 변수 표를 코드에 묶기(이번에 손으로 대조했더니 누락·미사용 0, 테스트로 옮길 만하다) (3/1/S) / 초안 축출이 방금 저장한 초안 자신을 버릴 수 있음(putFormDraft, AND draft_key<>$3) (3/1/S) / 견적 통화가 검증되지 않아 비교 Matrix 가 다른 통화의 금액을 한 척도에 올림 — 2e1abb1 이 반려된 이력이 있어 접근을 바꿔야 함 (3/2/M) / 공급업체 담당자·조직 이관이 API 로 불가능 — updateSupplier 의 SET 에 owner_id/organization_id 없음 (3/2/M) / 업무 객체 status 가 여전히 임의 문자열 — Lifecycle 화면의 유형별 목록이 어휘의 출발점 (3/3/M)