문서와 심볼

aidlc-docs 를 그대로 읽는다 · 읽기 전용
/home/sunny/enode-fixup-workshop2-visualizer연결 중
inception/requirements/requirements.md623 줄

요구사항 — enode 기능 다섯

이 문서는 requirements/ 여섯 문서를 대체하지 않는다. 값은 거기 있고
어긋나면 enode-design/protocol/INVARIANTS.md 가 이긴다 (canon.md).

여기 있는 것은 넷이다.

   1절 ~ 2절    의도 분석과 입력.  무엇을 받았고 어떻게 읽었나
   3절          확인 질문 첫 묶음의 답.  decisions.md 에 행으로 올라갈 값이다
   4절 ~ 7절    기능 요구사항의 추적표 · 비기능 요구사항 · 수용 기준 · 안 하는 것
   8절          화면 대조.  design/ 을 실제로 열고 나온 것과 둘째 묶음의 답 (8.4)

1. 의도 분석

항목 값
사용자 요청 aidlc-docs/audit.md 2026-09-04T11:48:04Z 항목에 원문 그대로 있다
요청 유형 New Feature — 기존 시스템에 기능 다섯을 더한다 (넷으로 시작해 2026-09-05 에 Mediator MCP 가 들어왔다). 마이그레이션도 리팩터도 아니다
범위 Multiple Components — internal/store · internal/api · internal/contract · internal/enode · cmd/enodectl · cmd/mediator 와 새 패키지 둘(internal/panel · internal/api/ui)
복잡도 Complex — 저장 상태 어휘가 늘고(QUEUED), 노드와 중앙 사이에 새 축(정책)이 생기고, HTTP 서버가 노드에 처음 선다
명확성 Clear. enode-features.md §5 가 미정 없음을 선언했고 확인 질문 셋으로 남은 계약을 닫았다
깊이 Comprehensive — 이틀 예산에 유닛 여섯이 병렬로 돌고 접점이 여덟이다. 추적표가 없으면 접점에서 갈린다
프로젝트 성격 Brownfield. 코드 기준선 1c290a6. 설계 정본 enode-design @ 0fd304b

1.1 가치의 고정점

scene-gates.md §1 의 dhseo 장면이다. 완결성은 그 장면이 끝까지 도는가로
판단한다.
기능 넷을 각각 완성하는 것이 목표가 아니라 넷이 한 장면을
만드는 것이 목표다. 그래서 수용 기준이 유닛 완료가 아니라 조각 게이트다 (6절).


2. 입력

문서 이 문서에서 쓰는 자리
requirements/decisions.md 값의 정본. 1절 사용자 확정 · 2절 권장값 · 3절 보안 취급 · 4절 이월
requirements/enode-features.md 기능 다섯의 목적 · 기능요구사항 · 데이터 · 보안 · 수용기준
requirements/unit-boundaries.md 유닛 일곱 · 파일 행렬 · 접점 표 · CP0 경계 검사
requirements/scene-gates.md 조각 게이트 CP0 ~ CP4. 이것이 수용 기준이다
requirements/canon.md 정본 연결 · 정본이 코드와 어긋난 자리 · 선행 조건 셋
requirements/constraints.md 안 만드는 것 여덟 범주 + 보안 확장의 경계
design/README.md 화면 여덟 장 · 컴포넌트 다섯 · 두 표면이 갈리는 규칙
aidlc-docs/inception/reverse-engineering/ 산출물 아홉. 다시 훑지 않았다. 신선도 델타는 reverse-engineering-timestamp.md 머리
코드 1c290a6 계약 확인용으로만 읽었다 — 2.1 절

2.1 코드에서 확인한 것 — 요구사항을 줄인 자리

기준선을 다시 훑지 않고, 계약이 이미 참인지만 대조했다. 둘이 요구사항을
줄였다.

   store.Cancel 이 이미 verdict 를 쓴다
       reap.go:170.  verdict{state:FAILED, checks:[{what:"cancelled",
       ok:false, note:"cancelled by: <by>"}]} 를 쓰고 sealRecord 까지 부른다.
       at-boundary 가 놓은 Run 을 Cancel(ctx, run, "drain:<node_id>") 로 닫으면
       decisions.md 2절의 「verdict 가 drain 취소와 진짜 실패를 가른다」가
       새 열도 새 필드도 없이 참이 된다.  GET /v1/runs 가 verdict 를 내면 끝이다

   runctl 은 고칠 것이 없다
       internal/runctl/client.go 의 do 가 StatusCode/100 == 2 로 받으므로 202 가
       통과한다.  cmd/runctl/main.go 는 r.State 를 그대로 찍고, Terminal(state) 은
       SUCCEEDED 와 FAILED 뿐이라 --wait 도 QUEUED 에서 계속 폴링한다.
       ⇒ CP2 의 「runctl status 가 QUEUED 를 표시한다」가 코드 변경 0 으로 닫힌다.
       unit-boundaries.md §2 의 파일 행렬에 runctl 이 없는 것은 누락이 아니다

또 하나 — internal/store/store.go 가 이미 internal/match 를 임포트한다
(match.Reject). 그래서 store.WakeQueued 가 match.Match 를 부를 수 있고
임포트 순환이 없다. 매처는 여전히 한 벌이다 (ADR-014 결정 3).


3. 확인 질문 첫 묶음의 답 — decisions.md 에 올릴 값

사용자 답 A · A · A (2026-09-04). 이 셋은 decisions.md 2절에 행으로
올라간다. 아래가 그 행의 내용이다.

둘째 묶음은 8.4절에 있다. 그림을 열고 나온 셋이고 같은 날 같은 형식으로
닫혔다 — 두 묶음을 한 절에 합치지 않은 것은 나온 경로가 다르기 때문이다.
이 절은 decisions.md 를 읽고 나온 것이고 8.4절은 design/ 을 열고 나왔다.

3.1 QUEUED Run 의 steps 는 제출 시점에 만든다

항목 값
결정 steps 행을 제출 시점에 전부 PENDING 으로 넣는다. node_id 는 빈 문자열. assigned 는 NULL
미루는 것 runAcquires 와 raiseAsks 는 승격 시점으로 미룬다 — 둘 다 자원을 잡거나 사람을 부르는 일이라 대기 중에 하면 안 된다
승격이 하는 일 매칭 · 임대 발급 · runs.state 를 RUNNING 으로 · assigned 채우기 · steps.node_id 채우기 · runAcquires · raiseAsks
근거 계약의 구조 검증(st.Kind() · contract.NeedsOf)이 제출에 동기로 붙는다. 미루면 202 를 받은 Run 이 승격 시점에 구조 오류로 죽고 그때는 보고할 호출자가 없다. 그리고 S2 작업 그래프가 대기 중에도 그려진다 (design/README.md §3.2)
딸린 것 Records.Open(runID) 은 제출 시점에 한다. Cancel 이 sealRecord 를 부르므로 대기 중 취소가 봉인까지 닿아야 한다

