feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종

설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.

계약과 생성
- src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록
- openapi-generator로 DTO(model)만 생성한다. generateApis 대신
  globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다
- useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이
  깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다
- 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated
  API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다

오류 계약
- StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier)
- StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을
  바꾸지 않기 위해서다
- 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는
  로그 전용이다(ApiErrorCarrier javadoc의 요구)
- 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다

게이트
- TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를
  늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다
- StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증
- StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라
  새 컨트롤러가 자동으로 걸린다
- StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의
  csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를
  테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다

스키마
- V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존
  idempotency_record 재사용)와 범위 밖 6종을 제외했다
- 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway
  세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다

알려진 제약
- getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을
  반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로
  200을 만들지 않았다
- 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다

이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로
작성됐다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:14:52 +09:00
co-authored by Claude Opus 5
parent 697fc740e6
commit 91e6d99654
48 changed files with 9495 additions and 220 deletions
+312
View File
@@ -916,3 +916,315 @@ errors:
runbook_link: "runbook://management/actuator-forbidden"
compatibility_impact: none
required_test: contract-verification:management-actuator
# ============================================================
# TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종, feature-techlog-studio-backend)
#
# StudioError(dev.caskeleton.application.techlog.error.StudioError)와 1:1 매핑.
# category/http_status/retryable은 그 enum의 선언과 정확히 같아야 한다
# (StudioErrorRegistryTest가 코드 존재만 보고, 값 일치는 이 파일의 리뷰 책임).
#
# PAYLOAD_TOO_LARGE / UNSUPPORTED_MEDIA_TYPE은 StudioError에도 있지만 별도 row를
# 추가하지 않는다 — feature-api-contract-baseline이 이미 동일 code로 아래(L514,
# L545)에 VALIDATION/413/415/false, VALIDATION/415/false row를 갖고 있고 값이
# StudioError 선언과 정확히 일치한다. error-codes.yaml의 identity column은 `code`
# 하나뿐이라 같은 code로 두 번째 row를 추가하면 ContractRegistrySchemaGovernanceTest
# 의 "duplicate identity" 게이트가 깨진다. 즉 이 두 코드는 기존 row가 이미 커버한다.
# ============================================================
# source: studio-v1.yaml ApiError.code — AUTHENTICATION_REQUIRED (StudioError.AUTHENTICATION_REQUIRED)
- code: AUTHENTICATION_REQUIRED
category: AUTH
http_status: 401
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: presentation
client_safe_message: "Studio 인증이 필요합니다"
log_level: INFO
runbook_link: "runbook://auth/token-missing"
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — STUDIO_ACCESS_DENIED (StudioError.STUDIO_ACCESS_DENIED)
- code: STUDIO_ACCESS_DENIED
category: AUTHZ
http_status: 403
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "이 Studio 리소스에 접근할 권한이 없습니다"
log_level: WARN
runbook_link: "runbook://authz/insufficient-permission"
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
- code: DOCUMENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 문서를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — VERSION_CONFLICT (StudioError.VERSION_CONFLICT)
- code: VERSION_CONFLICT
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "저장된 version이 더 최신입니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — REQUEST_VALIDATION_FAILED (StudioError.REQUEST_VALIDATION_FAILED)
- code: REQUEST_VALIDATION_FAILED
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: presentation
client_safe_message: "요청 형식이 올바르지 않습니다"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — DOCUMENT_VALIDATION_FAILED
# (StudioError.DOCUMENT_VALIDATION_FAILED). 원래 계약 이름은 VALIDATION_FAILED였으나
# 스켈레톤 전역 OperationalError.VALIDATION_FAILED(400, VALIDATION)와 code 문자열이
# 충돌해(같은 문자열, 다른 http_status) 개명했다 — controller 판정, 2026-08-19.
- code: DOCUMENT_VALIDATION_FAILED
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "문서 검증에 실패했습니다"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — VALIDATION_STALE (StudioError.VALIDATION_STALE)
- code: VALIDATION_STALE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PREVIEW_NOT_FOUND (StudioError.PREVIEW_NOT_FOUND)
- code: PREVIEW_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 미리보기를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PREVIEW_STALE (StudioError.PREVIEW_STALE)
- code: PREVIEW_STALE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PREVIEW_EXPIRED (StudioError.PREVIEW_EXPIRED)
- code: PREVIEW_EXPIRED
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "미리보기가 만료되었습니다. 다시 생성해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_NOT_FOUND (StudioError.PUBLICATION_NOT_FOUND)
- code: PUBLICATION_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 게시물을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_CONFLICT (StudioError.PUBLICATION_CONFLICT)
- code: PUBLICATION_CONFLICT
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "게시 작업이 다른 변경과 충돌했습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_EVENT_NOT_FOUND (StudioError.PUBLICATION_EVENT_NOT_FOUND)
- code: PUBLICATION_EVENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 게시 이벤트를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — PUBLICATION_SNAPSHOT_NOT_FOUND (StudioError.PUBLICATION_SNAPSHOT_NOT_FOUND)
- code: PUBLICATION_SNAPSHOT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 게시 스냅샷을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — WARNING_ACKNOWLEDGEMENT_REQUIRED (StudioError.WARNING_ACKNOWLEDGEMENT_REQUIRED)
- code: WARNING_ACKNOWLEDGEMENT_REQUIRED
category: VALIDATION
http_status: 422
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — IDEMPOTENCY_KEY_REUSED (StudioError.IDEMPOTENCY_KEY_REUSED)
- code: IDEMPOTENCY_KEY_REUSED
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "Idempotency 키가 다른 요청에 재사용되었습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_NOT_FOUND (StudioError.ASSET_NOT_FOUND)
- code: ASSET_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "요청한 자산을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_NOT_READY (StudioError.ASSET_NOT_READY)
- code: ASSET_NOT_READY
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "자산 처리가 아직 완료되지 않았습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_IN_USE (StudioError.ASSET_IN_USE)
- code: ASSET_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "자산이 사용 중이라 이 작업을 수행할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — ASSET_QUARANTINED (StudioError.ASSET_QUARANTINED)
- code: ASSET_QUARANTINED
category: DATA_INTEGRITY
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-studio-backend
owner_layer: application
client_safe_message: "자산이 격리 처리되어 사용할 수 없습니다"
log_level: WARN
runbook_link: null
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — STUDIO_UNAVAILABLE (StudioError.STUDIO_UNAVAILABLE)
- code: STUDIO_UNAVAILABLE
category: TRANSIENT_DEPENDENCY
http_status: 503
retryable: true
retry_after_seconds: 5
owner_branch: feature-techlog-studio-backend
owner_layer: infrastructure
client_safe_message: "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요"
log_level: ERROR
runbook_link: "runbook://studio/unavailable"
compatibility_impact: additive
required_test: StudioErrorTest
+5 -2
View File
@@ -1,10 +1,10 @@
---
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
category: AUTH
error_codes: [AUTH_TOKEN_MISSING]
error_codes: [AUTH_TOKEN_MISSING, AUTHENTICATION_REQUIRED]
severity: P3
owner: oncall
last_updated: 2026-06-15
last_updated: 2026-08-18
status: stub
---
@@ -14,6 +14,9 @@ status: stub
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
- Client missing Authorization header or Bearer token
- Tech Log Studio (`studio-v1.yaml`) surfaces the same missing-authentication scenario as
`error.code=AUTHENTICATION_REQUIRED` — same root cause, Studio-scoped code
(feature-techlog-studio-backend, `StudioError.AUTHENTICATION_REQUIRED`)
## Diagnosis
@@ -1,10 +1,10 @@
---
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
category: AUTHZ
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION]
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, STUDIO_ACCESS_DENIED]
severity: P3
owner: oncall
last_updated: 2026-06-15
last_updated: 2026-08-18
status: stub
---
@@ -14,6 +14,9 @@ status: stub
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
- Valid token but missing required role or permission
- Tech Log Studio (`studio-v1.yaml`) surfaces the same resource-authorization scenario as
`error.code=STUDIO_ACCESS_DENIED` — same root cause, Studio-scoped code
(feature-techlog-studio-backend, `StudioError.STUDIO_ACCESS_DENIED`)
## Diagnosis
+95
View File
@@ -0,0 +1,95 @@
---
title: Runbook — STUDIO_UNAVAILABLE (Tech Log Studio 일시 장애)
category: TRANSIENT_DEPENDENCY
error_codes: [STUDIO_UNAVAILABLE]
severity: P1
owner: oncall
last_updated: 2026-08-18
status: active
---
# Runbook: STUDIO_UNAVAILABLE (`runbook://studio/unavailable`)
## 1. Trigger
이 runbook은 Tech Log Studio API(`studio-v1.yaml`)가 `error.code=STUDIO_UNAVAILABLE`
(HTTP 503, category `TRANSIENT_DEPENDENCY`, `retryable=true`)을 응답할 때 발동됩니다.
- alert name: `studio_error_rate_critical` 또는 `studio_dependency_unavailable`
- alert payload 필수 field: `operation`(문서/미리보기/게시 중 어느 슬라이스인지), `error.code`,
`error.category`, `dependency_name`(가능하면), `runbook_link`
- 임계:
- P1: `STUDIO_UNAVAILABLE` 비율이 1분간 Studio 전체 트래픽의 10% 초과
- P2: 단발성 spike이나 5분 내 자연 회복
`StudioExceptionHandler`(adapter/inbound/web)가 `StudioException`을 봉투로 변환하는
지점이므로, 이 코드는 항상 Studio facade/use case(`application-core`)가 자신의 하위
의존성(영속성, 캐시, 오브젝트 스토리지, 렌더링/미리보기 파이프라인 등) 실패를
클라이언트에 안전한 단일 코드로 접어(classify) 던진 결과입니다 — 원인 그 자체가 아니라
**Studio가 판단한 결과**라는 점을 유의합니다.
## 2. First Response (5분 이내)
### Step 1 — 확인
1. 최근 배포 이력 확인 (`app-bootstrap` 롤아웃, config 변경) — 배포 직후 spike면 롤백 우선 검토
2. 로그에서 `StudioException`이 감싸고 있던 실제 원인을 확인: `dev.caskeleton.application.techlog`
패키지의 use case/facade 로그에서 `STUDIO_UNAVAILABLE`로 분류되기 직전의 원인 예외
(`PersistenceFailureException`, `DependencyFailureException` 등)를 추적
3. runtime-health Dependency Matrix에서 Studio가 의존하는 구성요소(DB, 캐시, object storage)의
required/optional 분류와 현재 상태 확인
4. 특정 slice(문서 편집/미리보기/게시)에 국한된 장애인지, Studio 전역 장애인지 구분
### Step 2 — 임시 격리
- 원인이 특정 하위 의존성이면 해당 의존성의 runbook으로 전환 (예: `runbook://db/unavailable`,
`runbook://cache/unavailable`) — `STUDIO_UNAVAILABLE`은 진입점일 뿐, 근본 원인 대응은
하위 의존성 runbook이 담당
- 클라이언트(Frontend)는 이미 `retryable=true`를 신뢰해 지수 백오프 재시도를 수행하므로,
단기 spike는 자연 회복을 우선 관찰 (조기 개입으로 인한 추가 부하 유발 방지)
## 3. Diagnosis
- log query: `{service="app"} | error.code="STUDIO_UNAVAILABLE" | stats count by operation`
- 원인 추적: 같은 요청의 correlation id로 use case 로그를 따라가 어떤 하위 호출이
`StudioException.withDetails(StudioError.STUDIO_UNAVAILABLE, ...)`로 재분류됐는지 확인
- metric panel:
- `http_server_requests_seconds_count{uri=~"/api/v1/studio/.*", outcome="SERVER_ERROR"}`
- 하위 의존성 metric (`hikaricp_connections_active`, `resilience4j_circuitbreaker_state`,
object storage client 오류율)
- 가능한 원인:
- DB/캐시/오브젝트 스토리지 등 필수 의존성 장애가 Studio 계층까지 전파
- Studio 자체 리소스 고갈 (스레드풀, 커넥션풀)
- 미리보기/렌더링 파이프라인의 타임아웃 누적
- 배포 직후 신규 코드 경로의 미검증 예외가 fallback으로 `STUDIO_UNAVAILABLE`에 접힘
## 4. Mitigation
- 단기: 근본 원인이 확인된 하위 의존성이면 해당 dependency runbook의 mitigation을 적용
- Studio 자체 리소스 고갈이면 인스턴스 스케일아웃 또는 커넥션/스레드풀 상향 검토
- 특정 slice(예: 미리보기)만 영향받는다면 해당 slice만 일시적으로 기능 차단하고
나머지(문서 편집/게시)는 정상 유지 검토 — 전면 장애보다 부분 degrade 우선
- 장기: 반복되는 하위 의존성 장애가 `STUDIO_UNAVAILABLE`로 잦게 나타나면, 해당 의존성의
circuit breaker/timeout 임계를 재조정하고 fallback 경로 보강
## 5. Escalation
- P1 5분 내 회복 신호 없으면 해당 하위 의존성 오너 팀에 page
- 여러 slice(문서/미리보기/게시)에서 동시에 발생하면 incident commander 호출
(공유 인프라 계층 문제 의심)
## 6. Recovery / Verification
- 회복 확인 metric: `STUDIO_UNAVAILABLE` 비율이 5분간 1% 미만으로 유지
- 하위 의존성 metric(circuit breaker CLOSED, connection pool 정상)도 함께 확인
- post-incident:
- 어떤 하위 의존성이 `STUDIO_UNAVAILABLE`로 접혔는지 기록하고 근본 원인 runbook에 링크
- Studio 클라이언트(Frontend `STUDIO_ERROR_CODES`) 쪽 재시도/백오프 동작이 기대대로
작동했는지 확인
- 특정 slice 반복 장애면 chaos test 시나리오 추가 검토
## 7. Related
- error-codes.yaml row: `STUDIO_UNAVAILABLE` (feature-techlog-studio-backend,
`dev.caskeleton.application.techlog.error.StudioError.STUDIO_UNAVAILABLE`)
- 매핑 지점: `dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler`
- 관련 runbook: `runbook://db/unavailable`, `runbook://cache/unavailable`
- 관련 branch: [[feature-techlog-studio-backend]]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,870 @@
# Tech Log Studio Backend — 설계
- 작성일: 2026-08-18
- 대상 저장소: `tech-log-backend` (`clean-architecture-backend-template` 스냅샷)
- 브랜치: `feature/techlog-studio-backend`
- 설계 원본: `/home/donghyeon/workspace/tech-log-design-package`
- 소비자: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` (Studio SPA, 구현 완료)
---
## 1. 배경
설계 패키지가 Tech Log의 제품·정보구조·계약·DB·백엔드 모듈을 확정했다. 프론트는
Studio SPA가 이미 구현되어 있고 백엔드만 없다. 이 문서는 **설계 패키지의 결정을
이 저장소의 구조·게이트에 맞춰 어떻게 구현할지**를 정의한다.
설계 패키지의 `docs/plans/02-backend-core-plan.md`는 본문에 STALE 배너가 붙어 있고
파일맵도 `services/api/...`라 이 저장소에 적용되지 않는다. 이 문서가 그 자리를
대신한다.
### 1.1 계약 정합 (검증됨)
`tech-log-design-package/scripts/check-contract-parity.py` 실행 결과:
```text
Frontend operation 19개 / Backend primary operation 19개
[OK] Frontend operation 13개가 모두 Backend primary 계약에 존재한다.
[OK] 공통 operation의 method/path가 모두 일치한다.
RecordKind FE=BE=['CASE','PROJECT_DECISION','QUESTION','REFERENCE']
오류 코드 FE=23개 / BE=23개 [OK]
```
`contracts/openapi/studio-v1.yaml`이 프론트가 실제로 호출하는 계약 그대로다.
---
## 2. 범위
### 2.1 In scope
`studio-v1.yaml`의 19개 operation 전부.
| operation | method · path |
| --- | --- |
| `getStudioSession` | GET `/api/v1/studio/session` |
| `getStudioDashboard` | GET `/api/v1/studio/dashboard` |
| `listStudioDocuments` | GET `/api/v1/studio/documents` |
| `createStudioDocument` | POST `/api/v1/studio/documents` |
| `getStudioDocument` | GET `/api/v1/studio/documents/{documentId}` |
| `saveStudioDocument` | PUT `/api/v1/studio/documents/{documentId}` |
| `validateStudioDocument` | POST `/api/v1/studio/documents/{documentId}/validate` |
| `createStudioPreview` | POST `/api/v1/studio/documents/{documentId}/preview` |
| `getCurrentStudioPreview` | GET `/api/v1/studio/documents/{documentId}/preview` |
| `publishStudioDocument` | POST `/api/v1/studio/documents/{documentId}/publish` |
| `listStudioPublications` | GET `/api/v1/studio/publications` |
| `unpublishStudioPublication` | POST `/api/v1/studio/publications/{publicationId}/unpublish` |
| `getStudioPublicationSnapshot` | GET `/api/v1/studio/publications/{publicationEventId}/preview` |
| `listStudioCatalog` | GET `/api/v1/studio/catalog` |
| `listStudioAssets` | GET `/api/v1/studio/assets` |
| `uploadStudioAsset` | POST `/api/v1/studio/assets` |
| `getStudioAsset` | GET `/api/v1/studio/assets/{assetId}` |
| `updateStudioAsset` | PUT `/api/v1/studio/assets/{assetId}` |
| `deleteStudioAsset` | DELETE `/api/v1/studio/assets/{assetId}` |
`RecordKind` 4종(`CASE`, `REFERENCE`, `QUESTION`, `PROJECT_DECISION`)을 하나의 문서
편집 흐름으로 다룬다.
### 2.2 Out of scope (이번 브랜치)
- `contracts/openapi/public-v1.yaml` (Public 조회 API) — 소비자(Astro Public 사이트)가
아직 없다.
- `contracts/openapi/studio-management-v1.yaml` (secondary capability 보존 계약).
- `release`, `topic`/`tag` 관리 UI, `identity`(SiteConfig/Profile/HomeFocus) 편집 API.
- 이번 범위에 필요한 만큼의 `topic`/`project` **조회**는 catalog에서 다루되, 그
편집 API는 만들지 않는다.
---
## 3. 상위 결정
| ID | 결정 | 근거 |
| --- | --- | --- |
| D1 | 새 Gradle leaf를 만들지 않고 기존 18 leaf **안의 하위 패키지**로 배치한다 | `src/settings.gradle``expectedModuleCount = 18`로 fail-closed 검증. leaf 추가는 registry·settings·dependency gate·ArchUnit을 동시에 바꾸는 template-level 변경이고 `template.lock.json` 기반 향후 sync와 충돌한다 |
| D2 | 패키지는 `dev.caskeleton.{domain,application,adapter...}` **루트 아래**에 `techlog` 하위로 넣는다 | 일부 ArchUnit 규칙이 `dev.caskeleton.application..`처럼 루트를 고정한다(`CleanArchitectureTest.java:221`). 다른 루트에 두면 가드레일이 조용히 미적용된다 |
| D3 | 계약 우선(openapi-generator)으로 **DTO(model)만** 생성하고 controller는 얇게 손으로 쓴다 (`globalProperties.set(['models': ''])`, `useOneOfInterfaces=false`) — 실제 설정은 §5.2, 실측과의 차이는 §3.1 | ADR-004를 유지하되 D4와 충돌하지 않는 형태. 생성 API interface는 봉투 wrapper 타입을 반환하게 되고, 그 타입은 `dev.caskeleton.shared.response.Envelope`가 아니라서 `EnvelopeBodyAdvice`**한 번 더 감싼다**(이중 래핑). 드리프트 위험의 실체는 93개 schema·enum·required 필드이고 그건 model 생성으로 덮인다. controller 19개 signature 드리프트는 §5.5 회귀 테스트가 잡는다 |
| D4 | `/api/v1/studio/**`도 템플릿 응답 봉투를 **그대로 쓴다**. 계약을 봉투 형태로 재정의한다 | 봉투는 `shared-contract/README.md:230`의 문서화된 결정(boundary D5/D6)이고 정보 손실이 없다. 적응 코드를 백엔드 *템플릿* 파일이 아니라 프론트 *제품* 코드에 두는 편이 template sync 충돌이 없다(§5.3) |
| D5 | `studio_idempotency` 테이블을 만들지 않고 기존 `idempotency_record`를 쓴다 | 기존 스키마가 설계 요구의 상위집합이고 `IdempotencyExecutorV2`까지 있다(§8.2) |
| D6 | Studio 통합 목록은 물리 인덱스 테이블 없이 union query로 시작한다 | 설계 08장 §4. 성능이 입증되면 read model 추가 |
| D7 | 커밋은 하지 않는다 | `AGENTS.md:64` — commit 정책 `human-only` |
### 3.1 D3 정정 — 실제 구현과의 차이 (2026-08-19, final whole-branch review B2)
이 절 작성 시점(§12 슬라이스 0 계획 단계)에는 openapi-generator 7.x가 이 저장소의 Spring Boot
버전과 함께 검증된 바 없었고, §5.2가 예정한 `generateApis=false` / `generateModels=true`
실제 스파이크(슬라이스 0)에서 두 가지가 틀린 것으로 드러났다. 아래는 spec이 스스로 요구한
"폴백을 쓰면 D3을 갱신한다"(구 §5.2)는 약속이 이행되지 않고 있던 것을 바로잡는 정정이다 —
구현이 폴백으로 넘어간 것은 아니고(생성기는 여전히 model을 생성한다), **생성기 설정 자체가
설계 시점에 존재하지 않는 API를 가정**했던 것이 실측으로 확인됐다.
- **`generateApis`/`generateModels`는 openapi-generator-gradle-plugin 7.18.0의 `openApiGenerate`
확장에 존재하지 않는다** (디컴파일로 확인, task-4-report.md). 대신 CLI `--global-property`
같은 뜻인 `globalProperties.set(['models': ''])`로 "models만" 생성하도록 제한한다. 실제 설정은
§5.2를 그대로 참조.
- **생성 소스는 `sourceSets.main.java.srcDir`에 있지 않다.** 별도 `generatedOpenapi` sourceSet에
있고, `main`/`test`의 compile·runtime classpath와 `jar` 산출물에 각각 명시적으로 이어 붙인다
(`src/adapter/inbound/web/build.gradle:22-38`). 이유: 생성 코드가 deprecated
`org.springframework.lang.Nullable`을 참조하는데, 저장소 루트 `build.gradle`
`-Werror`/`-Xlint:deprecation`이 이걸 컴파일 실패로 승격한다. `main`에 직접 넣으면 그 플래그가
생성 코드에도 적용돼 빌드가 깨진다 — 별도 sourceSet으로 분리하고 그 sourceSet의
`compileGeneratedOpenapiJava` 태스크에서만 `-Werror`/`-Xlint:deprecation`을 뺀다.
- **spec에 없던 `useOneOfInterfaces=false`가 결정적 옵션으로 추가됐다.** discriminator(`oneOf`)
union을 부모 Java interface로 생성하면(`useOneOfInterfaces=true`, openapi-generator 기본값)
discriminator getter가 항상 `String`을 반환하도록 SpringCodegen이 고정하는데, 하위 타입의 실제
getter 타입(공유 enum이든 아니든)과 충돌해 컴파일이 깨진다. 판별 필드를 하위 타입에서
narrowing하지 않도록 계약을 고쳐도 동일하게 깨지는 것까지 스크래치에서 직접 검증했다(3개 설정
조합, task-4-report.md) — 계약 쪽에서 우회할 수 없는 SpringCodegen 자체의 제약이다.
**`useOneOfInterfaces=false`의 대가 — 생성 union 5종이 파손됐다.** 이 경고는 지금
`src/adapter/inbound/web/build.gradle:139-155`의 주석에만 있고 spec 본문에는 없었다 — Plan 02
작성자가 읽는 문서는 이 spec이므로 여기 옮긴다.
`useOneOfInterfaces=false`는 컴파일은 통과시키지만, discriminator union 5종
(`WorkingCopyInput`, `WorkingCopy`, `Inline`, `CaseRenderBlock`, `PublicRenderModel`)의 하위
타입이 Java `implements` 관계를 전혀 갖지 않는 독립 클래스로 생성된다. 실측 결과 Jackson
양방향 배선도 계약을 어긴다:
- **역직렬화**는 `InvalidTypeIdException`으로 실패한다 (예: `Class CaseInput not subtype of
WorkingCopyInput`).
- **직렬화**는 생성된 `@JsonIgnoreProperties(value="kind", allowSetters=true)` 때문에 실제
discriminator 값 대신 클래스 simple name이 나간다 (예: 응답에 `"kind":"WorkingCopyInput"`).
이 union들을 요청·응답에 직접 또는 (409 `VersionConflictDetails`처럼) 간접적으로 포함하는
operation은 최소 `createStudioDocument`/`getStudioDocument`/`saveStudioDocument`
(`WorkingCopyInput`/`WorkingCopy`)와 `createStudioPreview`/`getCurrentStudioPreview`/
`getStudioPublicationSnapshot`(`PublicRenderModel`)이며, `validateStudioDocument`를 포함해
409 conflict 응답 경로로 더 넓게 새어 들어갈 수 있다 — Task 8/9(`getStudioSession`,
`listStudioCatalog`)는 이 union들을 쓰지 않아 막히지 않았을 뿐, 영향받는 operation의 정확한
목록과 대응 전략(수동 Jackson `@JsonTypeInfo`/`@JsonSubTypes` 재작성, 계약 재구조화, 또는 별도
수기 DTO)은 **Plan 02가 착수 전에 확정해야 한다.**
---
## 4. 코드 배치
### 4.1 패키지
```text
:domain-core
dev.caskeleton.domain.techlog.content Document / CaseDetail / ReferenceDetail
dev.caskeleton.domain.techlog.inquiry OpenQuestion
dev.caskeleton.domain.techlog.project ProjectDecision
dev.caskeleton.domain.techlog.asset Asset
dev.caskeleton.domain.techlog.publication Publication / PublicationEvent
dev.caskeleton.domain.techlog.<ctx>.vo Value Object
:application-core
dev.caskeleton.application.techlog.<ctx>.port.in Command · Query · *UseCase 인터페이스
dev.caskeleton.application.techlog.<ctx>.port.out Repository · Renderer · Clock 포트
dev.caskeleton.application.techlog.<ctx>.service *UseCase 구현
dev.caskeleton.application.techlog.studio.facade WorkingCopy / Validation / Preview / Publication
dev.caskeleton.application.techlog.studio.query Dashboard / Document / Publication / Catalog
dev.caskeleton.application.techlog.studio.mapper API 용어 ↔ Domain 용어
dev.caskeleton.application.techlog.studio.nextaction NextAction 계산
dev.caskeleton.application.techlog.studio.port.out Studio 자신의 read 포트(예: CatalogQueryPort) —
§4.3 경계 규칙 2가 금지하는 것은 studio가
*타* context의 port.out을 참조하는 것이지,
studio 자신의 port.out이 아니다
:adapter:outbound:persistence-jpa
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.entity
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.repository
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.mapper
dev.caskeleton.adapter.outbound.persistence.techlog.query JdbcClient union query
:adapter:inbound:web
dev.caskeleton.adapter.inbound.web.techlog.studio.controller
dev.caskeleton.adapter.inbound.web.techlog.studio.mapper
dev.caskeleton.adapter.inbound.web.techlog.studio.problem
:app-bootstrap
dev.caskeleton.bootstrap.techlog 조립·설정만
```
`modules.json`, `settings.gradle`, `verifyCleanArchitectureDependencies`는 변경하지
않는다.
### 4.2 상속되는 기존 가드레일
신규 코드에 자동으로 적용된다.
- `..domain..` 순수성 (프레임워크·전송·DB 의존 금지)
- `..domain.vo..` / `@ValueObject`: public 무인자 생성자 금지
- `@AggregateRoot`: `set*` 메서드 public 금지
- `@DomainEvent`: record 필수
- `..application..`: `@Transactional` 금지 → `TransactionPort` 사용
- `..application..`: `ApplicationContext` 의존 금지
- `CommandUseCase`/`QueryUseCase` 구현: 이름이 `UseCase`로 끝나야 하고
`@UseCaseCapability` 필수
- `verifyApplicationCoreDependencyPurity`: application-core 생산 의존은 project-only,
클래스패스에 Spring/slf4j/logback/micrometer 금지
### 4.3 추가할 경계 규칙 — `TechLogBoundaryArchTest`
`:app-bootstrap` 테스트에 추가한다.
1. `techlog.content` / `techlog.inquiry` / `techlog.project` / `techlog.asset`은
서로 의존하지 않는다.
2. `application.techlog.studio`는 타 context의 `port.in`만 참조한다.
타 context의 `domain`, `port.out`, `service` 직접 참조는 위반이다. (studio 자신의
`application.techlog.studio.port.out`은 이 규칙의 대상이 아니다 — §4.1 참조.)
3. 타 context는 `application.techlog.studio`를 참조하지 않는다 (역방향 금지).
4. `domain.techlog.publication`을 제외한 어떤 domain 패키지도 `Publication`을
직접 변경하지 않는다.
설계 08장의 "`studio`는 도메인 모듈이 아니다"를 빌드로 강제하는 장치다.
---
## 5. 계약 → 코드
### 5.1 계약 원본
`studio-v1.yaml`을 `src/config/openapi/studio-v1.yaml`로 복사한다
(`src/config/architecture`, `src/config/messaging`과 같은 authority 위치).
`src/config/openapi/MANIFEST.sha256`에 해시를 기록해 설계 패키지와의 드리프트를
가시화한다.
### 5.2 생성
`:adapter:inbound:web`에 `org.openapi.generator` 플러그인을 추가한다. 아래는 실제 구현 설정
(`src/adapter/inbound/web/build.gradle`)이다 — 이 절이 원래 예정했던 `generateApis=false`/
`generateModels=true`는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 프로퍼티였다.
무엇이 왜 달라졌는지는 §3.1 참조.
```text
generatorName spring
globalProperties ['models': ''] ← "models만" 생성 (CLI --global-property와 동치)
modelPackage dev.caskeleton.adapter.inbound.web.techlog.studio.api.model
useSpringBoot3 true
useJakartaEe true
openApiNullable true (jackson-databind-nullable 이미 선언됨)
useOneOfInterfaces false ← §3.1 — discriminator union 5종의 Jackson 배선이 깨지는 대가
output build/generated/openapi
```
- 생성 소스는 `sourceSets.main.java.srcDir`가 아니라 **별도 `generatedOpenapi` sourceSet**에
있다. 생성 코드가 deprecated `org.springframework.lang.Nullable`을 쓰는데 저장소 루트의
`-Werror`가 이를 빌드 실패로 승격하기 때문이다 — 자세한 배선은 §3.1과
`build.gradle:1-38`의 주석 참조. `jar`/`test` 클래스패스에는 별도로 명시적으로 얹는다.
- spotless / checkstyle / spotbugs / errorprone 대상에서 제외한다.
- `adapter/inbound/web/gradle.lockfile`을 `--write-locks`로 재생성한다.
#### 왜 API interface를 생성하지 않는가
D4로 응답 봉투를 유지하면 계약의 성공 응답 스키마가 봉투 wrapper가 된다. 그러면
생성 interface의 signature가 `ResponseEntity<StudioSessionEnvelope>`가 되는데,
`StudioSessionEnvelope`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가
아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로
이 본문을 **한 번 더 감싼다**.
```text
controller → StudioSessionEnvelope
EnvelopeBodyAdvice → Envelope<StudioSessionEnvelope>
wire → {"success":true,"data":{"success":true,"data":{...},"meta":{...}},"meta":{...}}
```
이걸 피하려면 `EnvelopeBodyAdvice`(템플릿 파일)를 고쳐야 하는데, 그건 D4가 피하려던
바로 그 template sync 충돌이다.
따라서 **model만 생성하고 controller는 손으로 쓴다.**
```java
@RestController
final class StudioSessionController {
@GetMapping("/api/v1/studio/session")
StudioSession getStudioSession() { // 생성된 payload DTO를 그대로 반환
return mapper.toApi(facade.currentSession());
}
}
// EnvelopeBodyAdvice가 여기서 정확히 한 번 감싼다.
```
controller는 facade 호출과 매핑만 하고 비즈니스 로직을 두지 않는다.
봉투 wrapper 스키마도 함께 생성되지만 백엔드는 쓰지 않는다. 계약의 wrapper는
소비자(프론트·외부 도구)를 위한 기술이고, 백엔드에서는 advice가 그 역할을 한다.
**선행 검증 결과:** openapi-generator 7.x × 이 저장소의 Spring Boot 조합은 슬라이스 0의 폐기용
스파이크로 검증했다. 폴백(생성기 1회 실행 후 수기 유지)으로 넘어가지는 않았다 — 생성기는 지금도
빌드마다 model을 생성한다. 대신 §5.2 상단에 적은 세 가지(`globalProperties`, 별도 sourceSet,
`useOneOfInterfaces=false`)가 스파이크에서 드러난 실제 조건이었다. §3.1이 그 정정 기록이다.
### 5.3 wire format — 템플릿 봉투를 그대로 쓴다
성공과 실패가 한 모양을 공유한다.
```jsonc
// 성공 (200 / 201)
{ "success": true, "data": { /* studio-v1 payload */ }, "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
// 실패 (4xx / 5xx) — HTTP status는 그대로 의미를 갖는다
{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT",
"message": "...", "retryable": false,
"details": { /* code별 polymorphic */ } },
"meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
// 204 (deleteStudioAsset) — 본문 없음. EnvelopeBodyAdvice는 null body를 감싸지 않는다.
```
미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지
않는다.
#### 왜 이 방향인가
- 봉투는 이 템플릿의 **문서화된 결정**이다. `shared-contract/README.md:230` —
"RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", "D5 가 RFC 7807 ProblemDetail 을
거부하고, D10 이 `category`를 1급 필드로 추가했다".
- **정보 손실이 없다.** §5.4의 매핑표 참조. `error.category`는 봉투 쪽이 추가로 준다.
- **적응 코드의 위치가 결정적이다.** 봉투를 벗기려면 `EnvelopeBodyAdvice.supports()`를
고쳐야 하는데 이는 템플릿 파일이고, 이 저장소는 `template.lock.json`의
`"materialization": "tracked-snapshot"`이라 이후 모든 template sync의 충돌 지점이 된다.
반대로 봉투를 유지하면 적응은 프론트 **제품 코드**
(`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`) 안에서
끝나고 프론트 플랫폼(`src/adapters/http/http-execution-v3.ts`)도 무변경이다.
#### 백엔드가 해야 할 일
**봉투 관련 신규 작업은 없다.** `EnvelopeBodyAdvice`, `ErrorResponseFactory`,
`GlobalExceptionHandler`, `EnvelopeAuthenticationEntryPoint`,
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`를 그대로 쓴다. 필요한 것은
§5.4의 오류 코드 등록과 Studio 전용 예외 → `ApiError` 매핑뿐이다.
#### 계약과 프론트가 해야 할 일
이 결정은 세 저장소에 걸친다. 상세는 §5.6.
```text
tech-log-design-package studio-v1.yaml을 봉투 형태로 재정의 (+ 06장 · ADR · MASTER_SPEC · 검증 스크립트)
tech-log-frontend 계약 재생성 + validator 2개를 봉투 언랩으로 교체
tech-log-backend 오류 코드 등록 + 예외 매핑 (봉투 자체는 무변경)
```
### 5.4 오류 코드
계약의 오류 코드 23개를 `ApiError.code`에 그대로 싣는다.
```text
AUTHENTICATION_REQUIRED STUDIO_ACCESS_DENIED DOCUMENT_NOT_FOUND
VERSION_CONFLICT REQUEST_VALIDATION_FAILED VALIDATION_FAILED
VALIDATION_STALE PREVIEW_NOT_FOUND PREVIEW_STALE
PREVIEW_EXPIRED PUBLICATION_NOT_FOUND PUBLICATION_CONFLICT
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
WARNING_ACKNOWLEDGEMENT_REQUIRED IDEMPOTENCY_KEY_REUSED
ASSET_NOT_FOUND ASSET_NOT_READY ASSET_IN_USE
ASSET_QUARANTINED PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
STUDIO_UNAVAILABLE
```
#### 필드 매핑 — 정보 손실 없음
| 기존 계약 `ProblemDetails` | 봉투 |
| --- | --- |
| `code` | `error.code` |
| `retryable` | `error.retryable` (1급) |
| `detail` | `error.message` (client-safe. stack trace·내부 ID 금지) |
| `status` | HTTP status (봉투도 실제 status를 유지한다) |
| `traceId` | `meta.traceId` (D7 — 응답에서 절대 null이 아니다) |
| `fieldErrors` | `error.details` = `ValidationErrorDetails` |
| `latestDocument` | `error.details` = `VersionConflictDetails` |
| `latestPublication` | `error.details` = `PublicationConflictDetails` |
| `conflictingFields` | 위 두 details 안 |
| `type` / `title` | 버린다. 프론트가 이미 `code`에서 합성한다(`studio-error-mapping.ts`의 `synthetic()`) |
| — | `error.category` — 봉투가 추가로 준다 |
`details`는 code별 polymorphic이므로 계약에서 `oneOf` 타입 변형으로 선언한다.
`Object` 자유형으로 두지 않는다.
#### 레지스트리 등록
`docs/registries/error-codes.yaml`에 23개 row를 additive로 추가한다. row 스키마가
요구하는 필드를 모두 채운다 — `category`(10-value `Category` enum), `http_status`,
`retryable`, `owner_layer`, `client_safe_message`, `log_level`, `runbook_link`,
`compatibility_impact: additive`, `required_test`.
runbook 정책: `retryable=false`이고 category가 `AUTH`/`AUTHZ`/`RATE_LIMIT`/`INTERNAL`/
`TRANSIENT_DEPENDENCY`/`PERMANENT_DEPENDENCY`면 `runbook_link`가 필수다.
`VALIDATION`/`NOT_FOUND`/`CONFLICT`/`DATA_INTEGRITY`는 client-error로 면제 가능하다.
따라서 `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ),
`STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY)는 runbook을 함께 작성한다.
category 배정:
```text
AUTH AUTHENTICATION_REQUIRED
AUTHZ STUDIO_ACCESS_DENIED
NOT_FOUND DOCUMENT_NOT_FOUND PREVIEW_NOT_FOUND PUBLICATION_NOT_FOUND
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
ASSET_NOT_FOUND
CONFLICT VERSION_CONFLICT PUBLICATION_CONFLICT IDEMPOTENCY_KEY_REUSED
VALIDATION_STALE PREVIEW_STALE PREVIEW_EXPIRED
ASSET_IN_USE ASSET_NOT_READY
VALIDATION REQUEST_VALIDATION_FAILED VALIDATION_FAILED
WARNING_ACKNOWLEDGEMENT_REQUIRED
PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
DATA_INTEGRITY ASSET_QUARANTINED
TRANSIENT_DEPENDENCY STUDIO_UNAVAILABLE
```
### 5.5 계약 회귀 테스트
**실제 구현 범위 (Task 10, `StudioContractDriftTest`, `:app-bootstrap`의 functional test
소스셋):** springdoc이 노출하는 `/v3/api-docs`를 `src/config/openapi/studio-v1.yaml`과 대조해
**operation id · method · path**가 어긋나면 실패시킨다 (`published ⊆ contract` 방향 — 아직
구현하지 않은 operation이 있어도 green을 유지한다). `dev.caskeleton.adapter.inbound.web.techlog`
아래를 `@ComponentScan`하므로 그 패키지 트리 밖에 컨트롤러를 두면 이 게이트가 못 본다 (스캔
범위를 벗어나는 즉시 유효성을 잃는다는 뜻이므로 §4.1의 패키지 배치를 반드시 따른다). 별도로
`getStudioSession`/`listStudioCatalog` 각 1개 케이스에 대해 성공 응답이
`{success:true, data, meta}` 모양이고 이중 래핑이 없는지도 고정한다 — **모든 operation**의
봉투 래핑을 확인하는 것은 아니다.
**아직 구현하지 않은 것 (Plan 02 몫):**
- 응답 media type 대조 (`application/json` 고정 여부)
- 스키마 필수 필드 대조 (93개 schema·enum·required 필드가 model 생성으로 덮인다는 D3의 전제를
실제로 검증하는 회귀 테스트는 없다 — 지금은 생성이 컴파일에 성공하는 것으로 암묵 검증한다)
- 나머지 17개 operation에 대한 봉투 래핑 확인
- `deleteStudioAsset` 204 본문 없음 확인 (해당 operation 미구현)
**401/403/429 코드 주장 — 삭제.** 이 절은 원래 401/403/429가 각각
`AUTHENTICATION_REQUIRED`/`STUDIO_ACCESS_DENIED`/rate-limit 코드로 난다고 적었으나 이는
코드상 사실이 아니다. 기존(템플릿 소유, 무변경 재사용) 경로가 실제로 내는 코드는:
- 401 — `EnvelopeAuthenticationEntryPoint` → `SecurityErrorClassifier.classifyAuthentication`
(`src/adapter/inbound/web/src/main/java/.../auth/SecurityErrorClassifier.java:21-35`)이
`AUTH_TOKEN_MISSING`(`InsufficientAuthenticationException`) 또는 `AUTH_TOKEN_MALFORMED`(그 외
분류 불가 인증 실패)를 낸다.
- 403 — `EnvelopeAccessDeniedHandler` → `SecurityErrorClassifier.classifyAccessDenied` (같은
파일:39)가 `AUTHZ_INSUFFICIENT_PERMISSION`을 낸다.
- 429 — `RateLimitInterceptor:75`가 `RATE_LIMIT_EXCEEDED`를 낸다.
이 세 코드 모두 계약(studio-v1.yaml) 23종 `ApiError.code` enum에 없다. 429는 계약에
rate-limit 코드 자체가 존재하지 않는다. §5.3 "백엔드가 해야 할 일"이 "봉투 관련 신규 작업은
없다"고 못 박았고 이 경로들은 템플릿 파일(`EnvelopeAuthenticationEntryPoint`,
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`, `SecurityErrorClassifier`)만으로
동작해 Studio가 손댈 수 없다 — 계약을 이 세 코드로 확장할지, 별도 Studio 매핑 계층을 둘지는
이 spec이 결정하지 않은 채 남아 있다. **Plan 02가 착수 전에 결정해야 한다.**
### 5.6 계약 재정의 명세
`studio-v1.yaml`을 다음과 같이 바꾼다. 이것이 세 저장소의 공유 SSOT가 된다.
**추가 스키마**
```text
ResponseMeta requestId, traceId, correlationId, page(nullable)
ApiError code(enum 23), category(enum 10), message, retryable, details(oneOf|null)
ErrorEnvelope success(const false), error(ApiError), meta(ResponseMeta)
ValidationErrorDetails fieldErrors[]
VersionConflictDetails latestDocument, conflictingFields[]
PublicationConflictDetails latestPublication
```
**성공 응답 래핑** — JSON 본문을 갖는 18개 operation(200 15개 / 201 3개)의 응답
스키마를 `{success: const true, data: <기존 payload>, meta: ResponseMeta}` 래퍼로
교체한다. OpenAPI 3.1에 제네릭이 없으므로 payload별 래퍼 스키마를 만든다.
기존 payload 스키마(`StudioSession`, `WorkingCopyDetail`, …)는 **그대로 남긴다** —
프론트가 `components["schemas"]["StudioSession"]`로 도메인 타입을 계속 뽑아 쓴다.
**오류 응답 교체** — `components.responses`의 16개 항목 content를
`application/problem+json` + `ProblemDetails`에서 `application/json` + `ErrorEnvelope`로
바꾼다. `ProblemDetails` 스키마는 제거한다.
`deleteStudioAsset`의 204는 변경 없다.
**`public-v1.yaml` / `studio-management-v1.yaml`** — 두 계약도 `ProblemDetails`를
쓰지만 이번 구현 범위 밖이고 소비자가 없다. 지금 변환하지 않고 각 파일 상단에
"봉투 결정(ADR-006) 반영 대기" 배너를 붙인다. 설계 패키지가 `docs/plans/02`에 쓴
STALE 배너와 같은 방식이다. 구현에 착수할 때 변환한다.
**따라오는 파일**
```text
tech-log-design-package
contracts/openapi/studio-v1.yaml 위 변경
docs/specs/06-api-contract-design.md 7장 오류 계약 재작성
decisions/ADR-006-response-envelope.md 신규 — 봉투 채택 근거와 RFC 7807 미채택 기록
TECH_LOG_MASTER_SPEC.md scripts/build-master-spec.sh 재생성
MANIFEST.sha256 scripts/update-manifest.sh 재생성
scripts/check-contract-parity.py ProblemDetails.code → ApiError.code 참조 변경
scripts/check-consistency.py 동일
tech-log-frontend
src/features/tech-log/contracts/studio/studio-api.openapi.yaml 동기화
src/features/tech-log/contracts/studio/generated.ts 재생성
src/features/tech-log/contracts/studio/canonical-source.json 재생성
src/features/tech-log/contracts/studio/contract.ts 타입 export 확인
src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts
outputValidator passthrough → 봉투 data 언랩
problemValidator PROBLEM(bare) → 봉투 error 언랩 + code enum 검증 유지
src/features/tech-log/adapters/http/studio-error-mapping.ts ApiError → StudioGatewayError
관련 테스트 · 픽스처의 HTTP 본문
```
`mock-studio-gateway.ts` 등 `StudioGateway` 포트를 직접 구현하는 mock은 전송 경계
아래가 아니라 위에 있으므로 **변경 대상이 아니다.** 앱·도메인·프레젠테이션 계층도
언랩이 경계에서 끝나므로 무변경이다.
---
## 6. 도메인 모델
### 6.1 Aggregate
| Aggregate | 소유 테이블 | 비고 |
| --- | --- | --- |
| `Document` | `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation` | `RecordKind.CASE` / `REFERENCE` |
| `OpenQuestion` | `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link` | `RecordKind.QUESTION` |
| `ProjectDecision` | `project_decision` | `RecordKind.PROJECT_DECISION` |
| `Asset` | `asset`, `asset_reference` | |
| `Publication` | `publication`, `publication_event`, `publication_snapshot` | Event·Snapshot은 불변 |
`WorkingCopy`는 Aggregate가 아니라 **API projection**이다. `working_copy` 범용
테이블을 만들지 않는다.
### 6.2 상태 기계 (설계 09장)
```text
Document DRAFT → IN_REVIEW → PUBLISHED → ARCHIVED (+ unpublish: PUBLISHED → DRAFT)
OpenQuestion OPEN → INVESTIGATING → PAUSED → RESOLVED → ARCHIVED
Publication PUBLISHED → REPUBLISHED → UNPUBLISHED → REPUBLISHED
```
**API 용어와 Domain 용어를 분리한다.**
```text
API questionStatus=OPEN ← Domain OPEN | INVESTIGATING | PAUSED
API questionStatus=RESOLVED ← Domain RESOLVED
```
`saveStudioDocument`는 편집 가능한 content field만 저장하며 **lifecycle 전이를
유발하지 않는다.** OPEN 계열 안에서의 값 변화는 무시한다. 프론트가 축약 상태를
보냈다는 이유로 `INVESTIGATING`을 `OPEN`으로 덮어쓰면 조사 이력이 소실된다.
### 6.3 저장하지 않는 값
`nextAction`, `publicationStatus`, `hasUnpublishedChanges`, Preview state는
**조회 시점 계산**이다. `workflow_status` 같은 domain 컬럼에 저장하지 않는다.
---
## 7. 애플리케이션 계층
### 7.1 use case 규약
context별 use case는 `CommandUseCase<C, R>` / `QueryUseCase<Q, R>`를 구현하고
이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다.
```java
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
final class PublishCaseUseCase implements CommandUseCase<PublishCaseCommand, PublishResult> { }
```
트랜잭션 경계는 `@Transactional`이 아니라 `TransactionPort`로 연다.
`TransactionMode`는 `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 계열이고,
`Idempotency`는 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`,
`RepositoryAccess`는 `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`다.
`Idempotency-Key`를 쓰는 mutation은 `KEYED`로 선언한다.
### 7.2 studio facade
```text
StudioWorkingCopyFacade create / get / save / list → 유형별 port.in dispatch
StudioValidationFacade validate → validation artifact 영속
StudioPreviewFacade create / get preview artifact + state 계산
StudioPublicationFacade publish / unpublish / 이력 / snapshot 조회
StudioDashboardQuery union query 기반 대시보드
StudioDocumentQuery union query 기반 문서 목록
StudioCatalogQuery TOPIC | PROJECT | RELATION | EVIDENCE
NextActionCalculator §7.4
StudioDocumentLocator documentId(UUID) → (sourceKind, sourceId)
```
facade는 domain object를 직접 수정하지 않는다. 타 context의 `port.in`만 호출하고
결과를 통합 DTO로 조립한다.
`documentId`는 계약상 **source aggregate id를 그대로 쓴다**(별도 surrogate 없음).
`StudioDocumentLocator`가 `document` / `open_question` / `project_decision` union
query로 kind를 해소한다.
### 7.3 Validation / Preview artifact
```text
studio_validation validationId, sourceKind, sourceId, validatedVersion,
status(INVALID|WARNINGS|VALID), issues(jsonb),
dependencyRevision, validatedAt, validUntil, createdBy
studio_preview previewId, sourceKind, sourceId, sourceVersion,
validationId(FK), dependencyRevision,
renderModel(jsonb), createdAt, expiresAt, createdBy
```
`dependencyRevision`은 검증에 쓴 외부 의존 상태(topic/project publishability,
relation target 상태, asset READY/QUARANTINED, slug/route ownership, catalog
revision, renderer/content-format version)의 identity+version을 정규화해 만든
해시다. 전역 카운터가 아니다.
상태는 저장하지 않고 조회 시 계산한다.
```text
Validation 유효 validatedVersion == 현재 working version
AND dependencyRevision == 현재 계산값
AND now() < validUntil
Preview CURRENT sourceVersion == 현재 working version
AND dependencyRevision == 현재 계산값
AND now() < expiresAt
STALE version 또는 dependencyRevision 불일치
EXPIRED now() >= expiresAt
```
`VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가
바뀜)은 다른 사건이며 코드도 다르다.
### 7.4 nextAction 계산
```text
저장 가능한 형태조차 미달 → CONTINUE_EDITING
현재 version에 대한 Validation 없음 → VALIDATE
현재 Validation = INVALID → FIX_VALIDATION
유효 Validation + 현재 version Preview 없음 → CREATE_PREVIEW
Preview가 STALE 또는 EXPIRED → CREATE_PREVIEW
현재 Validation + 현재 Preview
+ Publication version 불일치 → PUBLISH
Publication version == working version → NONE
```
### 7.5 Publish 트랜잭션 (설계 07장 §14, 20단계)
```text
0. idempotency 선검사 — 동일 key + 동일 fingerprint면 최초 응답 재생, 이하 미실행
1. Document SELECT FOR UPDATE
2. expectedVersion 검증 불일치 → VERSION_CONFLICT
3. validationId 조회 + dependencyRevision 재계산 비교 불일치 → VALIDATION_STALE
4. previewId 조회 + sourceVersion / dependencyRevision 비교
불일치 → PREVIEW_STALE, 만료 → PREVIEW_EXPIRED
5. acknowledgedWarningCodes가 WARNING 집합을 덮는지
미달 → WARNING_ACKNOWLEDGEMENT_REQUIRED
6. 유형별 publication validation (게시 필수 필드 / slug 충돌)
7. Markdown 분석
8. Asset READY 및 사용 위치 alt/decorative 검증
9. 공개 payload 생성
10. Publication Event 생성 (PUBLISHED | REPUBLISHED)
11. Publication Snapshot 생성 (immutable)
12. public_resource_projection upsert
13. public_route canonical/alias 변경
14. public_resource_tag / project link 교체
15. PUBLISHED asset_reference 교체
16. asset.first_published_at 갱신
17. Publication aggregate 갱신 (latest_event_id, publication_revision)
18. Document publish metadata 갱신
19. 선택적 ProjectActivity 생성
20. Commit
```
전 단계가 하나의 트랜잭션이다.
**Snapshot의 render model은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한
Preview의 render model을 그대로 쓴다.** 재렌더링하면 승인한 화면과 공개된 화면이
달라질 수 있다.
Unpublish는 `expectedPublicationRevision` 검증 → `UNPUBLISHED` Event(반드시
`sourcePublishedEventId` 보유) → Publication 상태 전환 및 revision 증가 →
Projection `ACTIVE → WITHDRAWN` → Working `workflow_status → DRAFT` → route 유지 →
commit. Snapshot은 삭제하지 않는다.
---
## 8. 영속화
### 8.1 마이그레이션
`PostgreSqlPersistenceConfig.java:57`이 Flyway location을
`classpath:db/migration/postgresql`로 고정한다. 기존 최대 버전이 V6이고
`out-of-order: false`이므로 **`V7__techlog_core.sql`부터** 추가한다.
이번 범위에 필요한 테이블:
```text
topic tag document case_detail reference_detail document_tag document_relation
open_question question_point question_update question_tag question_document_link
project project_decision project_document_link project_question_link project_activity
asset asset_reference
studio_validation studio_preview
publication publication_event publication_snapshot
public_resource_projection public_route public_resource_tag public_resource_project_link
```
`publication.latest_event_id` ↔ `publication_event.publication_id` 순환 FK는
`DEFERRABLE INITIALLY DEFERRED`로 선언한다. 즉시 검사로 두면 첫 게시가 불가능하다.
`publication_event` / `publication_snapshot`은 어떤 cleanup job도 삭제하지 않는다.
### 8.2 멱등 — 기존 자산 재사용 (D5)
설계의 `studio_idempotency` 테이블을 만들지 않는다.
| 설계 `studio_idempotency` | 기존 `idempotency_record` |
| --- | --- |
| `idempotency_key` | `idempotency_key` |
| `operation_id` | `use_case_name` |
| `request_fingerprint` | `request_hash` (char(64) sha256) |
| `response_status` / `response_body` | `response_payload` |
| `created_at` / `expires_at` | `created_at` / `expires_at` |
| `created_by` | `principal` (+ `tenant`) |
기존 것이 상위집합이고 `IdempotencyExecutorV2`, `IdempotencyStorePortV2`,
`IdempotencyKeySupport`(header 추출 · scope · sha256 fingerprint · JSON codec)까지
있다. 설계 대비 결손은 두 가지뿐이며 web 계층에서 채운다.
1. 재생 시 `Idempotency-Replayed: true` 응답 헤더
2. fingerprint 불일치 시 `IDEMPOTENCY_KEY_REUSED` ProblemDetails (409)
적용 대상: create / save / validate / create preview / publish / unpublish /
asset upload · update · delete.
### 8.3 읽기·쓰기 분리
- 쓰기: JPA aggregate + `@Version` 낙관적 잠금
- Studio 목록 / 대시보드 / 카탈로그: `JdbcClient` union query (도메인 repository 우회)
- 공통 CRUD repository를 만들지 않는다
---
## 9. 재사용 매핑 (신규 구현 금지)
| 설계 요구 | 재사용 대상 |
| --- | --- |
| `Idempotency-Key` | `application/idempotency/v2`, `web/idempotency/IdempotencyKeySupport`, `idempotency_record` |
| `expectedVersion` / 409 | JPA `@Version`, `web/conditional/ETags`, `PreconditionFailedException` |
| Studio cursor 페이지네이션 | `web/cursor/CursorCodec`, `web/pagination/PageParams` |
| 오류 응답 골격 | `shared/response/{Envelope,ApiError,ResponseMeta}`, `web/error/ErrorResponseFactory`, `GlobalExceptionHandler` — **무변경 재사용** |
| 401 / 403 / 429 | `EnvelopeAuthenticationEntryPoint`, `EnvelopeAccessDeniedHandler`, `RateLimitInterceptor` — **무변경 재사용** |
| Keycloak 세션 · CSRF | `web/auth/SecurityConfig`, `JwtDecoderConfig`, `PrimitiveSessionSecurityContextRepository`, `RedisSessionWebConfig` |
| 권한 | `application/security/RequiresPermission`, `web/authz/RolePermissionPolicy`, `RequiresPermissionAuthorizationManager` |
| Asset 업로드 · 저장 | `adapter:outbound:fileserver`, `adapter:outbound:objectstorage` |
| ID 생성 | `adapter:outbound:identifier` |
| 캐시 | `application/cache`, `adapter:outbound:cache-redis` (필요 입증 시에만) |
| 관측 | `web/observability`, `docs/registries/{metrics,mdc-keys,headers}.yaml` |
| 트랜잭션 | `application/transaction/TransactionPort` |
**Asset은 새 저장 계층을 만들지 않는다.** `asset` 테이블은 metadata·`asset_key
lifecycle만 소유하고 바이너리 저장·전송은 fileserver/objectstorage 어댑터에 위임한다.
`asset_key`(안정 참조)와 `object_key`(저장 위치)를 분리한다 — 본문에 object storage
URL을 저장하지 않는다.
---
## 10. 렌더러
`PublicRenderModel`은 Markdown을 의미 블록으로 변환한 결과다. 계약이 정의하는
블록·인라인 타입:
```text
블록 HeadingBlock ParagraphBlock CodeBlock BlockquoteBlock CalloutBlock
OrderedListBlock UnorderedListBlock DataTableBlock EvidenceFigureBlock
인라인 InlineText InlineStrong InlineEmphasis InlineCode InlineLink InlineStatus
```
제약:
- Public과 Studio Preview가 **같은 의미의 렌더 결과**를 써야 한다 (ADR-005).
- Snapshot은 `contentFormatVersion`, `rendererContractVersion`, asset manifest를 함께
보존한다. 이후 Asset이 교체되어도 과거 Snapshot의 표현이 변하지 않는다.
- Asset은 `assetKey`로 참조하고 렌더 시 `ResolvedAsset`으로 해소한다.
**이 항목이 이번 구현의 최대 리스크다.** 슬라이스 3 착수 전에 프론트의 기존 렌더
모델 구현(`src/features/tech-log/adapters/mock/project-public-render-model.ts` 및
static content 경로)을 기준선으로 대조해 블록 의미가 일치하는지 확인한다.
---
## 11. 테스트 전략
leaf별 템플릿 정책을 그대로 따른다.
| 대상 | 방식 |
| --- | --- |
| `domain-core` | 순수 JUnit. 상태 전이·불변 조건 |
| `application-core` | 손수 만든 fake 포트. 웹·영속 컨텍스트 금지 |
| `adapter:inbound:web` | 전송 slice 테스트 + §5.5 봉투 래핑·오류 코드 회귀 테스트 |
| `adapter:outbound:persistence-jpa` | 매핑·포트 계약 테스트. 벤더 의미가 필요한 것(deferrable FK, `SELECT FOR UPDATE`)은 `postgresqlIntegrationTest` 소스셋 |
| `app-bootstrap` | 배선·`TechLogBoundaryArchTest`·계약 회귀 테스트 |
필수 시나리오:
- publish 재시도가 중복 `publication_event`를 만들지 않는다
- `VALIDATION_STALE`과 `VALIDATION_FAILED`가 구분된다
- `saveStudioDocument`가 `INVESTIGATING`을 `OPEN`으로 되돌리지 않는다
- unpublish 후 과거 Snapshot이 그대로 조회된다
- 성공 응답이 `{success:true, data, meta}`이고 `meta.traceId`가 non-null이다
- 오류 응답이 `{success:false, error, meta}`이고 `error.code`가 계약의 23개 중 하나다
- 409가 `error.details`로 `latestDocument` / `latestPublication`을 싣는다
검증 명령 (`src/`에서):
```bash
./gradlew :domain-core:test --console=plain
./gradlew :application-core:test --console=plain
./gradlew :adapter:inbound:web:test --console=plain
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain
```
---
## 12. 슬라이스
| # | 내용 | 완료 판정 |
| --- | --- | --- |
| 0 | **계약 봉투 재정의(§5.6) → 프론트 재생성·validator 교체**, 생성기 스파이크 → 배선, 계약 복사, `TechLogBoundaryArchTest`, 오류코드 레지스트리 23개, `V7__techlog_core.sql` | 빌드·아키텍처 검증 통과 + 3 저장소 계약 parity 통과 |
| 1 | `getStudioSession`, `listStudioCatalog` | 프론트 로그인·카탈로그 실동작 |
| 2 | `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument` (+ 낙관적 잠금, 멱등) | 프론트 편집 실동작 |
| 3 | `validateStudioDocument`, `createStudioPreview`, `getCurrentStudioPreview`, `dependencyRevision`, `nextAction`, 렌더러 | 프론트 검증·미리보기 실동작 |
| 4 | `publishStudioDocument`, `unpublishStudioPublication`, `listStudioPublications`, `getStudioPublicationSnapshot`, `getStudioDashboard` | 프론트 게시 실동작 |
| 5 | asset 5종 | 프론트 자산 실동작 |
`getStudioDashboard`는 publication·validation 데이터에 의존하므로 슬라이스 4에서
완성한다.
---
## 13. 리스크
| 리스크 | 완화 |
| --- | --- |
| openapi-generator × Spring Boot 4.0.0 미검증 | 슬라이스 0 첫 스텝을 폐기용 스파이크로. 실패 시 §5.2 폴백 |
| 3 저장소(계약·프론트·백엔드) 동기화 실패 | 슬라이스 0에서 계약을 먼저 확정하고 `check-contract-parity.py`를 게이트로 삼는다. 백엔드는 §5.5 계약 회귀 테스트로 고정 |
| 프론트 언랩 누락 시 조용한 실패 (`outputValidator`가 passthrough라 전송 계층이 잡지 못한다) | 언랩 validator에 `success`/`data` 존재 검증을 넣어 하드 실패로 바꾼다 |
| 렌더 모델 의미 불일치 | 슬라이스 3 착수 전 프론트 구현 대조 |
| publish 20단계 트랜잭션 복잡도 | 단계별 실패 코드를 먼저 테스트로 고정한 뒤 구현 |
| 설계 DDL과 템플릿 스키마 충돌 | `studio_idempotency` 제거(D5) 외에는 이름 충돌 없음을 V7 작성 시 재확인 |
---
## 14. 운영 제약
- **커밋 금지** — `AGENTS.md:64` commit 정책 `human-only`. 브랜치 생성과 파일 작성까지
수행하고 stage/commit/push는 사용자가 한다. 이 제약은 `tech-log-backend`의 규약이며
`tech-log-design-package` / `tech-log-frontend`에는 적용되지 않는다.
- 이 설계는 **세 저장소**를 건드린다(§5.6). 계약이 SSOT이므로 순서는
`tech-log-design-package` → `tech-log-frontend` → `tech-log-backend`다.
- `git flow init`은 워킹트리에 미커밋 삭제분(`*-superpowers-package/`,
`scripts/verify-httpclient-docs.py`)이 있어 중단되었다. gitflow 설정은 기록했고
`develop` / `feature/techlog-studio-backend` 브랜치는 수동 생성했다. 미커밋 삭제분은
손대지 않았다.
---
## 15. 참조
- 설계: `tech-log-design-package/docs/specs/{06,07,08,09}`, `decisions/ADR-00{1..5}`
- 계약: `tech-log-design-package/contracts/openapi/studio-v1.yaml`
- 프론트 계약: `tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml`
- 프론트 실행 경로: `tech-log-frontend/src/adapters/http/http-execution-v3.ts`
- 저장소 권위: `src/config/architecture/modules.json`, `src/settings.gradle`, `AGENTS.md`