U-바닥 — 규칙
타입은 domain-entities.md, 절차는 business-logic-model.md 에 있다. 이
문서는 값이 무엇이어야 하고 무엇이 되면 안 되는가다.
1. 정책 값
R1. 값은 셋뿐이다
"" 안 걸려 있다
graceful 새 임대를 안 받는다
at-boundary 도는 단계를 끝까지 두고 결과 보고 뒤 임대를 놓는다
R2. 검증은 UpsertAdvert 한 자리에서 한다
nodes.drain 으로 가는 길이 그것 하나다. 라우트 쪽에서 걸면 나중에 다른
경로가 생겼을 때 한쪽만 검사한다.
R3. 알 수 없는 값은 거절하지 않고 graceful 로 조인다
광고를 400 으로 거절하면 안 된다. postNodes 가 광고이자 하트비트이고
같은 응답이 임대 갱신이다 (ADR-016). 거절하면 정책 파일의 오타 하나가
그 노드의 임대 갱신을 끊고, not_after 가 지나 Reap 이 도는 Run 을
회수한다. 오타가 도는 작업을 죽인다.
조용히 "" 로 떨어뜨려도 안 된다. 소유자는 걸었다고 믿는데 노드가 계속
일을 받는다 — 그것이 ADR-063 이 지키려는 「돌려받았다」의 뜻을 깬다.
그래서 닫는 쪽으로 조인다.
알 수 없는 값 -> graceful 로 저장한다. 경고 로그를 남긴다
graceful 인 것은 그것이 기본 모드이기 때문이다 (decisions.md 1절 —
at-boundary 로 올리는 것은 소유자의 명시다). 저장하는 값이 조인 값이라
아래 읽는 쪽 전부가 셋만 본다.
조인 값이 광고 응답으로 되돌아가는 것이 맞다 — requirements.md §4.2 가
「데몬은 Mediator 가 확인한 값을 Worker 에 넘긴다」고 적었다. 파일에
오타가 있어도 노드와 중앙이 같은 값으로 움직인다.
R4. 빈 값이 곧 해제다
광고는 델타가 아니라 매번 전부다 (ADR-012). policy 를 안 싣는 것과
drain: "" 를 싣는 것이 같다. capability 를 빼는 것이 「지금은 못 한다」가
되는 것과 같은 기계다.
R5. panel_token 은 contract.Policy 에 없다
기계 하나의 로컬 비밀이 Mediator 의 DB 로 흘러가면 안 된다 (NFR-S2 ·
SECURITY-12). 파일 타입은 U-drain 이 따로 갖는다.
2. 필터와 상한 — NFR-S5
R6. limit
0 이하 100 으로 읽는다
1 ~ 1000 그대로
1000 초과 1000 으로 자른다. 거절하지 않는다
상한 1000 은 requirements.md §5.1 이 새로 정한 값이다. 자르고 거절하지
않는 것은 limit 이 페이지네이션 없는 목록의 유일한 손잡이라 400 을 내면
호출자가 값을 낮춰 다시 부르는 것 말고 할 일이 없기 때문이다.
store 가 조인다. 두 곳에서 조이면 언젠가 한쪽만 바뀐다.
R7. state 의 어휘는 store 가 한 벌로 갖는다
// ValidState 는 runs.state 에 실제로 저장되는 어휘다.
func ValidState(s string) bool
ListRuns 는 어휘 밖의 state 를 받으면 오류를 낸다. 빈 목록을 조용히
내면 호출자가 「그런 Run 이 없다」와 「그런 상태가 없다」를 구별하지 못한다.
U-관측API 가 그 오류를 400 으로 옮긴다.
R8. since 는 created_at 을 자른다. 경계는 배타다
created_at > since. >= 가 아닌 것은 커서로 쓸 때 경계의 Run 이 두 번
오지 않게 하기 위해서다.
ended_at 이 아닌 이유를 실측으로 확인했다 — 픽스처 셋(RUNNING ·
QUEUED · FAILED)에 같은 창을 걸었을 때 created_at 은 셋을 다 냈고
ended_at 은 FAILED 하나만 냈다. 도는 것과 기다리는 것이 통째로 빠진다.
문자열을 time.Time 으로 바꾸는 것은 U-관측API 의 일이다 — RFC3339 파싱은
HTTP 층의 몫이고 store 는 이미 시각인 값을 받는다.
R9. work 는 정확 일치다
LIKE 도 접두도 없다. work_id 는 계약에서 유도된 키이지 사람이 치는
검색어가 아니다.
3. runs.state 의 실제 어휘 — NFR-I2 를 정정한다
requirements.md §5.2 의 NFR-I2 가 「저장되는 어휘는 RUNNING ·
SUCCEEDED · FAILED 와 새로 오는 QUEUED 넷이다」로 적었다. 코드를
읽으니 다섯이다.
internal/api/api.go:454 CreateRun 이 RUNNING 으로 넣는다
internal/store/store.go:342 CreateRejectedRun 이 FAILED 로 넣는다
internal/store/reap.go:58 만료 회수가 FAILED
internal/store/reap.go:191 Cancel 이 FAILED
internal/store/reap.go:279 SettleIfDone 이 RUNNING -> VERIFYING <-- 여기
internal/store/reap.go:319 마지막 정산이 SUCCEEDED 또는 FAILED
VERIFYING 은 따로 커밋되고, 그 뒤에 유효 계약 조회 · 함대 조회 · 대조가
이어진다. 그 사이의 폴링이 state: "VERIFYING" 인 행을 실제로 본다.
RESOLVING 과 ALLOCATING 은 안 저장된다 — 상수는 있으나 runs.state 에
쓰는 자리가 없다. INVARIANTS §1.1 의 이름이지 행의 값이 아니다.
// 실제로 저장되는 다섯. RESOLVING 과 ALLOCATING 은 여기 없다 —
// 상수는 있으나 runs.state 에 쓰는 자리가 없다.
StateRunning, StateVerifying, StateSucceeded, StateFailed, StateQueued
파급 둘.
ValidState 다섯을 받는다. VERIFYING 을 빼면 실재하는 행을 못 거른다
S1 의 필터 칩 전체 · RUNNING · QUEUED · SUCCEEDED · FAILED 다섯인데
VERIFYING 행은 「전체」에만 뜬다. 칩을 늘리지 않는다 —
창이 짧고 여섯째 칩은 사람이 안 쓴다. 다만 행의 배지가
모르는 글자에 안 깨져야 한다. U-관측API 의 몫이다
4. 시각과 정렬
R10. observed_at 은 DB 의 시계다
SELECT now(). Mediator 프로세스의 time.Now() 가 아니다. 같은 응답에
expires_at · not_after 가 함께 나가고 화면이 그 둘을 observed_at 과
빼서 카운트다운(NFR-O5)과 30초 흐림(NFR-O3)을 그리기 때문이다.
R11. observed_at 을 행보다 먼저 읽는다
반대로 하면 expires_at 이 두 시각 사이인 행이 통과하는데 관측 시각으로는
이미 만료라 음수 카운트다운이 나온다. business-logic-model.md §3.2.
R12. 정렬에 동점이 없다
ListRuns created_at DESC, run_id DESC
ListQueued created_at ASC, run_id ASC FIFO
ListNodes node_id ASC
같은 입력이면 같은 출력이다 — match.Match 가 동점을 node_id 로 가르는
것과 같은 규칙이다 (ADR-014 결정 3).
R13. 만료 기준을 한 벌로 쓴다
ListNodes 와 DrainingNodes 가 둘 다 expires_at > now() 로 거른다.
LiveAdverts 가 이미 그 기준이다. 세 목록이 갈리면 화면에는 있는 노드가
매칭에는 없는 일이 생긴다.
5. 안 내보내는 것
R14. leases.nonce
ADR-010 의 허가 아티팩트다. store.LeaseRow 에 있고 노드에게만 내려간다.
GET /v1/nodes 는 LeaseView{run_id, not_after} 를 쓴다.
SELECT 목록에서 고르지 않는다. 구조체에서 빼는 것만으로는 나중에
누가 필드를 더할 때 다시 새어 나온다.
R15. nodes.principal · runs.principal
ADR-065 §2 — 사람이 읽는 식별은 이미 label 에 있다. ADR-015 §1 이
principal 을 식별로 뒀지 인증으로 두지 않았으므로 목록에 실을 값이 아니다.
R16. runs.contract
계약 전문은 목록에 안 싣는다. 한 행이 수십 KB 가 되고, 필요하면
GET /v1/runs/{id} 가 준다.
R17. 오류 문구에 내부 경로와 스택이 없다 — NFR-S9
store 가 내는 오류는 ListRuns: unknown state "..." 처럼 값과 이유만
적는다. SQL 문도 파일 경로도 안 싣는다.
6. 안 건드리는 것 — NFR-I 대조
| # | 요구 | 이 유닛에서 |
|---|---|---|
| NFR-I1 | I1 · I2 · I4 · I5 와 leases.node_id 기본키 |
leases 를 안 만진다. 읽기만 하고 LEFT JOIN 한 번이다 |
| NFR-I2 | READY 를 안 만든다 |
안 만든다. 어휘는 3절의 다섯 |
| NFR-I3 | drain 중앙 라우트 없음 | 라우트를 하나도 안 만든다. 경로는 Advert.Policy 뿐이다 |
| NFR-I4 | match.Match 를 안 고친다 |
Reject 의 태그 세 줄만. 시그니처도 로직도 그대로다 |
| NFR-I5 | 단계 도중에 안 끊는다 | 이 유닛은 단계를 안 건드린다. Steps 에 열 하나를 더 실을 뿐이다 |
| NFR-I6 | dry-run 응답 불변 | submit 을 안 만진다 |
| NFR-I7 | 재제출은 200 |
GetRun 경로를 안 만진다 |
한 자리가 어긋난다 — UpsertAdvert 의 반환이 둘이 되므로
internal/api/api.go:208 한 줄이 바뀐다. unit-boundaries.md §2 의 파일
행렬에 api.go 가 U-바닥 열에 없는 것과 어긋나고, 근거는
business-logic-model.md §2.2 에 적었다. 등록 줄도 핸들러 로직도 아니다.
7. 경계 넷 — NFR-P1 ~ NFR-P5
internal/panel -> internal/store 금지
internal/panel -> internal/api 금지
internal/api/ui -> internal/store 금지
internal/enode -> internal/panel 금지
R18. 없는 패키지는 통과가 아니다
U-바닥 시점에 internal/panel 과 internal/api/ui 는 없다. 없는 것을
통과로 세면 검사기가 항진명제가 된다. t.Logf 로 「없음 — 검사 안 함」을
적고 요약에 건너뛴 수를 낸다.
R19. t.Skip 을 안 쓴다
.ci-allowed-skips 는 「CI 로 그 조건을 갖출 수 없는 스킵」만 받는다.
「아직 안 만들었다」는 뒤 유닛이 갖출 수 있는 조건이라 목록에 못 오르고,
목록에 없는 t.Skip 은 「스킵 감시 (전 패키지)」가 위반으로 센다.
R20. _test.go 도 센다
테스트가 store 를 임포트하면 그 패키지는 DB 없이 못 돌고, decisions.md
2절이 두 패키지를 「DB 없이 도는 테스트로 채운다」로 정한 것이 깨진다
(NFR-Q2).
R21. 하위 패키지도 덮는다
internal/panel 규칙이 internal/panel/ui 에도 걸린다. 양쪽 다 하위를
포함한다.
8. 완료 조건
기존 테스트 전부 초록. 회귀 0. 기존 라우트 15개가 그대로 돈다
nodes 에 drain 열이 있고 UpsertAdvert 가 이전 정책을 돌려준다
store.StateQueued 가 있다. runs.state 에 CHECK 가 없어 마이그레이션이 없다
ListNodes · ListRuns · DrainingNodes · ListQueued 가 있고 각각 테스트가 있다
StepView 에 Chosen 이 실려 나온다
contract.Policy{Drain} 이 있고 값은 셋뿐이다
match.Reject 가 {code, as, reason} 로 직렬화된다. 옛 DB 행이 그대로 읽힌다
경계 검사 테스트가 금지 임포트 넷을 기계로 센다
커버리지 · 린트 · glyphscan 초록
8.1 테스트가 있어야 하는 자리
ENODE_TEST_DATABASE_URL 이 필요한 것과 아닌 것을 가른다.
DB 필요 UpsertAdvert 가 이전 정책을 돌려준다 — "" -> graceful -> "" 세 번
UpsertAdvert 가 알 수 없는 값을 graceful 로 조인다
ListNodes 가 만료된 노드를 뺀다
ListNodes 가 lease 를 붙이고 nonce 를 안 낸다
ListRuns 의 current[] 가 CLAIMED 단계마다 하나다 (폭 2 로)
ListRuns 의 필터 넷과 limit 의 기본 · 상한
ListRuns 가 created_at DESC, run_id DESC 로 결정적이다
DrainingNodes 가 만료된 draining 노드를 뺀다
ListQueued 가 FIFO 다
Steps 가 chosen 을 낸다
DB 불필요 match.Reject 의 직렬화와 옛 대문자 키의 역직렬화
contract.Advert 가 policy 를 싣고 없으면 비운다
ValidState 의 다섯
limit 조이기의 경계값 — 0 · 1 · 1000 · 1001
경계 검사 (internal/boundary)
internal/boundary 는 DB 를 안 쓴다. 커버리지 하한에도 안 걸린다 — 문장이
0 이라 프로파일에 안 나온다 (실측).
9. CP0 — 「기동이 안 깨졌다」
scene-gates.md §3 의 명령에 한 줄을 더한다.
go test ./... && go vet ./...
go run ./scripts/glyphscan.go
golangci-lint run
커버리지 게이트 (.coverage-contract.yml)
go test -v ./internal/boundary <-- 더한 줄
curl 로 기존 라우트 하나 — GET /v1/capabilities
더한 이유 — go test ./... 는 통과한 패키지의 t.Logf 를 안 보인다
(실측). 그 줄이 없으면 「검사했다」와 「패키지가 없어 건너뛰었다」가 똑같이
ok 한 줄로 보이고, 그 순간 R18 이 지키려던 것이 사라진다.
이 줄은 CP0 에만 있다. 뒤 유닛에서 패키지가 생기면 같은 명령이 실제 검사
결과를 낸다 — 지울 것이 없다.