GET /v1/runs/{id} 가 QUEUED Run 에 내는 것 — state: "QUEUED" ·
steps[] 전부 PENDING 이고 node 가 빈 값 · assigned 없음 · reject 없음.

3.2 WakeQueued 는 도착 순서로 전부 훑는다

항목 값
결정 created_at 오름차순으로 QUEUED 를 전부 훑고, 지금 매칭되는 것을 전부 승격한다
FIFO 의 뜻 같은 자원을 다투는 것들 사이의 도착 순서다. 앞의 Run 이 못 가도 뒤를 막지 않는다
왜 머리 하나가 아닌가 보드를 기다리는 Run 이 머리에 있으면 전혀 다른 능력을 기다리는 뒤의 Run 이 자원이 비어 있어도 못 간다. 그것은 공정성 정책이 아니라 결함이다
정책 틀이 아닌가 아니다. 조건이 하나(도착 순서)이고 계약이 그것을 못 건드린다. constraints.md §2 가 뺀 것은 FIFO 외의 정책과 그것을 갈아끼우는 틀이다
안전성 재진입 · 동시 호출 안전. 승격은 Run 하나마다 독립 트랜잭션이고, 임대 기본키 충돌이 경쟁을 막는다 (I5)
실패 격리 Run 하나의 승격이 실패해도 나머지를 계속 본다. 로그에 남기고 넘어간다

3.3 SECURITY-04 는 네 헤더를 걸고 HSTS 는 N/A 다

항목 값
적용 대상 새 HTML 표면 둘 — 중앙 현황판 GET /ui/ · 호스트 제어판 /
거는 헤더 Content-Security-Policy: default-src 'self' · X-Content-Type-Options: nosniff · X-Frame-Options: DENY · Referrer-Policy: strict-origin-when-cross-origin
HSTS N/A. 평문 HTTP 에서 뜻이 없고 TLS 는 constraints.md §6 이다. 근거를 적었으므로 차단성 발견이 아니다
딸린 제약 CSP 에 unsafe-inline 을 안 쓴다 ⇒ 화면의 JS 와 CSS 는 인라인이 아니라 별도 파일이다. 이것이 U-화면의 파일 구성을 정한다
적용 안 하는 자리 기존 JSON 라우트 15개. constraints.md §7 이 기존 계약 변경을 뺐고 HTML 을 내지 않는다

4. 기능 요구사항 — 추적표

절 번호는 enode-features.md 의 것이다. 이 표가 요구사항과 유닛과 게이트를
잇는다.
유닛은 Units Generation 이 확정하고, 여기서는 unit-boundaries.md
의 권장을 그대로 딛는다.

