# 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..vo Value Object :application-core dev.caskeleton.application.techlog..port.in Command · Query · *UseCase 인터페이스 dev.caskeleton.application.techlog..port.out Repository · Renderer · Clock 포트 dev.caskeleton.application.techlog..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..entity dev.caskeleton.adapter.outbound.persistence.techlog..repository dev.caskeleton.adapter.outbound.persistence.techlog..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`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가 아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로 이 본문을 **한 번 더 감싼다**. ```text controller → StudioSessionEnvelope EnvelopeBodyAdvice → Envelope 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` / `QueryUseCase`를 구현하고 이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다. ```java @UseCaseCapability( transactionMode = TransactionMode.WRITE, idempotency = Idempotency.KEYED, repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) final class PublishCaseUseCase implements CommandUseCase { } ``` 트랜잭션 경계는 `@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`