문서와 심볼

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

U-바닥 Functional Design — 계약 질문 셋

이 단계가 정하는 것은 다른 넷이 전부 딛는 타입과 시그니처다. 아래 셋은
유닛 둘 이상이 읽는 자리인데 어느 문서도 값을 안 준 것이라 답을 받고
산출물을 낸다. 나머지는 2절에 근거를 적고 닫았다.

계획 파일을 안 만들었다. 규칙 Step 4 는
construction/plans/{unit}-functional-design-plan.md 를 요구하지만 사용자
지시가 「계획 파일은 Units Generation 과 Code Generation 에서만 만든다」이다.
이 단계의 계획은 3절 하나로 족하고, 파일을 하나 더 만들면 같은 목록이 두
벌이 된다.

유닛 이름의 디렉터리 슬러그 — 저장소의 파일 이름이 전부 라틴 문자라
그것을 따른다. 매핑은 aidlc-state.md 에 적었다.

   U-바닥      u-foundation        U-대기열   u-queue      U-제어판  u-panel
   U-관측API   u-observe-api       U-drain    u-drain

1. 질문 셋

권장을 A 로 뒀다. [Answer]: 를 채워 주면 그 값으로 산출물을 낸다.

Q1. 정책이 광고 요청의 어디로 오는가

UpsertAdvert 의 시그니처가 여기서 갈린다. requirements.md §4.1 은
「이전 정책을 돌려준다」만 고정했고 받는 자리를 안 적었다.

A. contract.Advert 에 필드로 싣는다. (권장)

type Advert struct {
    // ...
    Policy Policy `json:"policy,omitempty"`
}

type Policy struct {
    Drain string `json:"drain,omitempty"` // "" | "graceful" | "at-boundary"
}

func (s *Store) UpsertAdvert(ctx context.Context, a contract.Advert,
    principal string, ttl time.Duration) (contract.Policy, error)

B. 별도 인자로 받는다.

func (s *Store) UpsertAdvert(ctx context.Context, a contract.Advert,
    principal string, ttl time.Duration, p contract.Policy) (contract.Policy, error)

A 인 이유 — internal/api/api.go:195 의 postNodes 가 본문 전체를
json.NewDecoder(r.Body).Decode(&a) 로 Advert 하나에 읽는다. A 면
U-drain 이 그 파싱을 한 줄도 안 고친다. B 면 본문을 두 번 읽거나 새 요청
타입을 만들어야 하고, 그 순간 「광고는 델타가 아니라 매번 전부」(ADR-012)가
객체 둘로 쪼개진다. requirements.md §4.2 가 응답 쪽에 policy 를 두기로
이미 정했으므로 요청도 같은 이름이면 왕복이 대칭이다.

Q2. ListNodes · ListRuns 가 무엇을 돌려주고 observed_at 은 누구의 시계인가

ADR-065 와 decisions.md 2절이 와이어 응답을 고정했다 — 둘 다 머리에
observed_at 이 있다. 고정되지 않은 것은 그 값을 어느 층이 찍는가이고,
그것이 store 반환 타입을 가른다.

A. store 가 페이지를 통째로 낸다. observed_at 은 DB 의 now(). (권장)

type NodePage struct {
    ObservedAt time.Time  `json:"observed_at"`
    Nodes      []NodeView `json:"nodes"`
}

type RunPage struct {
    ObservedAt time.Time `json:"observed_at"`
    Runs       []RunView `json:"runs"`
}

type RunFilter struct {
    State string    // "" 면 전체
    Work  string    // 정확 일치
    Since time.Time // 제로면 안 자른다
    Limit int       // 0 이면 100.  1000 을 넘으면 1000 으로 조인다
}

func (s *Store) ListNodes(ctx context.Context) (NodePage, error)
func (s *Store) ListRuns(ctx context.Context, f RunFilter) (RunPage, error)

B. store 는 행만 낸다. []NodeView · []RunView 를 돌려주고,
observed_at 은 internal/api 가 time.Now() 로 찍고 limit 상한도
거기서 조인다.

A 인 이유 둘.

   시계    같은 응답 안에 expires_at 과 not_after 가 함께 나가고, 화면이 그 둘을
           observed_at 과 빼서 카운트다운과 30초 흐림을 그린다 (NFR-O3 · NFR-O5).
           observed_at 만 Mediator 프로세스의 시계면 DB 와의 어긋남이 그대로
           카운트다운의 오차가 된다.  LiveAdverts 가 이미 expires_at > now() 로
           DB 시계를 기준으로 삼았으므로 A 가 그 기준을 잇는다

   상한    limit 의 기본 100 과 상한 1000 을 store 가 조이면 (NFR-S5)
           U-관측API 와 U-제어판이 각자 조일 필요가 없다.  두 곳에서 조이면
           언젠가 한쪽만 바뀐다