# 요구사항 절 유닛 게이트
FR-1 GET /v1/nodes 신규. ADR-065 응답 모양 + draining. principal 안 냄 · 필터 없음 · 만료 안 된 광고만 3.1.1 U-바닥 · U-관측API CP1
FR-2 GET /v1/runs 신규. 응답 모양과 필터는 decisions.md 2절 + 행마다 current[] 와 reject (§8.4 ① · ①-a). 시간 역순 · limit 기본 100 · 상한 1000 · 페이지네이션 없음 3.1.1 U-바닥 · U-관측API CP1 · CP2
FR-3 StepView.chosen 추가. DB 에 있고 뷰에 없다 (ADR-060) 3.1.1 U-바닥 CP1
FR-4 임대 표시는 GET /v1/nodes 의 lease{run_id, not_after} 에 내장. 별도 표면 없음 3.1.1 U-관측API CP1
FR-5 중앙 현황판 정적 파일. S0 · S0b · S1 · S1b · S2. Mediator 에 embed, GET /ui/, 무인증. 토큰은 S0 에서 받아 브라우저 세션 저장소에만 둔다 3.1.1 U-화면 CP1 · CP2 · CP4
FR-6 runs.state 어휘에 QUEUED 추가. 409 -> QUEUED, 제출 응답 202. 422 는 그대로 FAILED 3.2.1 U-바닥 · U-대기열 CP2
FR-7 QUEUED Run 의 steps 를 제출 시점에 만든다. runAcquires · raiseAsks 는 승격으로 미룬다 (3.1절) 3.2.1 U-대기열 CP2
FR-8 승격. store.WakeQueued 가 도착 순서로 전부 훑는다 (3.2절) 3.2.1 U-대기열 CP2
FR-9 깨우기 지점 여섯 — postResult · postCancel · Reap · 재기동 감지 · 부분 반납 · drain 해제를 받은 광고 처리. 주기에 안 얹는다 3.2.1 U-대기열 · U-drain CP2 · CP3
FR-10 기동 시 WakeQueued 한 번 (cmd/mediator/main.go). QUEUED 는 아무것도 쥐지 않으므로 재기동 후에도 참이다 3.2.1 U-대기열 CP2
FR-11 매칭에서 draining 노드 제외. submit 이 BusyNodes 에 DrainingNodes 를 합쳐 넘긴다. match.go 는 아무도 안 만진다 3.2.1 · 3.2.2 U-대기열 · U-drain CP3
FR-12 정책 자리. 정본은 노드의 파일. 위치와 형식은 Functional Design 몫 — 기존 enode 설정 파일 옆, 같은 yaml 3.2.2 U-drain CP3
FR-13 경로. 데몬이 광고 직전에 정책을 읽어 광고에 싣고, UpsertAdvert 가 nodes 열에 복사본을 두고, 광고 응답이 되돌려 주고, 데몬이 그 값을 Worker 에 넘긴다. 중앙 라우트 신설 없음 3.2.2 U-바닥 · U-drain CP3
FR-14 두 모드. graceful 은 새 임대만 막는다. at-boundary 는 도는 단계를 끝까지 두고 결과 보고 뒤 임대를 놓고, Mediator 가 postResult 끝에서 store.Cancel(ctx, run, "drain:<node_id>") 로 닫는다 3.2.2 U-drain CP3
FR-15 해제는 소유자 명시. 도는 Run 이 끝나도 자동으로 안 풀린다. 풀면 다음 광고에서 후보로 돌아가고 WakeQueued 가 대기 Run 을 보낸다 3.2.2 U-drain CP3
FR-16 drain 중에도 광고는 계속 보낸다. draining 을 실어서. 현황판에 「있는데 안 빌려준다」로 보인다 3.2.2 U-drain · U-화면 CP3 · CP4
FR-17 enodectl serve [name]. 별도 프로세스 · 새 실행파일 없음. 이 기계의 노드를 전부 서브한다 — 이름은 처음 선택될 노드일 뿐이라 여럿이어도 생략할 수 있다(생략하면 names() 의 첫째). 라우트가 ?node=<name> 을 받는다 (§8.4 ②) 3.1.2 U-제어판 CP4
FR-18 프로세스 제어. status · start · stop · logs 를 감싼다. 재료는 cmd/enodectl/main.go 의 names · pidOf · identityOf · ownsConfig · signalStop 과 proc_*.go 의 processAlive — internal/panel 로 내려 같이 쓴다 3.1.2 U-제어판 CP4
FR-19 신원 표시. node_id · label · principal · instance 전부. 자기 기계다 3.1.2 U-제어판 CP4
FR-20 탐지 능력을 Detect() 결과로 읽기 전용 표시. 편집 자리를 만들지 않는다 (ADR-017 결정 3) 3.1.2 U-제어판 CP4
FR-21 현재 작업. 임대 유무 · Run · not_after · CLAIMED 단계 이름 · attempt · 시작 시각. 출처는 Mediator 조회 — GET /v1/nodes 의 자기 행과 GET /v1/runs/{id}. 데몬 로그를 읽지 않는다 3.1.2 U-제어판 CP4
FR-22 drain 토글. 걸기 · 모드 고르기 · 풀기. 건 뒤에는 현재 모드와 「풀기」가 보인다 (C3) 3.1.2 · 3.2.2 U-제어판 CP4
FR-23 Mediator 연결 상태. 마지막 응답 시각. 끊기면 drain 통보가 늦어진다는 문구 3.1.2 U-제어판 CP4
FR-24 호스트 화면 S3 · S5. 멈춘 노드에서 주 동작은 start 3.1.2 U-화면 CP4
FR-26 runctl mcp. stdio JSON-RPC 2.0 하나. streamable HTTP 를 안 연다. 새 실행파일 없음 — cmd/runctl 의 하위명령이다 (decisions.md 5.2) 3.3.1 U-MCP CP5
FR-27 토큰은 ENODE_MEDIATOR · ENODE_TOKEN 환경변수에서 읽는다. runctl 과 같은 규칙이고 도구 인자로 안 받는다 — 받으면 토큰이 대화 기록에 남는다 (SECURITY-12) 3.3.1 U-MCP CP5
FR-28 도구 일곱 — capabilities.list · fleet.list · runs.list · run.submit · run.plan · run.cancel · record.get. 전부 기존 REST 를 감싼다. 새 라우트 0 · Mediator 무변경 (decisions.md 5.3) 3.3.1 U-MCP CP5
FR-29 internal/runctl/client.go 에 Nodes · Runs 두 메서드를 더한다. fleet.list · runs.list 가 그것을 쓴다. HTTP 클라이언트를 새로 안 짠다 — do() 가 인증과 오류 사상을 이미 진다 3.3.1 U-MCP CP5
FR-25 마지막 관측 캐시. 제어판이 노드마다 마지막 성공 관측을 메모리에 든다. serve 가 5초마다 GET /v1/nodes 를 한 번 받아 이 기계 노드 전부의 행을 채운다 (§8.4 ③-a). GET /api/status 가 observed{seen_at, expires_at, lease, instance, at} 과 stale 을 낸다. serve 가 도는 동안만 살고 재시작하면 빈다 — 비면 화면이 — 로 그린다 (§8.4 ③) 3.1.2 U-제어판 CP4

4.1 확정된 계약 — 접점에서 갈리면 안 되는 것

unit-boundaries.md §4 의 접점 표에 3절의 답을 얹은 것이다. Functional
Design 이 내부를 다듬을 수 있으나 아래 모양은 못 바꾼다
— 유닛 둘 이상이
읽기 때문이다.

   contract.Policy{Drain string}          "" | "graceful" | "at-boundary".  문자열 그대로
   store.DrainingNodes(ctx)               map[string]bool.  match.Match 의 busy 와 같은 모양
   store.WakeQueued(ctx) error            도착 순서로 전부.  재진입 · 동시 호출 안전
   store.UpsertAdvert(...) 의 반환         이전 정책을 돌려준다.  해제 감지가 이 값을 읽는다
   store.ListNodes · store.ListRuns       decisions.md 2절의 응답 모양을 그대로 낸다
   GET /v1/runs 의 행                      decisions.md 2절 + current[] + reject (8.4절 ①)
   current[]                              [{node, step, attempt, started_at}].  CLAIMED 마다 하나
   reject                                 {code, as, reason}.  match.Reject 에 태그를 단다
   GET /v1/nodes 노드 항목                 ADR-065 모양 + draining
   GET /api/status 의 observed · stale     8.4절 ③.  캐시가 비면 observed 를 안 내고 stale 이 true
   광고 응답의 정책 왕복                    advertResponse 에 policy 를 더한다 (4.2절)
   policy.Read(path) · policy.Write(path, Policy)   U-drain 이 정본.  제어판은 파서를 안 만든다
   enodectl serve 의 라우트                4.3절.  전부 ?node=<name> 을 받는다
   MCP 도구 이름 일곱                      decisions.md 5.3.  Claude 설정에 박히면 못 바꾼다
   runctl.Client.Nodes · .Runs            GET /v1/nodes · GET /v1/runs 를 그대로 낸다

MCP 도구 이름이 계약인 이유 — 사용자의 Claude 설정과 대화에 그 글자가
박힌다. 라우트 이름을 바꾸는 것과 같은 무게이고, 그래서 Functional Design 이
내부는 다듬어도 일곱 이름은 못 바꾼다.

4.2 광고 응답의 정책 왕복 — 권장값을 벗어난 자리

unit-boundaries.md §4 는 이 접점의 모양을 안 정했다. advertResponse 에
policy 를 더한다
— GET /v1/nodes 의 납작한 draining 과 이름을 달리 한다.

근거 — 광고 요청이 올려보낸 것과 같은 객체를 그대로 되돌리는 왕복이다.
같은 것에 이름을 둘로 두면 어느 쪽이 정본인지가 갈린다. GET /v1/nodes 쪽은
관측이고 ADR-065 가 납작한 필드로 응답을 짜 뒀으므로 거기서는 draining 이
맞다. 데몬은 policy.drain 만 읽는다.

