Version: v1.0.0 Status: Final (Phase 4) Last updated: 2026-04-24 Authors: developer (lead), test-engineer (review) Scope: ota v0.1.0 MVP (Read-Only)
| 버전 | 날짜 | 변경 | 작성자 |
|---|---|---|---|
| v0.1.0-draft | 2026-04-24 | 초안 (Phase 4) | developer |
| v1.0.0 | 2026-04-24 | D-A 합의 반영: Port 위치 internal/domain/ports.go 확정. depguard 린트 규칙 명시. TESTING v0.9.1 정합성 확보. |
developer |
| v1.0.1 | 2026-04-24 | Phase 6 결정 반영: MVP는 직접 net/http, SDK는 v0.2+ 옵션 (§6.5). team-lead 승인. |
developer |
| v1.1.0 | 2026-06-17 | REQ-W01 Users 프로필 편집 addendum 통합. §6.8 신설(internal/tui/shared/form/ — 재사용 가능 폼 위젯 패키지, OI-W5 Option C). §6.1에 UserProfilePatch + UsersPort.UpdateProfile 추가. §7.4 신설(Edit/Form 데이터 흐름). §9.5 신설(폼 에러 매핑 — BadRequestError.Causes → field-level inline). §10.1 wiring에 form widget 미포함(stateless). §13.4(write surface 확장 플레이북) 추가. 기존 read-path 무영향. |
developer |
ota (Okta TUI)는 운영자가 Okta Workforce Identity 테넌트를 터미널에서 읽기 전용으로 탐색하도록 돕는 단일 바이너리 CLI 애플리케이션이다. k9s의 UX(리소스 리스트 → 상세 → 드릴다운, : 커맨드, / 검색, Vim 단축키)를 Okta 5개 리소스(Users, Groups, Group Rules, Policies, System Logs)에 적용한다.
핵심 특징:
- 단일 실행 파일: 외부 데몬·서버 없음. 설정 파일 + 환경변수만으로 동작.
- Read-Only: v0.1 MVP는 모든 mutation 호출 금지. 쓰기 경로 자체가 코드에 없다.
- 도메인 플러그인 가능성: Okta 외 IdP(Entra, JumpCloud 등)를 향후 대체 가능하도록 Port/Adapter로 격리.
근거: PRD §1, §4.
| 목표 | 동인 (REQ/근거) |
|---|---|
| 테스트 가능성 | 도메인 규칙·어댑터·TUI를 각기 독립 테스트. teatest로 화면 전환까지 검증. |
| 유지보수성 | 작은 패키지, 명시적 의존성 방향. 새 리소스 추가가 한 디렉토리 안에서 완결. |
| 낮은 지연 | 키 입력 렌더 < 16ms, 리스트 초기 렌더 < 500ms — PRD §6.1. |
| 보안 | 토큰·PII 누출 방지가 아키텍처 수준에서 강제 — PRD §6.2, REQ-C05. |
| 확장성 | IdP 도메인 교체, 새 Okta 리소스 추가 비용이 선형. PRD §12. |
| 관측성 | 상관 ID 기반 디버그 로그, rate limit 상태의 런타임 가시성 — REQ-O01, REQ-E01. |
- Write / mutation (v0.2+)
- 멀티 창 / split pane (터미널 단일 뷰)
- 백그라운드 데몬 / 상주 서비스
- 다중 tenant 동시 조회 (동시 하나의 활성 프로필)
- 모바일 · 웹 UI
- 플러그인 핫리로드 (v0.3+)
- i18n (영어만, PRD §6.4)
선택: Hexagonal (Ports & Adapters) + Elm Architecture(Bubbletea) 위에 얹음.
근거:
- Okta SDK/HTTP는 밖으로 밀어낸다. 도메인 규칙(예: "Group Rule의 INVALID 상태는 경고")이 SDK 타입에 얽매이면 SDK 버전 업그레이드마다 도메인 테스트가 흔들린다.
- TUI도 Adapter로 본다.
tea.Model은 inbound adapter(사람의 입력을 도메인으로 변환). Okta SDK는 outbound adapter(도메인 요청을 외부로 변환). - 확장성.
EntraPort,JumpCloudPort구현 추가만으로 도메인이 재사용 가능. - 테스트 가능성. 각 어댑터는 인터페이스(Port) 구현이므로 상위 레이어 테스트는 순수 fake로 가능.
| 대안 | 탈락 이유 |
|---|---|
| Layered (3-tier) | UI가 직접 도메인과 외부 시스템을 호출하게 되어 Okta SDK 타입이 UI로 누출됨. |
| Clean Architecture (uncle Bob) | Hex과 사실상 동형이나 레이어 개수가 많고 "use case" 인터페이스 폭발. 프로젝트 규모 대비 오버엔지니어링. |
| DDD Tactical Patterns (Aggregate/Repository 풀 세트) | ota는 읽기 전용 + CRUD 없음 → Aggregate/Transaction 개념이 과잉. Repository 패턴만 차용. |
| Bubbletea만 순수 MVC | 화면마다 Okta SDK 호출이 흩어짐 → 테스트 · 재사용 난항. |
┌─────────────────────────────────────────────────────────────────┐
│ cmd/ota │
│ flag 파싱, config 로드, 의존성 조립, tea.Program 실행 │
└───────────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────────▼─────────────────────────────────┐
│ internal/app (inbound adapter — TUI shell) │
│ App Shell Model (Router), 전역 단축키, overlay 관리 │
└───────────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────────▼─────────────────────────────────┐
│ internal/tui/<resource> (inbound adapter — Screen Models) │
│ UsersListModel, UserDetailModel, PoliciesTypeSelectModel, ... │
│ tea.Model composition · Msg·Cmd 기반 상태 전이 │
└───────────────────────────────┬─────────────────────────────────┘
│ Service API (use cases)
┌───────────────────────────────▼─────────────────────────────────┐
│ internal/service │
│ UsersService, GroupsService, RulesService, │
│ PoliciesService, LogsService (tail iterator) │
└──────┬─────────────────────────────────────────────────┬────────┘
│ │
│ requires (Port interfaces) │ uses
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────┐
│ internal/domain │ │ internal/config │
│ (pure) │ │ (Config loader, profile)│
│ User, Group, Rule, │ └─────────────────────────┘
│ Policy, LogEvent, Err*, │
│ Iterator[T], PageInfo │
└──────────────────────────┘
▲
│ implements Port
│
┌──────┴───────────────────────────────────────────────────────────┐
│ internal/okta (outbound adapter) │
│ Client, UsersAdapter, GroupsAdapter, ..., RateLimitMonitor, │
│ Pagination iterator, errorCode → domain.Err 매퍼 │
└──────────────────────────────────────────────────────────────────┘
│ okta-sdk-golang/v5 + net/http
▼
┌───────────────┐
│ Okta Core API │
└───────────────┘
| 레이어 | 단일 책임 | 금지 |
|---|---|---|
cmd/ota |
부팅 및 조립 (wiring) | 비즈니스 로직 |
internal/app |
화면 라우팅·오버레이·전역 단축키 | 도메인 로직 · 직접 HTTP |
internal/tui/<r> |
리소스별 Screen Model·Msg 처리·뷰 렌더 | Okta SDK import · 직접 HTTP |
internal/service |
유스케이스(여러 Port 조합·캐시·정책) | tea.* 참조 · SDK import |
internal/domain |
순수 타입·불변식·필터 매처 | 모든 외부 import (stdlib 외) |
internal/okta |
SDK 호출·페이지네이션·rate limit·에러 매핑 | 도메인 규칙 |
internal/config |
YAML 로드·경로 해결·검증 | 런타임 상태 |
cmd → app → tui/* → service ──┐
│ domain.*Port 인터페이스
domain ◄────┤◄── okta (implements Port)
│
config ◄──── cmd (loads), app (reads)
keys, mask, logger, clock ◄── 횡단 유틸 (upward 허용)
규칙:
internal/domain은 표준 라이브러리 외 어떤 것도 import 금지. Port 인터페이스·쿼리 타입도 여기 선언.internal/tui/*는internal/service,internal/domain,internal/keys,internal/mask만 import.internal/service는internal/domain만 (외부 의존 없음). SDK import 금지.internal/okta는internal/domain+ SDK.domain.*Port를 구현하지만 Service/TUI는 몰라도 됨.- 순환 의존 금지 — 컴파일러가 잡음.
Go 관용구 준수: "Accept interfaces, return structs". 인터페이스는 구현체가 아닌 값 객체·도메인 경계 패키지에 둔다 (Service·TUI 공통 소비). 어셈블리(wiring)는 cmd/ota/wire.go에서 명시적으로.
린트 강제 (depguard):
internal/app/**,internal/tui/**,internal/service/**:github.com/okta/okta-sdk-golang/**import 금지.internal/domain/**: stdlib 외 import 금지.
책임: Okta 리소스 5종의 ota-내부 표현과 불변식.
주요 타입:
type User struct {
ID string // 00uXXXXXX
Login string // profile.login (email)
Status UserStatus // ACTIVE, SUSPENDED, ...
Profile UserProfile // displayName, email, mobilePhone, custom...
LastLogin *time.Time
StatusChanged *time.Time
// ...
}
type UserStatus string // 상수: STAGED, PROVISIONED, ACTIVE, SUSPENDED,
// LOCKED_OUT, PASSWORD_EXPIRED, DEPROVISIONED
type Group struct {...}
type Rule struct {...}
type Policy struct {...}
type LogEvent struct {...}
type Iterator[T any] interface {
Next(ctx context.Context) (T, bool, error) // (item, hasMore, err)
}
type PageInfo struct {
Cursor string
Limit int
}
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
ErrRateLimited = errors.New("rate limited")
ErrTokenInvalid = errors.New("token invalid")
ErrBadRequest = errors.New("bad request")
ErrOktaServer = errors.New("okta server error")
ErrNetwork = errors.New("network error")
ErrFeatureDisabled = errors.New("feature disabled")
)핵심 원칙:
- 불변식은 type invariant로 강제 (생성자만 공개, 필드는 검증 후 채움).
- 어떤 SDK 타입도 여기 없다. 매핑은
internal/okta/*가 담당. - 파일 I/O·네트워크·시간·난수 직접 사용 금지. 필요하면 인터페이스로.
Write Patch 타입 (REQ-W01, v0.2):
// internal/domain/user_patch.go — v0.2 REQ-W01
//
// UserProfilePatch는 partial-merge 시맨틱의 입력 타입이다.
// nil 포인터 = "필드 미변경" (JSON body에서 omit).
// 명시적 클리어(null 전송)는 MVP 제외 — D-W13/도메인 §1.2.
// login 필드는 의도적으로 제외 (D-W2 read-only 잠금).
type UserProfilePatch struct {
FirstName *string
LastName *string
DisplayName *string
NickName *string
Email *string
Title *string
Division *string
Department *string
EmployeeNumber *string
MobilePhone *string
SecondEmail *string
}
// IsEmpty reports whether every field is nil — D-W13 guard.
// 서비스는 Empty patch에서 ErrEmptyPatch를 반환하여 API 호출을 차단한다.
func (p UserProfilePatch) IsEmpty() bool { /* all fields nil */ }
var ErrEmptyPatch = errors.New("empty patch: no fields to update")*string포인터 패턴 선택 근거: nil/value 구분이 명시적이며 JSON marshalomitempty와 자연스럽게 정합. struct embedding 대비 검증·dirty 추적이 간결.ErrEmptyPatch는 도메인 sentinel. 서비스 레이어가 0 변경 저장을 거부 (D-W13). TUI는 Save 버튼 자체를 disable하지만, 방어적 가드를 도메인에 둔다.
근거: PRD §7.7 에러 매핑, REQ-R01~R05의 필드 명세, REQ-W01 AC-2/AC-4.2, 도메인 §1.2 (partial-merge semantics).
책임: 도메인을 조합한 운영자 관점 유스케이스. internal/domain/ports.go에 선언된 Port 인터페이스를 소비한다.
Port 위치 결정 (test-engineer와 합의, 2026-04-24):
internal/domain/ports.go에 모든 Port 인터페이스 선언.- 근거: Service와 TUI 둘 다 Port를 참조(소비자가 여러 레이어). 중립 위치 필요.
- Adapter(
internal/okta)는 이미domain타입을 매핑하므로 Port 선언이 같이 있어도 순환 의존 없음. depguard린트로app/tui/service에서 SDK 직접 import 차단이 단순해짐.
주요 인터페이스 (Port):
// 중립 위치 — internal/domain/ports.go
package domain
type UsersPort interface {
List(ctx context.Context, q UsersQuery) (Iterator[User], error)
Get(ctx context.Context, idOrLogin string) (User, error)
ListGroups(ctx context.Context, userID string) ([]Group, error)
ListFactors(ctx context.Context, userID string) ([]Factor, error)
// Lifecycle (write) operations (v0.1.x — issues #125, #187).
// ResetPassword, Unlock, ResetFactors, Activate, Deactivate,
// ExpirePassword, Delete — see internal/domain/ports.go.
// UpdateProfile (REQ-W01, v0.2) issues a partial-merge update via
// POST /api/v1/users/{userID}. Only non-nil fields in `patch` are
// emitted in the request body (omit-on-nil). Returns the updated
// domain.User from the response body so callers can refresh
// list/detail caches (compensates for last-write-wins races —
// 도메인 §5.2).
//
// When patch.IsEmpty(), returns ErrEmptyPatch without an API call
// (D-W13 — empty-patch guard).
//
// Errors are domain sentinels per ARCHITECTURE §9.2 + §9.5:
// ErrBadRequest (E0000001) — *BadRequestError with field-level Causes
// ErrTokenInvalid (E0000004 / E0000011) — token rotation needed
// ErrForbidden (E0000006) — 'Manage user profiles' missing
// ErrNotFound (E0000007) — user deleted between GET and POST
// ErrFeatureDisabled (E0000038) — schema violation
// ErrRateLimited (E0000047 / 429) — *RateLimitedError with RetryAfter
UpdateProfile(ctx context.Context, userID string, patch UserProfilePatch) (User, error)
}
type GroupsPort interface { ... }
type RulesPort interface { ... }
type PoliciesPort interface {
List(ctx context.Context, policyType PolicyType, pi PageInfo) (Iterator[Policy], error)
Get(ctx context.Context, id string) (Policy, error)
Rules(ctx context.Context, policyID string) ([]PolicyRule, error)
}
type LogsPort interface {
Search(ctx context.Context, q LogsQuery) (Iterator[LogEvent], error)
}쿼리 타입(UsersQuery, LogsQuery 등)도 domain에 둔다 — Port가 사용하는 값 객체이므로 같은 경계 안.
주요 서비스:
type UsersService struct {
port domain.UsersPort
cache *ttl.Cache
clock clock.Clock
log *slog.Logger
}
func NewUsersService(p domain.UsersPort, opts ...Option) *UsersService { ... }
func (s *UsersService) Search(ctx context.Context, q domain.UsersQuery) (domain.Iterator[domain.User], error) {
// 1) 쿼리 정규화 ( /u -> q="u" 등)
// 2) 캐시 조회 (30s TTL, REQ-E01 AC-6)
// 3) port.List 호출
// 4) cache + iterator wrap
}원칙:
- Service는
domain.*Port인터페이스만 참조. 구현체는cmd/ota/wire.go에서 주입. - 캐시는 Service 책임 (어댑터가 아님). 캐시 무효화 API 제공 (
:refresh,:profile전환). - 로깅: Service 수준의 "유스케이스 경계" 로그(
user.list.started,user.list.cache_hit). 구조화 필드.
근거: PRD REQ-E01 AC-6, REQ-U04, REQ-R01~R05.
책임: 최상위 tea.Model. 화면 간 전환·오버레이·전역 단축키·커맨드 팔레트.
구조 (TUI_DESIGN §5.1 기반):
type Model struct {
active screen.ID // users_list, user_detail, ...
screens map[screen.ID]tea.Model // lazy 생성
overlay overlay.Model // cmd palette / help / confirm / errors / about
statusBar statusbar.Model
profile string // active profile name
rateLimits ratelimit.Snapshot // 최신 관찰값
errBuf errorBuffer // :errors
cancel func() // 현재 Cmd 취소용
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 1) 전역 키: :/?Esc Ctrl-c → overlay or quit confirm
// 2) RateLimitObservedMsg → m.rateLimits 갱신
// 3) ProfileSwitchedMsg → 전체 스크린 리셋 + 캐시 무효화
// 4) 그 외 → active screen Update
}오버레이 합성:
오버레이는 active screen 위에 그려지고, 키 입력을 가로챈다. 단 Esc는 항상 오버레이 dismiss로 우선 처리.
근거: TUI_DESIGN §5.1, §2.2, REQ-U01~U07.
구조 예 (users):
// internal/tui/users/list.go
package users
type ListModel struct {
svc *service.UsersService // 서비스를 직접 주입. 테스트는 domain.UsersPort fake를 서비스에 주입.
table table.Model // bubbles/table
filter filter.Model // inline / prompt
search string // :search expr
loading bool
items []domain.User
page pageCursor
err error
keys keys.ResolvedMap
}
func (m ListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case UsersLoadedMsg:
m.items = append(m.items, msg.Users...)
m.page = msg.Next
m.loading = false
return m, nil
case ErrorMsg:
m.err = msg.Err
m.loading = false
return m, nil
}
}Msg 계약:
각 Screen Model은 자기 화면에서 발생하는 Msg 타입을 선언하되, 전역 Msg(RateLimit, Error, Toast)는 internal/app/msg.go에 공통 정의.
책임:
- Okta HTTP API 호출 (MVP: 직접
net/http; SDK는 v0.2+ 옵션) - 도메인 타입 매핑 (
wireUser→domain.User) - Link 헤더 기반 페이지네이션을
domain.Iterator[T]로 제공 - Rate Limit 헤더 관찰 →
ratelimit.Monitor로 전달 - Okta
errorCode→domain.Err*매핑 (PRD §7.7)
MVP 구현 메모 (Phase 6, 2026-04-24): MVP는 net/http 직접. okta-sdk-golang/v5는 go.mod에만 남아있으며 런타임에서는 사용하지 않는다. 이유는 SDK의 host 주입 방식이 ota의 httptest 시나리오 드라이버와 잘 맞지 않아서. 얇은 Client 래퍼(internal/okta/client.go)가 SSWS 인증·429 Retry-After 재시도·rate limit 관찰·errormap 통합을 한 곳에서 수행하므로 Port 경계는 동일하게 유지된다. SDK는 v0.2에서 per-endpoint 선택적 전환 대상 (오픈 이슈).
구성:
type Client struct {
baseURL string
token string
http *http.Client // 주입 가능 (httptest 바인딩)
monitor *ratelimit.Monitor
log *slog.Logger
clock clock.Clock
maxRetries int // REQ-E01 AC-2: 기본 3
}
func NewClient(ctx context.Context, cfg Config, opts ...Option) (*Client, error) { ... }
type UsersAdapter struct { client *Client }
// implements domain.UsersPort
func (a *UsersAdapter) List(ctx context.Context, q domain.UsersQuery) (domain.Iterator[domain.User], error) {
// 1) q → query string (Limit, Q, Search, Filter, After)
// 2) doGet → 429 자동 재시도 + rate limit 헤더 관찰 + errormap
// 3) pagedIterator[T] 반환 (Next는 Link 헤더 next 자동 follow)
}System Logs 예외: logs.debugContext.debugData 등 free-form 필드는 json.RawMessage로 보존하여 도메인 타입에 그대로 전달.
근거: 도메인 §8.3, §8.4, PRD §11.4, Phase 6 구현 보고서.
책임: YAML 파싱, XDG 경로 해결, 프로필 스위칭, 검증.
구조:
type Config struct {
Profiles map[string]Profile `koanf:"profiles"`
UI UIConfig `koanf:"ui"`
Keybindings map[string]string `koanf:"keybindings"` // key_id -> keybind str
Logs LogsConfig `koanf:"logs"`
Debug bool `koanf:"debug"`
}
type Profile struct {
OrgURL string `koanf:"org_url"`
APITokenEnv string `koanf:"api_token_env"`
DefaultLogFilter string `koanf:"default_log_filter"`
}로드 순서 (REQ-C04):
--config <path>flag$XDG_CONFIG_HOME/ota/config.yaml~/.config/ota/config.yaml- default (메모리)
토큰 해결 순서 (REQ-C04):
--token-env=<VAR>/ profile.api_token_envOKTA_API_TOKEN+OKTA_ORG_URL- 대화식 프롬프트 (마스킹, 메모리 only)
| 패키지 | 책임 |
|---|---|
internal/keys |
Key ID(nav.down 등) 상수, 기본 맵, 사용자 override resolver. |
internal/mask |
PII 마스킹 (mask.Phone, mask.Email). 로거·뷰 공통 사용. |
internal/logger |
slog 설정. file handler + masking middleware. 상관ID(session_id). |
internal/clock |
Clock 인터페이스 + realClock / FakeClock. tail 폴링·토큰 수명 추정 테스트용. |
internal/version |
ldflags 주입 값. :about 노출. |
책임: Bubbletea Screen 단위에서 사용하는 다중 필드 입력 폼의 도메인-agnostic 추상화. REQ-W01 Users Edit이 첫 사용처이며, v0.2 lifecycle write(activate / deactivate reason input 등)가 재사용한다.
OI-W5 결정 (Option C — 절충 추출): TUI Design §10.1 권고를 그대로 채택. 패키지가 owning 하는 책임을 명시적으로 한정:
| 추출 (재사용) | 비추출 (도메인 특화) |
|---|---|
FieldSpec (필드 메타) |
각 mutation의 FieldSpec 카탈로그 (예: Users 11필드 목록은 tui/users/) |
Form 모델 (Model-Update-View) |
fetch / save Cmd, navStack push 로직 |
| dirty 추적 (snapshot vs current) | dirty UI 카피(N changes 문구는 form, 단축키 hint는 screen) |
client-side validation 엔진 (Validate 함수형 인터페이스) |
도메인 검증 규칙 자체 (이메일 정규식·필수 표시) |
ErrorMapper (BadRequestError → field-level inline + footer) |
도메인-특화 매핑(예: secondEmail ↔ Secondary Email 라벨) |
DiscardConfirm 보조 모달 렌더러 |
OverlayKind 등록 (App Shell이 보유) |
| key handling (Tab/Shift-Tab/Ctrl+S/Esc/Alt+m intercept) | screen 외부 전이 (popNav, UserUpdatedMsg 발송) |
핵심 인터페이스 (v0.2 초안):
// internal/tui/shared/form/spec.go
package form
// FieldKind controls input rendering and validation hooks.
type FieldKind int
const (
KindText FieldKind = iota
KindEmail // loose *@*.* hint
KindPhone // E.164 advisory only
KindReadOnly // displayed but not editable (e.g., login)
)
// FieldSpec is the static description of one form input. Screens build
// a slice of FieldSpec when they construct a Form.
type FieldSpec struct {
Key string // canonical key — matches BadRequestError.FieldError.Field
Label string // display label, e.g., "First Name"
Kind FieldKind
Required bool // adds "*" / required validator
PII bool // routes through mask.* + Alt+m toggle
Section string // group header text (e.g., "Identity")
Hint string // advisory text rendered under input (no error styling)
MaxLen int // 0 = unbounded (server-side enforcement)
}
// Field is the live state of one input in a running Form.
type Field struct {
Spec FieldSpec
Original string // snapshot at form load — drives dirty diff
Current string // editing buffer (bubbles/textinput Value())
InlineError string // last server/client error attached to this field
Masked bool // PII mask state
// unexported textinput.Model lives behind a getter
}
// Form is the screen-agnostic tea.Model.
type Form struct { /* fields, focus, errorMapper, ... */ }
func New(specs []FieldSpec, initial map[string]string, opts ...Option) Form
// Bubbletea contract — Update/View are pure Elm.
func (f Form) Init() tea.Cmd { return nil }
func (f Form) Update(msg tea.Msg) (Form, tea.Cmd)
func (f Form) View() string
// Inspection helpers used by the owning screen.
func (f Form) Dirty() int // count of changed fields
func (f Form) DirtyFields() []string // ordered keys
func (f Form) Validate() (ok bool, firstInvalid string)
func (f Form) Snapshot() map[string]string // current buffer per key
func (f Form) Diff() map[string]string // changed-only (for patch build)
func (f Form) SetSaving(on bool) Form // dim inputs, disable Esc
func (f Form) ApplyServerErrors(causes []domain.FieldError) Form // §9.5
// Messages owned by Form (subset — full list in §10 tui/shared/form/msgs.go):
// form.FieldFocusedMsg / form.FieldBlurredMsg
// form.PIIToggleMsg (Alt+m)
// form.SaveRequestedMsg // emitted on Ctrl+S when Dirty()>0 && validation passes
// form.DiscardRequestedMsg // emitted on Esc when Dirty()>0소유자 책임 분리:
- Form (재사용 위젯) — 입력 버퍼·dirty 추적·검증·inline error 렌더·키 가로채기. tea.Cmd 발사 안 함.
- Screen (예:
tui/users/edit.go) — Form을 embed 하고,form.SaveRequestedMsg/form.DiscardRequestedMsg를 자기 Update에서 받아 fetch/save tea.Cmd 발사, navStack pop, toast 발송. - App Shell —
ScreenUserEdit/OverlayDiscardConfirm등록 + 메시지 라우팅.
Bubble 컴포넌트 매핑 (구현 hint — TUI Design §2.8):
| Form 슬롯 | 구현 |
|---|---|
| 각 Field 입력 박스 | bubbles/textinput.Model 인스턴스 (KindReadOnly는 plain text 렌더) |
| 폼 본문 스크롤 | bubbles/viewport — H < 30일 때만 활성 |
| Saving spinner | bubbles/spinner — Saving 상태에서만 활성 |
| 섹션 헤더 + divider | shared/styles.go의 tokens.Header |
| Discard 모달 | shared/modal.go 패턴 재사용 + Form의 DirtyFields() 표시 |
금지:
- Form은 도메인 타입(
domain.User,domain.UserProfilePatch)을 import 하지 않는다. screen이Form.Diff()결과(map[string]string)를 받아 patch struct를 조립. 도메인-agnostic 유지의 핵심 가드. - Form은
tea.Cmd를 발사하지 않는다. 모든 사이드 이펙트(fetch/save)는 owning screen이 트리거. - Form은
service.*또는internal/okta를 import 하지 않는다.
근거: REQ-W01 AC-1~AC-10, TUI Design §10.1 (OI-W5 Option C), 도메인 §1.2 (omit-on-nil).
[User] j↓ on UsersListModel
│
▼
Screen.Update(tea.KeyMsg{j}) ── nop (moves cursor) ──► view re-render
│
[User] /{query}{Enter} ────────► Screen.Update(SearchMsg{q})
│
returns tea.Cmd{fetchUsers(ctx, svc, q)}
│
┌────────────────────── tea.Cmd goroutine ─────────────────────┐
│ svc.Search(ctx, q) │
│ └► cache miss │
│ └► port.List(ctx, q) │
│ └► UsersAdapter.List │
│ └► sdk.UserAPI.ListUsers(ctx).Q(q).Execute() │
│ └► HTTP GET /api/v1/users?q=... │
│ └► parse Link header → pageCursor │
│ └► RateLimitMonitor.Observe(resp.Header) │
│ └► err := mapOktaErr(apiErr) || nil │
│ └► domain.Iterator[User] 반환 │
│ └► domain.Iterator wrap (cache on Next) │
│ └► iterator 반환 │
│ return UsersLoadedMsg{iter, page} or ErrorMsg{err} │
└──────────────────────────────────────────────────────────────┘
│
▼
Screen.Update(UsersLoadedMsg) ──► 상태 갱신 + view
User presses f (enter tail)
│
▼
LogsListModel.Update(ToggleTailMsg) → Cmd{tea.Tick(7s)}
│
▼
Tick → Cmd{fetchLogs(ctx, svc, LogsQuery{since: lastPublished+1ms})}
│
▼
Msg{LogsPolledMsg{events, newSince}} → Append + re-tick
│
429 ERRoutcome │
▼ │
RateLimitErrorMsg → Pause (UI badge) → tea.Tick(Retry-After+jitter)
│
recover → resume with same `since` (hole-free, REQ-E01 AC-3)
User :profile prod
│
▼
CmdPalette.Update → CmdExecutedMsg{"profile", "prod"}
│
▼
App.Model.Update → ProfileSwitchStartedMsg
└► 즉시 토스트: "Switching to prod… (invalidating cache)"
└► Cmd{reinit(ctx, "prod")}
│
▼
- services.reset() // 캐시 clear
- okta.Client.reinit() // 새 token + org_url
- screens 재생성
- ProfileSwitchedMsg → App 갱신, 토스트 "Switched to prod"
ota의 첫 profile-mutation 데이터 흐름. v0.2 lifecycle write의 모범 구현체 역할.
| Surface | Entry msg | Load Cmd | Save Cmd | Result msg | Adapter call |
|---|---|---|---|---|---|
| REQ-W01 Users Edit | OpenUserEditMsg{ID} |
fetchUserForEdit(ctx, svc, id) (= svc.Get) |
saveUserProfile(ctx, svc, id, patch) (= svc.UpdateProfile) |
UserUpdatedMsg{User} (on 200) |
UsersPort.UpdateProfile → POST /api/v1/users/{id} (partial-merge) |
| (future) Group Rule Activate-with-Reason | TBD | (none) | runRuleAction(...) |
RuleActionDoneMsg |
GroupRulesPort.Activate |
| (future) Lifecycle Deactivate-with-Reason | TBD | (none) | runUserAction(...) |
RefreshScreenMsg |
UsersPort.Deactivate |
[User] e on UsersList row / UserDetail any tab
│
▼
List/Detail.Update(tea.KeyMsg{e}) → emit OpenUserEditMsg{ID: selectedID}
│
▼
App.Update → pushNav(ScreenUserEdit)
│
▼
editmodel.Init() → Cmd{fetchUserForEdit(ctx, svc, id)}
│
┌────────────────────── tea.Cmd ────────────────────────────────┐
│ user, err := svc.Get(ctx, id) │
│ └► port.Get → GET /api/v1/users/{id} (latest snapshot, AC-1.3)│
│ return UserLoadedForEditMsg{User: user} or ErrorMsg{...} │
└───────────────────────────────────────────────────────────────┘
│
▼
editmodel.onLoaded → state=editing,
embedded form.New(specs, initial={...11 fields from user.Profile})
snapshot 저장
[User] 입력 (textinput keystrokes)
│
▼
form.Update(KeyMsg) → field.Current 변경 → dirty diff 갱신
view 재렌더 (dirty marker `*`, footer "N changes")
[User] Ctrl+S (Dirty > 0 && Validate ok)
│
▼
form emits form.SaveRequestedMsg
│
editmodel.onSaveRequested → state=saving,
patch := buildPatch(form.Diff()) // 7.4.3 참조
Cmd{saveUserProfile(ctx, svc, id, patch)}
│
┌────────────────────── tea.Cmd ────────────────────────────────┐
│ updated, err := svc.UpdateProfile(ctx, id, patch) │
│ └► port.UpdateProfile → POST /api/v1/users/{id} (partial) │
│ └► JSON body 안에 nil이 아닌 필드만 포함 │
│ └► 응답 본문 → domain.User 재매핑 │
│ └► errormap.FromResponse → domain.Err* 또는 nil │
│ return UserUpdateSucceededMsg{User: updated} │
│ or UserUpdateFailedMsg{Err: err} │
└───────────────────────────────────────────────────────────────┘
│
▼
editmodel.onSucceeded → popNav() + broadcast UserUpdatedMsg{User} + toast Success
cache 갱신: UsersList/UserDetail이 UserUpdatedMsg 수신 시 자기 캐시 patch (AC-4.5)
// internal/tui/users/edit.go (개념적)
func buildPatch(diff map[string]string) domain.UserProfilePatch {
p := domain.UserProfilePatch{}
if v, ok := diff["firstName"]; ok { p.FirstName = &v }
if v, ok := diff["lastName"]; ok { p.LastName = &v }
if v, ok := diff["displayName"]; ok { p.DisplayName = &v }
if v, ok := diff["nickName"]; ok { p.NickName = &v }
if v, ok := diff["email"]; ok { p.Email = &v }
if v, ok := diff["title"]; ok { p.Title = &v }
if v, ok := diff["division"]; ok { p.Division = &v }
if v, ok := diff["department"]; ok { p.Department = &v }
if v, ok := diff["employeeNumber"]; ok { p.EmployeeNumber = &v }
if v, ok := diff["mobilePhone"]; ok { p.MobilePhone = &v }
if v, ok := diff["secondEmail"]; ok { p.SecondEmail = &v }
return p
}키 문자열(firstName 등)은 Okta API field 이름과 동일하게 유지. FieldSpec.Key ↔ BadRequestError.FieldError.Field도 동일 문자열이라 §9.5의 매핑이 자동.
| HTTP | 처리 | TUI 시각화 |
|---|---|---|
400 BadRequestError |
editmodel.onFailed → form.ApplyServerErrors(causes) |
field-level inline error + footer fallback (§9.5) |
401 ErrTokenInvalid |
popNav 안 함, form 유지 + Draft 보존 | toast "Token invalid. Rotate and retry." |
403 ErrForbidden |
form 유지 + Draft 보존 | toast "Insufficient permissions: Manage user profiles" |
404 ErrNotFound |
popNav + RefreshScreenMsg 발송 |
toast "User no longer exists. Refreshing list." (AC-6.4 — 유일한 close 사유) |
429 RateLimitedError |
form 유지, retry tick (REQ-E01 AC-2) | footer "Rate limited. Retrying in Ns…" |
5xx ErrOktaServer |
form 유지 | footer "Okta service error. Retry?" |
ErrEmptyPatch (방어) |
Save trigger 자체를 차단 (TUI에서 Ctrl+S disable) — D-W13 | footer "No changes to save" |
서버 응답의 domain.User를 UserUpdatedMsg{User}로 broadcast → UsersList / UserDetail이 받아 자기 캐시의 해당 entry를 교체한다. 이는 다른 admin이 동시에 변경한 필드가 응답에 반영되었을 때 그 결과를 자연스럽게 흡수하는 효과 (last-write-wins의 인지 가능 부분).
재시도 금지: Save Cmd는 멱등성이 보장되지 않으므로(partial-merge는 본질적으로 idempotent하지만
Ctrl+S로 명시적 재시도. 429만 REQ-E01 AC-2의 1회 자동 재시도 적용.
- tea.Cmd 내부 only. Screen Model은 goroutine을 직접 spawn 하지 않는다.
- tea.Cmd는 호출 시점에 1회 실행되어 Msg를 돌려준다. 장기 실행(tail)은
tea.Tick+ 재귀 Cmd로 표현. - 예외: Okta Adapter 내부에서 병렬 페치가 필요한 경우(없는 것을 권장, 순차) — Okta는 병렬 페이지 요청 금지([확정], 도메인 §2.1).
- Screen Model의 상태는 그 Model이 owning. 외부는 Msg로만 접근.
- 전역 상태(Profile, RateLimits, ErrorBuffer)는 App.Model이 owning. Screen에 읽기 전용 스냅샷을 Msg로 전파.
sync.Mutex는 원칙적으로 사용하지 않는다 (Elm arch가 직렬화). 예외 발생 시 코드 주석으로 이유 명시.
- 모든 외부 I/O Cmd는
context.Context를 받는다. - Esc 키: App.Model이
cancel()호출 → 진행 중 Cmd가 cancelled. 타임아웃 + 취소 둘 다 처리. - Profile 전환 시 이전 context 전체 cancel.
- tail Cmd는 각 tick에서 새로운 context를 얻음. 이전 tick이 남아있으면 취소.
- 429 시
tea.Tick(Retry-After)로 대기. 이 기간 타이머 충돌 없음. - 리사이즈 시에도 tail은 영향 없음(별도 lane).
근거: PRD §11.4 기술 검증 필요 항목.
Okta SDK error ─► okta.mapErr(err) → domain.Err*
│
▼
service가 받아서 처리 여부 결정
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
retry (429,네트워크) 도메인 에러 반환 wrap with context
│ │ │
└──────────────────────┴──────────────────────┘
│
▼
tui: ErrorMsg → statusbar 토스트 + :errors 로그
(message = Okta errorCode 기반 문구, PRD §7.7)
| Okta errorCode | HTTP | domain.Err | 사용자 메시지 |
|---|---|---|---|
| E0000001 | 400 | ErrBadRequest | errorCauses 파싱해서 필드별 표시 |
| E0000004 | 401 | ErrTokenInvalid | "API token invalid or revoked. Rotate and retry." |
| E0000006 | 403 | ErrForbidden | "Insufficient permissions for " |
| E0000007 | 404 | ErrNotFound | "Resource not found. Refreshing list…" |
| E0000011 | 401 | ErrTokenInvalid | "Token expired or revoked" |
| E0000022 | 400 | ErrBadRequest | "Deactivate before deleting" (읽기 전용 — 정보성) |
| E0000038 | 400 | ErrFeatureDisabled | "This feature is disabled for your organization." |
| E0000047 | 429 | ErrRateLimited | 자동 재시도 (REQ-E01) |
| (그 외 5xx) | ErrOktaServer | "Okta server error. Retrying…" |
| 상황 | 처리 |
|---|---|
| 429 rate limited | 최대 3회 자동 재시도, Retry-After + jitter (REQ-E01 AC-2) |
| 5xx 서버 에러 | idempotent GET만 지수 백오프 3회 |
| 네트워크 단절 | 폴링 중단, statusbar "offline", 복구 감지 시 재개 (REQ-E03) |
| 401/403 | 재시도 없음. 사용자에게 명확 안내. |
| 404 | 리스트 refresh 트리거 |
cmd/ota/main.go가defer recover()마지막 안전망. 크래시 로그에 상관ID + 버전.- Bubbletea 프로그램이 panic 시 터미널 상태 복원 후 친절한 메시지 출력.
§9.1/§9.2의 도메인 매핑은 상태바 토스트 / inline raw 표시까지만 다룬다. REQ-W01의 폼은 한 단계 더 — BadRequestError.Causes의 field별 메시지를 해당 입력칸 바로 아래에 inline 으로 표시해야 한다(AC-6.1).
[Save Cmd 응답]
│
▼
errormap.FromResponse(resp) → error
│
├─ *RateLimitedError → editmodel 자체에서 footer 카운트다운 (§7.4.4)
├─ ErrForbidden / ErrTokenInvalid / ErrNotFound / ErrOktaServer
│ → editmodel.onFailed → toast + form 유지 (404은 popNav)
│
└─ *BadRequestError (E0000001)
│
▼
form.ApplyServerErrors(badReq.Causes) — `internal/tui/shared/form/`
│
├─ for each cause:
│ cause.Field 가 form 내 FieldSpec.Key 와 매치되면
│ → field.InlineError = cause.Summary
│ 매치 실패하면
│ → form.OtherErrors = append(..., cause.Summary)
│
└─ View 렌더:
field.InlineError → 입력 박스 아래 `! <msg>`
form.OtherErrors → 폼 footer "Other errors: …" 누적
FieldSpec.Key는 Okta API field 이름과 동일한 문자열을 사용한다 (예:firstName,lastName,mobilePhone,secondEmail).errormap.splitCause가 이미"<field>: <reason>"패턴에서<field>prefix를 추출하여domain.FieldError.Field에 저장한다. ota는 별도 파서를 추가하지 않는다.- 매핑 실패(Okta가 prefix 없이 보낸 경우, 또는 모르는 field) →
form.OtherErrors에 누적. 운영자에게는 footer로 표시되어 정보 손실 없음.
| 발생 시점 | 클리어 시점 |
|---|---|
Save 실패 후 ApplyServerErrors |
사용자가 해당 field 를 다시 수정한 직후 (AC-6.2 낙관적 UX) — form.Update가 keystroke 받으면 자동 클리어 |
| Client validation (focus-out / Ctrl+S) | 동일 — keystroke 시 클리어 |
Server-side error는 stale 가능성이 높으므로 (사용자가 값을 수정하면 의미가 사라짐) clear 정책을 단순하게: 해당 필드 keystroke 1회로 사라진다. 다음 Save가 동일 오류를 다시 재현하면 또 표시.
- prefix 매칭에 실패한 cause,
E0000038(schema 위반) 같은 폼 외 오류 →OtherErrors []string에 누적. - 폼 footer 영역 아래 collapsible 영역에 렌더 (
! Other errors:+ 줄별· <msg>). - Save Cmd 발사 직전 클리어 (새 시도에서 다시 채워질 수 있도록).
근거: REQ-W01 AC-6.1~6.3, TUI Design §6.2/§6.3 (errorCauses 파싱 의사 코드), 도메인 §6.1.
main()
├─ flag.Parse → --config, --profile, --token-env, --debug, --poll-interval
├─ config.Load(flagPath)
│ ├─ XDG 경로 resolve
│ ├─ koanf 로드 + 병합 (default → file → env)
│ └─ Validate (profile 이름, keybinding 유효성)
├─ profile := pickProfile(flag, config)
├─ token, src := resolveToken(flag, profile, env, prompt)
├─ logger := logger.New(config.Debug, sessionID)
├─ clk := clock.Real{}
├─ oktaClient := okta.NewClient(okta.Config{OrgURL: profile.OrgURL, Token: token}, ...)
├─ svcs := service.Bundle{
│ Users: service.NewUsersService(oktaClient.Users(), ...)
│ Groups: ...
│ ...
│ }
├─ app := app.New(svcs, config, keys, clk, logger)
└─ tea.NewProgram(app, opts...).Run()
토큰 결정 노출: :about 화면에 token source: env OKTA_API_TOKEN 같이 노출 (REQ-C04 AC-1).
:profile <name>이 실행되면 전체 re-init (6.3 섹션 참조).- < 2s 타겟 (REQ-C02 AC-3). 리셋 중에는 로딩 스피너 + 상태 메시지.
internal/okta/ratelimit.go의RateLimitMonitor가 모든 응답 헤더에서X-Rate-Limit-Limit/Remaining/Reset를 읽어 카테고리별 last-observed로 보관.- 카테고리:
management(users/groups/rules),logs,policies,apps(v0.2). - Monitor가 관찰 직후
RateLimitObservedMsg를 보낼 방법:- Screen Model이 Monitor의 채널을 구독하는 대신, Monitor는 단순 메모리 저장소이고 각 Cmd가 응답 후 snapshot을 Msg에 포함해 반환.
statusbar.Model이 메시지 수신 시 뱃지 렌더 (:about상세 보기 제공).
- Okta 응답의
Retry-After(초 또는 HTTP-date) 준수. wait = retryAfter ± 20% jitter.- 최대 3회 재시도. 실패 시
ErrRateLimited→ statusbar 빨간 에러.
- 첫 응답에서
X-Rate-Limit-Limit < 60관찰 시 LogsService가 tail 주기를 7s → 15s 상향. - 변경 사실은 1회성 토스트 +
:about의 "Polling interval: 15s (adaptive, observed limit 50/min)" 표시.
- UsersService/GroupsService/PoliciesService: 30s TTL 메모리 캐시.
:refreshorR→ 현재 화면 캐시 무효화.:profile전환 → 전체 캐시 무효화.
log/slogJSON handler, file sink~/.cache/ota/debug.log(REQ-O01).- 모든 이벤트에
session_id(UUID) 필드. - 레벨: ERROR/WARN/INFO/DEBUG. 기본 INFO.
--debug로 DEBUG. - 민감 필드 마스킹 middleware:
authorization헤더·api_token·secondEmail·mobilePhone같은 키를 발견하면 값***대체.
:about→ 버전, build 시각, session_id, active profile, token source, rate limit snapshot, polling interval, config path.:ratelimit→ 카테고리별 last-observed detail.:healthcheck→ 짧은 GET/api/v1/users/me+ 결과 표시.:errors→ 세션 내 에러 히스토리 (REQ-E02 AC-3).
internal/domain/app.go타입 정의internal/domain/ports.go에AppsPort추가internal/okta/apps.go어댑터 구현internal/service/apps.goAppsServiceinternal/tui/apps/Screen Modelsinternal/app/router.go에 라우트 추가cmd/ota/wire.gowiring 한 줄
통상 변경 범위: 7개 파일, 1 디렉토리 신규.
REQ-R04 AC-8: 타입 카탈로그는 설정 가능 구조.
internal/domain/policy.go의policyTypeCatalogmap에 추가.- Rich renderer가 없으면 자동 raw-view 모드 (
policies/renderers/미등록).
internal/entra/어댑터 신설. Port 인터페이스만 구현.- 도메인 타입은 공통(User/Group 등) — 필요하면
domain내에서 공통 필드만 유지, IdP 고유는extras map[string]any. cmd/ota/main.go의 wiring 함수만 교체. TUI 레이어 변경 없음.
이 확장은 v0.3+ 탐색. 지금은 필요 인터페이스 모양을 비우지 않고 Okta에 맞게 좁게 둔다 (YAGNI). 단, Port 경계는 유지.
REQ-W01의 form widget 패키지(internal/tui/shared/form/)를 재사용:
- 도메인에 Patch / Input 타입 정의 (
internal/domain/<resource>_patch.go등) —*string패턴 +IsEmpty()+ErrEmpty<X>Patch센티넬 internal/domain/ports.go에 mutation 메서드 추가 (예:GroupRulesPort.DeactivateWithReason(ctx, ruleID, reason string) error)internal/okta/<resource>.go에 어댑터 구현 — 기존doPost재사용, errormap 매퍼 그대로internal/service/<resource>.go에 wrap (캐시 무효화 + Cmd 친화 시그니처)internal/tui/<resource>/edit.go(또는 reason input modal) —form.New(specs, initial, ...)호출internal/tui/shared/msgs.go에<X>UpdatedMsg/Open<X>EditMsg추가- App Shell에 ScreenKind 등록 (또는 OverlayKind로 modal 모드 선택)
- TESTING.md §12 매트릭스에 새 REQ-W## 행 추가, fakes에 메서드 추가
변경 반경 (REQ-W01 기준): 신규 파일 ~5개, 수정 파일 ~6개 (Port, Adapter, Service, fakes, shared/msgs, App Shell router).
| 요구 | 메커니즘 |
|---|---|
| 토큰 파일 저장 금지 (REQ-C05 AC-4) | 설정 구조체에 Token 필드 없음. 오직 APITokenEnv(이름). |
| 로그 마스킹 (REQ-C05 AC-2) | internal/logger middleware가 인증 헤더·토큰 값·PII 스크럽. |
| 크래시 스택에 토큰 누출 방지 (REQ-C05 AC-3) | Client 구조체의 token 필드는 fmt.Stringer에서 *** 반환. panic handler는 Authorization 헤더 scrub. |
| PII 기본 마스킹 (REQ-R01 AC-6) | internal/mask로 View 레이어에서 마스킹 후 렌더. :unmask는 세션/60s 타임아웃. |
| TLS only | okta-sdk-golang는 TLS 기본. ota는 http:// URL 거부 (config validate). |
| 디버그 로그 파일 권한 | 0600 (user-only). 생성 시 umask 명시. |
| NFR | 아키텍처적 대응 |
|---|---|
| 초기 실행 < 500ms | lazy wiring, TLS 연결 pool 재사용, 초기 SDK client 생성 최소화. |
| 키 입력 < 16ms | Update 함수는 순수·비블로킹. 모든 I/O는 Cmd. |
| 리스트 → 상세 < 300ms (cached) | Service 캐시 30s TTL. Detail에서 연관 리소스는 탭 진입 시에만 호출. |
| 1,000행 필터 < 50ms | 클라이언트 필터는 메모리 substring match. fuzzy는 명시 토글 시만. |
| 메모리 < 200MB | 로그 버퍼는 TUI viewport(최대 N행), Iterator는 페이지 단위로 GC. |
| 관점 | 검증 전략 |
|---|---|
| Port 경계 | Service 테스트는 fake Port 주입. SDK 없는 단위 테스트. |
| Adapter 통합 | httptest.Server + JSON 픽스처(testdata/oktaapi/). 실제 SDK 호출 경로 검증. |
| TUI 인터랙션 | teatest로 키 시퀀스 → 렌더 비교. |
| 에러 매핑 | 테이블 드리븐: Okta 응답 fixture → domain.Err 매칭. |
| Rate Limit | 429 fixture + Retry-After 준수 / tail 복구 since 유지. |
| 프로필 스위칭 | Fake Clock + Fake Adapter 두 세트 → 스위치 후 캐시 리셋. |
상세는 TESTING.md (test-engineer 주도).
PRD §11.4를 상속:
teatest의 스냅샷 안정성 (ANSI 정규화)- SDK v5의 Rate Limit 헤더 노출 정확성 — middleware 내 직접
http.Response접근 필요성 - SDK v5의 Link 헤더 헬퍼(
HasNextPage/Next) 커버리지 — 미커버 엔드포인트가 있으면 수동 파서 fallback - Resize 중 tail 안정성
- SystemLogs의 debugContext free-form 필드 — 직접 HTTP fallback 필요 여부
- Policy Rule id prefix 일관성 (도메인 §12.1 잔존) — 픽스처로 검증
END of ARCHITECTURE.md draft