Q3. GET /v1/runs 의 since 는 어느 열을 자르는가

정본이 명시적으로 안 정한 자리다. enode-design/protocol/mediator-api.md
의 라우트 표가 이 자리를 「자리만 — Office 가 실물로 붙을 때 커서 의미(시각인가
순번인가)를 그쪽 요구로 정한다」로 적었고, decisions.md 2절은 필터 이름
넷만 정하고 뜻을 안 정했다.

A. created_at > since. RFC3339 시각. (권장)

B. ended_at > since. 끝난 것만 커서로 집는다는 뜻.

A 인 이유 — 목록이 created_at DESC 로 정렬되므로(「시간 역순」) 자르는
열과 정렬하는 열이 같아야 「최근 N 개」가 그대로 나온다. B 면 ended_at 이
NULL 인 Run — 즉 지금 도는 것과 QUEUED 인 것 — 이 통째로 빠져 S1 이
RUNNING 도 QUEUED 도 못 그린다. 현황판이 오늘의 유일한 호출자다.

A 가 Office 를 막지 않는다 — Office 가 실물로 붙을 때 봉인 커서가 필요하면
그때 mediator-api.md 가 파라미터를 따로 정하면 된다. since 하나에 뜻
둘을 얹는 것이 오히려 그 결정을 막는다.


2. 묻지 않고 닫은 것 — 근거를 적는다

2.1 nodes 의 정책 열은 drain text 한 열이다. jsonb 가 아니다

ALTER TABLE nodes ADD COLUMN IF NOT EXISTS drain text NOT NULL DEFAULT '';

capabilities 가 jsonb 인 이유를 schema.sql 이 직접 적었다 — 「속성 어휘가
창발하므로(ADR-012) 정규화하지 않는다」. 정책은 창발이 아니다. 값이
"" · graceful · at-boundary 셋으로 닫힌 열거이고 decisions.md 1절이
그것을 값으로 고정했다. 읽는 표면도 납작하다 — GET /v1/nodes 의 draining
이 문자열 하나다. DrainingNodes 가 WHERE drain <> '' AND expires_at > now()
로 단순해진다. 필드가 늘면 그때 열을 더한다 — 이 스키마는 이미 전부
ADD COLUMN IF NOT EXISTS 로 자란다.

유닛 밖으로 새는 계약이 아니라서 묻지 않는다 — U-drain 은 UpsertAdvert 로,
U-관측API 는 ListNodes 로만 닿는다.

2.2 panel_token 은 광고에 안 싣는다

정책 파일에는 drain 과 panel_token 둘이 있다 (unit-boundaries.md §4).
contract.Policy 에는 Drain 만 둔다. 와이어 타입에 panel_token 을
두면 기계 하나의 로컬 비밀이 Mediator 의 DB 로 흘러가고, 그것이 SECURITY-12
가 막는 바로 그 방향이다 — decisions.md 2절이 「Mediator 토큰은 함대 전체의
신뢰 경계라 기계 하나의 제어판에 흘리지 않는다」고 적은 것의 역방향이다.

파일 타입은 U-drain 의 internal/enode/policy.go 가 따로 갖는다. 와이어
타입과 파일 타입이 다른 것이 여기서는 흠이 아니라 경계다.

2.3 GET /v1/nodes 의 lease 는 store.LeaseRow 를 재사용하지 않는다

LeaseRow 에 Nonce 가 있다 (internal/store/claim.go:25). 그것은
ADR-010 의 허가 아티팩트이고 노드에게만 내려가는 값이다. ADR-065 의 응답
예시는 lease 에 run_id 와 not_after 둘뿐이다.

그대로 재사용하면 토큰을 가진 누구나 현황판에서 남의 nonce 를 읽는다. 작은
타입을 따로 만든다.

type LeaseView struct {
    RunID    string    `json:"run_id"`
    NotAfter time.Time `json:"not_after"`
}

principal 을 안 내는 것과 같은 결정이다 (ADR-065 §2).

2.4 경계 검사기는 t.Skip 을 쓰지 않는다

unit-of-work.md 가 「패키지가 없으면 통과가 아니라 건너뛴다를 검사기가
구분해서 적는다」고 적었다. 그것을 t.Skip 으로 구현하면 안 된다.

   .ci-allowed-skips 의 규칙    "목록에 오를 수 있는 것은 CI 로 그 조건을 갖출 수
                                없는 스킵뿐이다.  갖출 수 있는 스킵은 목록이 아니라
                                조건을 갖추는 것으로 닫는다"

   여기의 조건                  "internal/panel 이 아직 없다" 는 갖출 수 있다.
                                U-제어판이 만들면 갖춰진다.  그래서 목록에 못 오른다

   목록에 없는 t.Skip           ci.yml 의 「스킵 감시 (전 패키지)」가 위반으로 세고
                                CI 가 빨개진다