왜 왕복이 필요한가 — 데몬이 파일을 직접 읽었는데도 응답의 값을 쓰는 이유는
Worker 가 Mediator 가 확인한 값으로 움직여야 하기 때문이다. 그렇지 않으면
노드는 at-boundary 로 임대를 놓았는데 중앙은 아직 후보로 알고 있는 창이
생긴다. 그 창을 없애는 것이 이 왕복이다 (enode-features.md 3.2.2 「데몬은
응답의 값을 Worker 에 넘긴다」).

4.3 제어판 라우트 — 권장값을 넘긴 자리

unit-boundaries.md §4 는 /api/status · /api/policy · 정적 / 셋을
시작값으로 뒀다. 그 셋으로는 FR-18(프로세스 제어)이 안 닫힌다. Units
Generation 이 확정한다
고 같은 절이 적었으므로 여기서 늘린다. 8.4절 ② 의 답
A 가 여기에 ?node= 와 /api/nodes 를 더했다.

   GET  /                     정적 파일.  S3 · S5
   GET  /api/nodes            이 기계의 노드 목록.  사이드바가 읽는다 (8.4절 ②)
   GET  /api/status?node=     신원 · 프로세스 살았나 · 탐지 능력 · 현재 작업
                              · Mediator 마지막 응답 시각 · observed · stale
   GET  /api/policy?node=     현재 정책
   PUT  /api/policy?node=     정책 쓰기.  drain 걸기와 풀기.  모드 바꾸기는 못 한다 (아래)
   POST /api/start?node=      데몬 띄우기
   POST /api/stop?node=       데몬 멈추기
   GET  /api/logs?node=       로그 꼬리

?node= 는 이 기계의 노드 이름이다. 생략하면 names() 의 첫째이고,
names() 에 없는 이름이면 404 다. 노드가 하나뿐인 기계에서는 붙일 일이 없다.
/api/nodes 가 하나 는 것은 사이드바가 목록을 어디선가 받아야 하기 때문이다 —
/api/status 에 목록을 얹으면 노드마다 부르는 라우트가 전체 목록을 매번 나른다.

PUT /api/policy 하나로 걸기와 풀기를 받는다 — 정책은 상태이지 명령이 아니고,
명령으로 쪼개면 「지금 무엇이 걸려 있나」가 라우트 셋에 흩어진다.
GET /api/status 가 종합이라 화면이 폴링 하나로 돈다.

모드 바꾸기는 받지 않는다. 이 절의 1판은 「걸기 · 모드 바꾸기 · 풀기가
전부 이것 하나」였고 그것이 틀렸다. C3 컴포넌트의 각주가 「걸 때 고른
모드(graceful · at-boundary)는 풀기 전까지 바뀌지 않는다」이고 C3 에
모드 라디오가 없다 (§8.5). 그래서 전이는 둘뿐이다.

   ""            -> graceful | at-boundary      받는다
   graceful | at-boundary -> ""                 받는다.  언제나
   graceful <-> at-boundary                     409.  먼저 풀어라

정본은 여전히 노드의 파일이므로 소유자가 파일을 직접 고쳐 모드를 갈아치울 수
있다 — 라우트가 막는 것은 화면에서 오는 길뿐이다. 그것으로 충분하다:
막는 이유가 안전이 아니라 「되찾았다」의 뜻을 지키는 것이기 때문이다.
at-boundary 를 graceful 로 바꾸면 이미 놓인 임대가 안 돌아오고, 반대로
바꾸면 도는 Run 이 예고 없이 경계에서 죽는다.


5. 비기능 요구사항

5.1 보안 — 새 표면에만 건다

security-baseline 이 켜져 있다. decisions.md 3절이 규칙마다 취급을 미리
적었고, 여기에 3.3 절이 SECURITY-04 를 더했다. 기존 코드의 사실은 기록만
하고 고치지 않는다.

# 요구사항 규칙
NFR-S1 제어판 바인딩 기본값 127.0.0.1:8081. LAN 노출은 --listen 명시로만 SECURITY-07
NFR-S2 --listen 이 127.0.0.1 밖이면 정책 파일의 panel_token 필수. 없으면 serve 가 기동 거부. Mediator 토큰을 재사용하지 않는다 SECURITY-12
NFR-S3 정책 파일은 소유자만 쓸 수 있는 권한으로 만든다 SECURITY-09 · SECURITY-12
NFR-S4 GET /v1/nodes · GET /v1/runs 는 Mediator 토큰으로 보호. 기존 표면과 같은 auth 미들웨어 SECURITY-08
NFR-S5 입력 검증. limit 은 상한을 갖고 넘으면 잘린다. state · draining 은 enum. since 는 RFC3339. work 는 정확 일치 SECURITY-05
NFR-S6 새 HTML 표면 둘에 헤더 넷. HSTS 는 N/A (3.3절) SECURITY-04
NFR-S7 자격증명을 소스에 박지 않는다. 제어판이 Mediator 를 부를 토큰은 그 기계의 기존 enode 설정에서 읽는다 SECURITY-12
NFR-S8 제어판 127.0.0.1 무인증은 명시적 public 표면이다. 객체 수준 권한은 해당 없음 — ADR-015 §1 이 principal 을 식별로 뒀고 그 기계에 접속한 것이 소유의 증거다 (ADR-042 · ADR-063 §3) SECURITY-08
NFR-S9 새 핸들러의 외부 호출은 전부 오류 처리를 갖고 실패하면 닫는다. 사용자에게 나가는 오류 문구에 내부 경로 · 스택을 안 싣는다 SECURITY-15

limit 상한 — 새로 정하는 값

decisions.md 2절이 기본 100 만 정했고 상한은 안 정했다. SECURITY-05 가
명시적 상한을 요구하므로 여기서 정한다 — 상한 1000, 넘으면 1000 으로
자른다.
기본의 열 배이고, 이틀 안에 그만큼 쌓이지 않으며, 페이지네이션이
없는 것과 모순되지 않는다 (limit 이 유일한 손잡이다).

하지 말 것

   TLS 를 넣는 것                          constraints.md §6
   cmd/mediator · internal/config 를 보안 이유로 고치는 것   constraints.md §6.  유닛 경계 밖
   bootstrapToken 의 stderr 토큰 출력을 고치는 것             decisions.md 3절 SECURITY-03
   Mediator Listen :8080 을 좁히는 것                        decisions.md 3절 SECURITY-07
   sslmode=disable 을 고치는 것                              decisions.md 3절 SECURITY-01

