문서와 심볼

aidlc-docs 를 그대로 읽는다 · 읽기 전용
/home/sunny/enode-fixup-workshop2-visualizer연결 중
construction/u-foundation/functional-design/business-rules.md320 줄

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 에만 있다. 뒤 유닛에서 패키지가 생기면 같은 명령이 실제 검사
결과를 낸다 — 지울 것이 없다.