그래서 없는 패키지는 통과시키고 t.Logf 로 「없음 — 검사 안 함」을 적는다.
go test ./... 는 통과한 패키지의 t.Logf 도 fmt.Println 도 안 보이므로
(실측), CP0 게이트 명령에 한 줄을 더해 그 글자가 실제로 보이게 한다.

   go test -v ./internal/boundary

2.5 internal/boundary 가 테스트 파일만 가져도 게이트가 안 깨진다

실측했다 — 테스트 파일 하나뿐인 패키지에서 go build ./... · go vet ./...
· go test ./... 가 전부 exit 0 이다. 커버리지도 안전하다: 문장이 0 이라
프로파일에 그 패키지가 아예 안 나오고, ci.yml 의 하한 검사는 프로파일에
나온 패키지만 센다. 「스킵 감시」의 want 는 go list ./... 가 세므로 하나
늘고, 그 패키지가 결과를 내므로 수가 맞는다.

2.6 상태 상수의 이름은 store.StateQueued 다

unit-of-work.md 는 「store.QUEUED 상수」로 적었다. 식별자는
StateQueued 로 쓴다
— store.go 의 기존 블록이 StateResolving ·
StateRunning · StateFailed 이고 거기만 벗어난 이름을 두면 같은 어휘가 두
모양이 된다. 문서의 「store.QUEUED」는 어휘를 가리킨 것이지 식별자를 못박은
것이 아니다.

자리는 internal/store/queue.go 다. store.go 의 그 블록에 붙은 주석이
「상태 이름은 INVARIANTS §1.1 그대로다」인데 QUEUED 는 ADR-064 가 더한
것이라 그 문장을 거짓으로 만든다. 새 파일에 두고 ADR-064 를 가리키는
주석을 따로 단다. 어느 쪽이든 같은 패키지라 store.StateQueued 로 읽힌다.

2.7 match.Reject 의 태그는 소문자 셋이고 옛 행이 그대로 읽힌다

값은 requirements.md §8.4 ①-b 가 닫았다. 여기서 더할 것은 하나 —
Code · As · Reason 에 code · as · reason 을 달아도 이미 저장된
runs.reject 행이 그대로 읽힌다.
Go 의 언마샬이 키를 대소문자 무시로
맞추기 때문이다. 그 행을 읽는 자리는 store.GetRun 하나다.

2.8 StepView.Chosen 은 언제나 내보낸다

omitempty 를 안 붙인다. 같은 구조체의 Needs 가 이미 그 근거를 주석에
적었다 — 「비어 있어도 내보낸다. 안 내보내면 읽는 쪽이 기본값 규칙을
추측한다」. chosen 은 ADR-060 의 「안 고른 SKIPPED」와 「고른 뒤에도
SKIPPED」를 가르는 유일한 글자이고 S2 의 범례가 그 둘을 색으로 가른다.

2.9 current[] 의 step 은 steps.name 이다

requirements.md §4.1 이 키 넷을 {node, step, attempt, started_at} 으로
고정했다. step 이 seq 인지 이름인지가 그 표에 없는데, FR-21 이 제어판의
「현재 작업」에 「CLAIMED 단계 이름」을 요구하므로 이름이다. 계약의
steps[].id 와 같은 값이고 StepView.ID 와 같은 글자다.


3. 답을 받으면 낼 것

   domain-entities.md        타입과 열.  contract.Policy · NodeView · LeaseView
                             · RunView · RunFilter · StepView.Chosen
                             · nodes.drain 열 · runs.state 어휘

   business-logic-model.md   조회 넷의 흐름.  ListNodes · ListRuns · DrainingNodes
                             · ListQueued.  UpsertAdvert 의 이전 정책 반환 경로.
                             경계 검사기가 임포트를 읽는 절차

   business-rules.md         정책 값 셋의 검증 · limit 의 기본과 상한 · 필터 넷의
                             입력 검증 (NFR-S5) · 만료 안 된 광고만 · 시간 역순
                             · QUEUED 가 임대를 안 쥔다는 불변식

frontend-components.md 는 안 낸다. U-바닥에 화면이 없다 — 이 유닛이
끝난 시점에 밖에서 보이는 변화가 없다는 것이 이 유닛의 성질이다
(unit-of-work.md).