5.2 불변식 — 건드리면 안 되는 것

# 요구사항
NFR-I1 I1 · I2 · I4 · I5 를 건드리지 않는다. leases.node_id 기본키를 안 바꾼다
NFR-I2 상태 READY 를 만들지 않는다 (ADR-064 §1.1). 저장되는 어휘는 RUNNING · SUCCEEDED · FAILED 와 새로 오는 QUEUED 넷이다
NFR-I3 drain 을 위한 중앙 라우트를 만들지 않는다. 경로는 광고뿐이다
NFR-I4 match.Match 를 안 고친다. 순수 함수이고 busy 는 api.go 의 submit 이 만들어 넘긴다 (ADR-014 결정 3)
NFR-I5 단계 도중에 끊지 않는다. at-boundary 는 경계에서만 놓는다 (INVARIANTS §2 재개 없음)
NFR-I6 POST /v1/runs/dry-run 의 응답을 안 바꾼다. busy 를 안 보듯 draining 도 안 본다
NFR-I7 같은 run_id 재제출은 200 + 기존 Run 이다 (INVARIANTS §4). 202 는 새로 QUEUED 가 되는 순간만

5.3 패키지 경계

# 요구사항
NFR-P1 internal/panel -> internal/store 금지. 제어판은 HTTP 로 Mediator 를 본다
NFR-P2 internal/panel -> internal/api 금지
NFR-P3 internal/api/ui -> internal/store 금지. 화면은 정적 파일이다
NFR-P4 internal/enode -> internal/panel 금지. 데몬은 제어판을 모른다
NFR-P5 위 넷을 go/parser 로 임포트를 읽어 기계로 센다. U-바닥의 산출물이고 CP0 가 돈다 (unit-boundaries.md §5)
NFR-P6 유닛끼리 서로 임포트하지 않는다. 새 기능은 새 패키지, api.go 는 등록 줄만

5.4 관측과 갱신

# 요구사항
NFR-O1 두 화면 다 폴링 5초. SSE · WebSocket 없음 (constraints.md §5)
NFR-O2 화면은 값 대신 **「마지막 갱신 시각」**을 보인다
NFR-O3 expires_at 까지 30초 이하면 카드가 흐려진다
NFR-O4 갱신이 연속 3회 실패하면 카운트다운을 멈추고 S1b 로 간다
NFR-O5 중앙 카드에 observed_at 과 expires_at 을 함께 얹는다 — 보고 있는 것이 관측 시각의 사실이지 지금의 사실이 아니다 (design/README.md §2.1)
NFR-O6 draining 배지는 두 국면을 가른다 — 「draining · 진행 중」(C4) 과 「draining · 대기 중」(C5). 안 가르면 사람이 「지금 되찾았나」를 모른다

5.5 품질 게이트

# 요구사항
NFR-Q1 .coverage-contract.yml 의 바닥 80% 를 새 패키지에도 그대로 건다. 계약 파일을 안 고친다
NFR-Q2 internal/panel · internal/api/ui 는 DB 없이 도는 테스트로 채운다 — store 를 안 부르므로 ENODE_TEST_DATABASE_URL 이 필요 없다
NFR-Q3 go test ./... · go vet ./... · golangci-lint run · go run ./scripts/glyphscan.go 가 초록이다
NFR-Q4 표기와 언어는 CONVENTIONS.md. 강조는 마크다운 굵게로만, 코드에 장식 문자 금지, 에러 · 로그 · CLI 출력 · 테스트 이름은 영어, 주석과 커밋 메시지는 한국어
NFR-Q5 기존 라우트 15개가 회귀 없이 돈다

5.6 성능

상한을 두지 않는 것이 결정이다. 근거는 decisions.md 1절.

   대기 상한            없다.  QUEUED 는 무기한.  빠져나가는 길은 cancel 뿐
   at-boundary 지연 상한  없다.  도는 단계의 남은 시간 + 광고 주기가 곧 값
   페이지네이션          없다.  limit 뿐
   노드 수              여섯 규모.  LISTEN/NOTIFY 는 ADR-015 가 적어둔 나중의 최적화다

6. 수용 기준

scene-gates.md 가 정본이다. 조각마다 실행 명령이 있고, 조각이 초록이
아니면 다음 유닛에 들어가지 않는다. 집행자는 그 유닛을 구현하지 않은 사람이다.

조각 무엇을 확인하나 딛는 유닛
CP0 기동이 안 깨졌다. 기존 테스트 · 커버리지 · 린트 · glyphscan · 라우트 15개 회귀 · 경계 검사 테스트 U-바닥
CP1 보인다. GET /v1/runs · GET /v1/nodes 가 실데이터 JSON. lease.not_after 와 draining 이 실려 있다. 화면 S1 · S1b U-관측API (+ U-화면)
CP2 기다린다. 노드 하나에 Run 둘 -> 둘째 202 · QUEUED -> 첫째가 끝나면 RUNNING. 없는 능력은 여전히 422 · FAILED. 화면 S1 의 QUEUED 행이 어느 카드에도 안 얹혀 있다 U-대기열
CP3 돌려받는다. at-boundary 로 걸면 경계에서 임대가 풀리고 후보에서 빠진다. 풀면 대기 Run 이 집는다. 화면 없음 — 정책 파일과 GET /v1/nodes 로 닫는다 U-drain
CP4 한 장면. scene-gates.md §1 ① ~ ⑦ 을 끝까지. 현황판과 제어판이 같은 사실을 보인다. LED 가 새 패턴이다 전부

6.1 화면 검증은 API 검증과 따로 적는다

API 어휘(「임대가 풀린다」)는 화면 없이도 초록이 된다. 그래서 화면이 있는
유닛의 완료 조건은 그 화면의 버튼을 전부 나열한다. 「누르면 걸린다」만으로는
「풀기」가 빠진 채 통과된다 (scene-gates.md §2.1 · design/README.md §7).

Units Generation 이 유닛마다 그 목록을 낸다.

6.2 게이트가 그 유닛 밖의 이유로 빨간 경우

진행자가 사실을 aidlc-docs/audit.md 에 적고 조각을 보류로 표시한 뒤
넘어간다. 보류는 통과가 아니다 — CP4 전에 전부 초록이어야 한다
(scene-gates.md §4).


7. 범위 밖

constraints.md 여덟 범주와 decisions.md 4절 이월이 정본이다. 요약만 둔다.

   실행 중 선점 · FIFO 외의 공정성 정책 · 격리 강화 · 중앙에서의 소유권 판정
   SSE · WebSocket · 트랜스크립트 (S4 는 자리만)
   대시보드 인증 · TLS · 리버스 프록시 · 다중 Mediator · 페이지네이션
   기존 계약 변경 (success_when 문법 · 봉인 규칙 · 임대 키 · READY · 중앙 drain 라우트)
   역할 주입 · 우선순위 · 별칭 · 메모
   트랜스크립트 구독 (MCP 는 범위 안이지만 구독 표현은 여전히 이월이다)

Mediator MCP 가 이 목록에서 빠졌다 (2026-09-05). CP2 가 닫힌 뒤 진행자가
이월을 되돌렸고 근거는 decisions.md 5절이다 — 이월의 이유가 결합이 아니라
값 둘(전송 · 토큰 전달)이었고 5.2 가 그 둘을 닫았다. 유닛 U-MCP · 게이트 CP5
로 들어왔고 FR-26 ~ FR-29 가 그것이다.

7.1 정본 쪽 선행 조건 — 이 저장소의 일이 아니다

canon.md §3 이 셋을 적었다. enode-design 의 PR 이고 이 저장소가 하지
않는다. 다만 이 팩은 개정된 문장을 따른다.

   ADR-065 개정      GET /v1/nodes 에 draining 필드를 더한다
   ADR-063 개정      §2.1 을 graceful 한정으로 하고 at-boundary 를 갈라 적는다
   ADR-063 · 064     초안을 결정으로 올린다.  decisions.md 가 미결에 값을 줬다

ADR-065 는 「구현됨」이라고 적혀 있으나 1c290a6 에 GET /v1/nodes 가 없다
(canon.md §2). v1 은 1c290a6 만 본다 — U-관측API 는 ADR-065 §2 의
응답 모양에서 새로 만든다.


8. 화면 대조 — design/ 을 실제로 열고 나온 것

Workflow Planning 까지 이 절이 비어 있었다. 읽은 것은 design/README.md
(그리는 규칙)뿐이었고 그림 자체를 안 봤다. 사용자 지적으로 열었다.

   pencil MCP       안 붙는다.  failed to connect to running Pencil app: code-server
                    MCP 는 실행 중인 앱에 붙는 통로다.  이 작업에는 필요 없었다
   @pen.dev/cli     0.3.6.  pen login 뒤 헤드리스로 .pen 을 직접 연다 —
                    pen interactive --in design/enode-ux.pen --out <스크래치패드>
                    save() 를 안 부르고 --out 을 저장소 밖에 뒀다.  원본 무변경
   PNG 로 읽은 것     design/exports/*.png 일곱 — S0 · S0b · S1 · S1b · S2 · S3 · S5
                    C1 · C2 는 S1 안에 인스턴스로 들어 있어 같이 봤다
   .pen 으로 읽은 것  C3 · C4 · C5.  export 가 없어 원본을 열어야 보였다 (8.5절)
                    S1 · S1b · S2 의 배지와 필터 글자를 원본에서 대조했다 (8.6절)
   안 읽어도 되는 것   S4.  범위 밖이고 자리만이다 (constraints.md §5)

그림은 장식이 아니었다. decisions.md 와 ADR-065 가 정한 응답 모양으로는
못 그리는 자리가 셋 있다 — 8.3 절.

8.1 확인된 것 — 문서가 맞았다

design/README.md §4.1 은 「중앙 카드는 label 만. principal 을 내지 않는다」
인데 S1 카드 제목이 taeels@thinkpad:ws-a 라 언뜻 principal@host:label 로
보인다. 아니다. internal/enode/identity.go 의 deriveLabel 이
local@host:configbase 를 만든다 — 그 문자열 전체가 label 이다. S3 의 신원
블록이 principal: taeels@corp 과 label: taeels@thinkpad:ws-a 를 따로
보이며 「principal 은 이 화면에만 있다」고 못 박아 뒀다. ADR-065 를
안 어긴다.

node_id 도 맞다 — 화면의 a3f19c2b8e04 는 12 자이고 deriveFrom 이
hex[:12] 를 쓴다. 자르는 것이 아니라 그것이 전부다.

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

자리 그림이 보인 것 처리
S1b 의 SEALED 필터 칩과 배지의 보이는 글자가 SEALED 다. S1 은 같은 자리가 SUCCEEDED 다 SUCCEEDED 로 간다. design/README.md §6 이 「SEALED 는 Run 상태가 아니다」로 이미 판정했다. §6 은 S1 의 프레임 이름표만 그렇다고 적었으나 S1b 는 보이는 글자까지 SEALED 다 — 그림 쪽 오류이고 규칙이 이긴다. 행 부제도 봉인 이 아니라 완료 시각으로 적는다
S2 의 produced 단계 상세에 produced 행이 있다. StepView 에는 그 필드가 없다 StepView 에 안 더한다. GET /v1/runs/{id}/ledger 가 이미 단계별 산출물 이름을 준다 (entries[].by = "step:<id>"). 화면이 그것을 조인한다. 새 필드 0 개
draining 배지 두 국면 (C4 · C5) 「진행 중」과 「대기 중」을 갈라야 한다 파생이다. draining != "" 이고 lease != null 이면 진행 중, lease == null 이면 대기 중. 새 필드 없음
at-boundary 카드 카운트다운 대신 경계에서 · 최대 13:02:38 표시 규칙. draining == "at-boundary" 면 남은 시간을 세지 않고 lease.not_after 를 상한으로 적는다. 경계가 언제인지는 아무도 모르므로 카운트다운이 거짓말이 된다
S3 의 「재시작」 요구사항에는 status · start · stop · logs 넷뿐이다 합성이다. stop 뒤 start. 새 라우트를 안 만든다
S3 의 트랜스크립트 카드 「하네스가 지금 뱉는 출력」 카드에 → 가 있다 자리만. 비활성으로 그린다. constraints.md §5 가 범위 밖이고 design/README.md §3 이 S4 를 자리만으로 뒀다. 「헤더의 로그 는 데몬 로그다. 다른 물건이다」는 문구는 남긴다
S5 에 drain 블록 없다 맞다. 그림의 문구 그대로 — 「drain 은 도는 노드에만 건다. 멈춘 노드는 이미 자원을 내놓은 상태」. 유닛 완료 조건이 상태별로 갈린다
S3 의 임대 진행 막대 임대 시간 — 4분 39초 중 2분 41초 남음 파생이다. not_after 와 granted_at 대신 lease.not_after 와 설정의 TTLSeconds 로 그린다. 새 필드 없음

8.3 화면의 버튼 전부 — 유닛 완료 조건의 재료

scene-gates.md §2.1 과 design/README.md §7 이 「화면이 있는 유닛의 완료
조건은 그 화면의 버튼을 전부 나열한다」고 요구한다. Units Generation 이 이
목록을 쓴다.

   S0    토큰 표시 · 숨김 토글 (눈 아이콘)
         현황판 열기
   S0b   같은 둘.  입력 테두리가 붉고 그 아래 「401 — Mediator 가 이 토큰을
         받지 않았다」 가 인라인으로 붙는다.  버튼은 그대로 산다

   S1    필터 칩 다섯 — 전체 · RUNNING · QUEUED · SUCCEEDED · FAILED
         Run 행의 열기 (>)  -> S2
         (카드에는 조작이 없다.  중앙은 읽기 전용이다)

   S2    닫기 (X)  -> S1
         단계 노드 고르기 -> 오른쪽 상세
         「함대 격자의 이 노드로」 (->)  -> S1 의 그 카드

   S3    사이드바의 노드 고르기
         「로그」 · 「재시작」 · 「정지」                 (헤더 셋.  프레임 이름 Btn 로그 · Btn 재시작 · Btn 정지)
         graceful (라디오) · at-boundary (라디오)
         「drain 걸기」
         [건 뒤 = C3]  「drain 풀기 · 함대로 돌려주기」   <- 이것이 빠지면 통과가 아니다
                       모드 배지 (graceful | at-boundary).  라디오는 사라진다
         트랜스크립트 (->)                          자리만 · 비활성

   S5    사이드바의 노드 고르기
         「마지막 로그」                              (헤더 하나.  로그·재시작·정지가 아니다)
         「시작」
         (drain 블록 없음)

8.4 그림이 연 계약 구멍 셋 — 답으로 닫았다

사용자 답 A · A · A (2026-09-04). 질문 전문은
design-verification-questions.md 에 그대로 남아 있다. 셋 다 decisions.md 도
ADR-065 도 답을 갖고 있지 않던 자리이고, 셋 다 결정표 2절의 권장값을 넘긴
자리라 근거를 적는다
— 그것이 결정표의 게이트 규칙이다.

① Run 행과 S1 카드의 「지금 무슨 단계」 — GET /v1/runs 를 넓힌다

항목 값
결정 GET /v1/runs 의 행에 current 와 reject 를 더한다. GET /v1/nodes 는 안 건드린다
current 도는 단계 — step · attempt · started_at. 도는 것이 없으면 안 낸다
reject runs.reject 열의 투영. Run 행 부제의 「함대에 능력 없음」이 여기서 온다
새 열 0. reject 와 verdict 는 runs 에 이미 있고 current 는 steps 조인이다 (2.1절과 같은 성질의 확인)
폴링 비용 화면 하나당 요청 둘로 유지된다 — GET /v1/nodes 와 GET /v1/runs. C 안이면 1 + 임대 수 로 늘었다
ADR-065 개정 범위 draining 하나 그대로. B 안이면 lease 세 필드까지 늘었다
근거 GET /v1/runs 는 이 팩이 새로 만드는 라우트라 정본 개정 부담이 없다. ADR-065 는 이미 착지한 결정이고 거기에 얹으면 canon.md §3 의 선행 PR 이 커진다
사는 곳 FR-2 · §4.1

② enodectl serve 의 범위 — 이 기계의 노드를 전부 서브한다

항목 값
결정 enodectl serve [name]. 이름은 처음 선택될 노드일 뿐이고 여럿이어도 생략할 수 있다
라우트 전부 ?node=<name> 을 받는다. 사이드바용 GET /api/nodes 가 하나 는다 (§4.3)
넘긴 문면 enode-features.md 3.1.2 의 「names() 가 하나면 생략, 여럿이면 필수」. 그 문장은 하나만 서브한다는 뜻이었다
근거 S3 · S5 의 사이드바가 「멈춘 노드는 이 화면에만 나온다」를 말하는 자리다. 기계에 노드가 둘인데 둘을 보려고 포트 둘에 serve 를 띄우는 것은 소유자 화면으로 이상하다. 프로세스 하나 · 포트 하나가 「이 기계의 제어판」의 뜻과 맞다
안 늘어나는 것 프로세스도 포트도 여전히 하나다. decisions.md 2절의 127.0.0.1:8081 이 그대로다
사는 곳 FR-17 · §4.3

③ 멈춘 노드의 「마지막 관측」 — 제어판이 메모리에 든다

항목 값
결정 제어판이 노드마다 마지막 성공 관측을 메모리에 든다. serve 가 도는 동안만 살고 재시작하면 빈다
응답 GET /api/status 가 observed{seen_at, expires_at, lease, instance, at} 과 stale: true | false 를 낸다. at 은 그 관측을 받은 시각이다
빈 경우 observed 를 안 내고 stale 이 true. 화면이 — 로 그린다
근거 FR-23(「Mediator 마지막 응답 시각」)이 이미 같은 성질의 메모리 상태를 요구한다. 부품이 하나 느는 것이 아니라 같은 부품이다. 디스크에 안 쓰므로 정본이 갈리지 않는다
기각한 C 안 데몬이 마지막 광고를 로컬 파일에 남기는 것. decisions.md 2절이 「데몬이 상태 파일을 남기는 것은 새 기계다」로 이미 기각한 자리다
「겉면은 얇게」와의 관계 unit-boundaries.md 의 그 줄은 store 를 직접 부르지 말라는 뜻이다. 제어판은 여전히 HTTP 로만 Mediator 를 본다 — 캐시는 그 응답의 사본이지 두 번째 관측 경로가 아니다
사는 곳 FR-25 · §4.3

답 A 가 덜 정한 자리 셋 — 셋도 닫혔다

값으로 굳히면서 코드와 대조했더니 답 자체는 맞는데 모양이 하나로 안 정해지는
자리가 셋
남았다. 유닛 둘 이상이 읽는 접점이라 unit-of-work-plan.md §1 이
물었고 사용자 답 A · A · A (2026-09-04) 로 닫혔다.

자리 값 근거
①-a current 의 폭 배열이다. current: [{node, step, attempt, started_at}] — 지금 CLAIMED 인 단계마다 한 항목. 도는 것이 없으면 안 낸다 S1 카드는 노드 하나를 세고 Run 행은 Run 하나를 센다. 단수면 폭이 1 을 넘는 Run 에서 카드가 남의 노드가 도는 단계를 그린다. CP3 계약이 「단계 둘 이상」을 요구하므로(scene-gates.md §3) 가정이 아니라 실제로 걸린다. steps 조인에 node_id 가 하나 더 실릴 뿐 새 열은 여전히 0
①-b reject 의 키 match.Reject 에 json 태그를 단다. 두 라우트가 reject{code, as, reason} 로 합쳐진다 지금 GET /v1/runs/{id} 가 {Code, As, Reason} 을 낸다 (match.go:49 에 태그가 없고 api.go:155 가 그대로 싣는다). 소비자가 0 이다 — 저장소에서 runView.Reject 의 키를 읽는 코드가 없고 runctl 이 읽는 것은 별개 타입 errBody 다. mediator-api.md 의 GET /v1/runs/{id} 절이 reject 를 안 적었으므로 개정할 문장이 없다. 옛 DB 행 {"Code":…} 는 Go 의 언마샬이 키 대소문자를 무시해 그대로 읽힌다
③-a 제어판 폴링 범위 serve 가 5초마다 GET /v1/nodes 를 한 번 받아 이 기계 노드 전부의 행을 캐시에 넣는다. /api/status?node= 는 캐시를 읽는다. GET /v1/runs/{id}(현재 작업)는 화면이 보고 있는 노드에 임대가 있을 때만 부른다 Mediator 로 나가는 요청이 노드 수와 무관하게 5초에 하나다. 안 열어 본 노드의 「마지막 관측」도 차므로 멎은 노드를 처음 열었을 때 S5 가 그림대로 그려진다 — 요청이 올 때만 부르면 그 칸이 영영 빈다

①-b 는 constraints.md §7 을 넘지 않는다. 그 절의 목록은 success_when
문법 · 봉인 규칙 · 임대 키 · READY · drain 중앙 라우트 · I1·I2·I4·I5
다. 태그를 다는 것은 그중 어느 것도 아니고 match.Match 의 시그니처와 로직을
안 건드리므로 NFR-I4 도 그대로다. 다만 internal/match/match.go 를 만지는
유닛이 하나 생긴다
— unit-boundaries.md §2 의 「아무 유닛도 안 만진다」가
이 한 줄만큼 바뀐다. 처리는 unit-of-work-dependency.md 에 있다.

8.5 C3 · C4 · C5 — .pen 을 열어 읽었다

@pen.dev/cli 로 로그인한 뒤 pen interactive --in design/enode-ux.pen --out <스크래치패드> 로 헤드리스로 열었다. MCP 는 필요 없다 — MCP 는 실행 중인
앱에 붙는 통로이고 CLI 헤드리스는 파일을 직접 연다. save() 를 안 불렀고
--out 을 저장소 밖으로 두어 원본을 안 건드렸다 (START.md §5 — design/
은 진행자만 고친다).

C3 자원 회수 · 활성 (pxDrainActive)

   제목        자원 회수 (drain) · 활성
   모드 배지    graceful                          걸 때 고른 모드를 그대로 보인다
   상태 1      지금 도는 작업 없음 — 되찾은 상태
   상태 2      이 기계는 온전히 당신 것이다. 풀기 전까지 새 임대가 안 온다
   버튼        drain 풀기 · 함대로 돌려주기        단 하나다.  모드 라디오가 없다
   각주        푸는 순간 매칭 후보로 돌아온다 — 대기 중인 Run이 있으면 곧바로
              배정될 수 있다.  걸 때 고른 모드(graceful · at-boundary)는
              풀기 전까지 바뀌지 않는다

각주의 마지막 문장이 §4.3 의 라우트 설계를 정정했다. 모드 전이를 라우트가
받지 않는다.

C3 은 「자유」 국면(C5)만 그렸다. 「진행 중」 국면(C4)의 상태 문구는 그림에
없으므로 구현이 유도한다 — 임대가 남아 있으면 도는 Run 과 그 단계를 적고
「경계를 기다린다」로 쓴다. 근거는 C4 배지의 문구와 decisions.md 1절의
at-boundary 정의다.

C4 · C5 배지 — 문구가 design/README.md 와 다르다

   C4  draining · 진행 중            채운 배지.  $state-drain 바탕에 어두운 글자
   C5  draining · 대기 중(owner)     테두리 배지.  $state-drain 글자

design/README.md §2.2 는 C5 를 「draining · 대기 중」으로 적었으나 원본의
글자는 draining · 대기 중(owner)
다. (owner) 가 붙는 것이 더 정확하다 —
무엇을 기다리는지가 「소유자가 풀기」임을 그 자리에서 말한다. 원본을 따른다.

S1 카드의 배지는 지금 그냥 draining 이고 범례도 draining 이다. 구현이
C4 · C5 를 조건부로 끼운다
(design/README.md §3). 범례는 그대로 둔다 —
범례는 색의 뜻이고 배지가 국면을 말한다.

8.6 원본이 코드와 어긋난 자리 둘 — 규칙이 이긴다

.pen 을 열어 보이는 글자를 직접 확인했다. 둘 다 design/README.md §6 의
「API 값을 보이는 자리는 원문 enum 그대로」가 판정한다.

화면 원본의 보이는 글자 코드의 값 구현
S1 Run 필터 · 배지 SUCCEEDED SUCCEEDED 맞다. 프레임 이름만 Filter SEALED 다 — §6 이 적어 둔 그것
S1b Run 필터 · 배지 SEALED SUCCEEDED SUCCEEDED 로 간다. §6 의 괄호는 S1 만 덮었고 S1b 는 보이는 글자까지 SEALED 다. SEALED 는 Run 상태가 아니다 (ADR-005 의 별도 행위)
S2 단계 배지 · 범례 RUNNING (「실행 중 RUNNING」) CLAIMED CLAIMED 로 간다. steps.state 어휘는 PENDING · CLAIMED · DONE · FAILED · SKIPPED · ASKED 이고 RUNNING 은 Run 의 상태다. 단계에 RUNNING 은 없다

S2 범례가 넷(DONE · RUNNING · PENDING · SKIPPED)뿐이다. 여섯 중
CLAIMED(범례의 RUNNING 자리) 말고 FAILED 와 ASKED 가 빠져 있다.
구현이 둘을 더한다 — 안 더하면 실패한 단계와 되묻는 단계가 색 없이 뜬다.

.pen 자체의 스키마 흠 하나

여는 동안 검증 경고가 하나 났다.

   /children/10/children/3/textGrowth
   expected one of: "auto", "fixed-width", "fixed-width-height", got "auto-height"

children[10] 이 C3 이고 그 children[3] 이 각주 텍스트다. 고치지 않았다 —
design/ 은 진행자만 고친다 (START.md §5). 파일은 그래도 열리고 읽힌다.