openapi: 3.1.0 info: title: Tech Log Studio API version: 3.0.0 description: | Tech Log Studio orchestration 계약이다. 이 파일이 Studio HTTP 계약의 canonical source다. Frontend의 `contracts/studio-api.openapi.yaml`은 이 계약에서 생성되어야 하며 독립적인 두 번째 canonical source가 되어서는 안 된다. (ADR-004) ## 이 계약의 성격 현재 Studio UI의 단일 문서 제작 흐름을 그대로 계약으로 승격한 것이다. ```text 작업본 → 편집 → 저장 → 검증 → Public Preview → 게시/재게시 → 게시 기록/Snapshot ``` 유형별 CMS 메뉴(Case 관리 / Reference 관리 / Question 관리)는 이 계약의 primary mental model이 아니다. 유형별 specialized management capability는 `studio-management-v1.yaml`에 secondary API로 보존한다. ## WorkingCopy는 API projection이다 `WorkingCopy`는 Domain Aggregate가 아니다. Studio API가 여러 도메인을 동일한 편집 경험으로 보여주기 위한 API/Application projection이며, Backend는 이를 기존 유형별 use case로 dispatch한다. ```text createStudioDocument(kind=CASE) → CreateCaseDraft saveStudioDocument(kind=CASE) → UpdateCaseDraft validateStudioDocument(kind=CASE) → CasePublicationValidator publishStudioDocument(kind=CASE) → CasePublicationHandler kind=REFERENCE → Reference capability kind=QUESTION → Inquiry capability kind=PROJECT_DECISION → ProjectDecision capability ``` `documentId`는 source aggregate id를 그대로 사용한다. 별도 surrogate Studio id를 만들지 않으며 `working_copy` 범용 테이블도 만들지 않는다. ## API 용어 ↔ Domain 용어 mapper 경계 API enum은 UI 용어이고 Domain enum은 그대로 보존한다. 변환은 mapper가 한다. | API (이 계약) | Domain | 비고 | |---|---|---| | `questionStatus=OPEN` | `OPEN`, `INVESTIGATING`, `PAUSED` | 축약 view. 저장이 Domain 상태를 덮어쓰지 않는다 | | `questionStatus=RESOLVED` | `RESOLVED` | Resolve command로 해석하며 기존 resolve invariant를 통과해야 한다 | | `decisionStatus=ADOPTED` | `ACCEPTED`/`ADOPTED` | Domain enum을 UI 용어 때문에 변경하지 않는다 | | `documentId` | `source_kind` + `source_id` | Publication 계열 테이블은 두 컬럼으로 저장한다 | | `nextAction` | (없음) | Domain state가 아니라 Studio projection이다. `workflow_status`에 저장하지 않는다 | `saveStudioDocument`는 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain Action이 담당한다. Frontend가 `OPEN`을 보냈다고 해서 `INVESTIGATING → OPEN`으로 자동 전이하면 안 된다. ## Frontend 계약과의 현재 차이 (2026-08-17 실측) `tech-log-frontend`의 `src/features/tech-log/contracts/studio/studio-api.openapi.yaml`과 대조한 결과 다음이 이미 일치한다. ```text operation 13개 전부 operationId · method · path 일치 RecordKind 양쪽 4종 동일 (CASE / REFERENCE / QUESTION / PROJECT_DECISION) 오류 코드 Frontend 15종이 이 계약의 23종에 모두 포함 ``` Backend가 추가로 제공하는 것은 두 가지이며, Frontend가 이 계약에서 재생성할 때 흡수된다. 1. Asset operation 5개와 `getStudioSession`. Frontend는 아직 Asset capability를 구현하지 않았다. 2. mutating operation의 `X-CSRF-TOKEN`. Frontend 계약은 `security: []`이며 이 header를 선언하지 않는다. ## 게시 경로는 하나다 `studio-management-v1.yaml`의 publish/unpublish 계열 operation도 이 계약과 동일한 Publication Event/Snapshot 경로를 거쳐야 한다. 서로 다른 두 개의 게시 경로를 허용하지 않는다. servers: - url: / security: - sessionCookie: [] tags: - name: Session description: Studio 접근 제어 - name: Dashboard description: Workspace 대시보드 - name: Documents description: 통합 Working Copy 편집 - name: Validation description: Validation Artifact - name: Preview description: 인증된 Public Preview Artifact - name: Publication description: Publication Aggregate / Event / Snapshot - name: Catalog description: Editor picker 통합 조회 - name: Assets description: Asset Library / Picker / Upload paths: /api/v1/studio/session: get: operationId: getStudioSession tags: [Session] summary: 현재 Studio 세션과 CSRF 토큰을 조회한다 responses: "200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/dashboard: get: operationId: getStudioDashboard tags: [Dashboard] summary: Get the Studio dashboard description: | `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. responses: "200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/documents: get: operationId: listStudioDocuments tags: [Documents] summary: List working copies description: | Case/Reference/OpenQuestion/ProjectDecision은 서로 다른 table/aggregate에 존재한다. 공통 CRUD repository를 만들지 않고 query side에서 union projection을 구성한다(`StudioDocumentQueryService`). `cursor`는 normalized filter/sort와 결합된 opaque 값이다. 필터가 달라진 cursor 재사용은 거절한다. parameters: - { $ref: "#/components/parameters/Query" } - { $ref: "#/components/parameters/DocumentKind" } - { $ref: "#/components/parameters/PublicationStatus" } - { $ref: "#/components/parameters/NextAction" } - { $ref: "#/components/parameters/ProjectId" } - { $ref: "#/components/parameters/DocumentSort" } - { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } post: operationId: createStudioDocument tags: [Documents] summary: Create a working copy description: 불완전한 초안도 생성할 수 있다. 생성은 Public Projection을 변경하지 않는다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateDocumentInput" } } } } responses: "201": description: Created working copy headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/documents/{documentId}: parameters: [{ $ref: "#/components/parameters/DocumentId" }] get: operationId: getStudioDocument tags: [Documents] summary: Get a working copy and its current state responses: "200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "503": { $ref: "#/components/responses/StudioUnavailable" } put: operationId: saveStudioDocument tags: [Documents] summary: Save a full working copy description: | 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details` (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SaveDocumentCommand" } } } } responses: "200": description: Saved working-copy detail headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/documents/{documentId}/validate: parameters: [{ $ref: "#/components/parameters/DocumentId" }] post: operationId: validateStudioDocument tags: [Validation] summary: Validate a saved working copy description: | 단순 request validation이 아니다. 다음 체인을 모두 수행한다. ```text Schema/Input Validation → 유형별 Domain Validation → 관계/Project/Topic 존재 검증 → Asset READY 검증 → Slug/Route 충돌 검증 → Publication Validation ``` 결과는 일급 artifact인 `ValidationReport`로 영속되며 `validationId`로 `createStudioPreview`와 `publishStudioDocument`가 이를 참조한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ValidateDocumentCommand" } } } } responses: "200": description: Validation report headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/documents/{documentId}/preview: parameters: [{ $ref: "#/components/parameters/DocumentId" }] get: operationId: getCurrentStudioPreview tags: [Preview] summary: Get the latest preview and its computed state description: | Preview 상태(`CURRENT`/`STALE`/`EXPIRED`)는 서버가 계산한다. anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 Studio API로만 조회한다. responses: "200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/PreviewNotFound" } "503": { $ref: "#/components/responses/StudioUnavailable" } post: operationId: createStudioPreview tags: [Preview] summary: Create a public-layout preview description: | 저장된 working version + validationId + dependency revision을 묶어 `PublicRenderModel` snapshot을 만든다. Preview는 Public과 동일한 semantic renderer와 Asset resolver를 사용한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreatePreviewCommand" } } } } responses: "201": description: Created preview headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/PreviewRejected" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/documents/{documentId}/publish: parameters: [{ $ref: "#/components/parameters/DocumentId" }] post: operationId: publishStudioDocument tags: [Publication] summary: Publish or republish a validated preview description: | 하나의 transaction으로 다음을 수행한다. ```text 1. source version lock/check 2. validationId / current dependency revision 검증 3. previewId / current dependency revision 검증 4. warning acknowledgement 검증 5. 유형별 publication validation 6. Publication Event 생성 (PUBLISHED | REPUBLISHED) 7. Publication Snapshot 생성 (immutable) 8. public_resource_projection 교체 9. public_route 교체 / alias 처리 10. public relation/tag/project projection 교체 11. published Asset reference 교체 12. Publication Aggregate 갱신 13. commit ``` 재시도가 중복 Publication Event를 만들면 안 된다. `Idempotency-Key`로 최초 결과를 재생한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishDocumentCommand" } } } } responses: "200": description: Publication aggregate and immutable event headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/PublishRejected" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/publications: get: operationId: listStudioPublications tags: [Publication] summary: List immutable publication events description: Events are ordered by occurredAt DESC, then publicationEventId. parameters: - { $ref: "#/components/parameters/Query" } - { $ref: "#/components/parameters/PublicationEventType" } - { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/publications/{publicationId}/unpublish: parameters: [{ $ref: "#/components/parameters/PublicationId" }] post: operationId: unpublishStudioPublication tags: [Publication] summary: Unpublish the current publication description: | `UNPUBLISHED` Event를 생성하고 current projection을 `WITHDRAWN`으로 바꾼다. canonical route ownership/history는 보존한다. 과거 Snapshot은 삭제하거나 재계산하지 않는다. Backend 내부에서 working copy가 `DRAFT`로 되돌아가는 lifecycle 전이는 유지하되 이 API로 직접 노출하지 않는다. 응답은 `publicationStatus=UNPUBLISHED`와 다음 `nextAction`으로 표현한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UnpublishCommand" } } } } responses: "200": description: Updated publication aggregate and event headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/PublicationNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/publications/{publicationEventId}/preview: parameters: [{ $ref: "#/components/parameters/PublicationEventId" }] get: operationId: getStudioPublicationSnapshot tags: [Publication] summary: Get an immutable publication snapshot description: | `PUBLISHED`/`REPUBLISHED` Event 시점에 저장된 불변 `PublicRenderModel`을 반환한다. 현재 Working Copy나 현재 Projection에서 재계산하지 않는다. `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. responses: "200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/PublicationSnapshotNotFound" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/catalog: get: operationId: listStudioCatalog tags: [Catalog] summary: List catalog entries for editor pickers description: | Editor picker가 사용하는 통합 read API다. Backend source는 다음과 같다. ```text TOPIC → topic capability PROJECT → project capability RELATION → Case/Reference/Question/ProjectDecision 중 연결 가능한 대상 EVIDENCE → resolution/decision 근거로 사용할 수 있는 공개 기록 ``` Asset 검색/업로드/metadata는 Catalog가 아니라 `/api/v1/studio/assets`가 소유한다. parameters: - { $ref: "#/components/parameters/CatalogType" } - { $ref: "#/components/parameters/Query" } - { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/assets: get: operationId: listStudioAssets tags: [Assets] summary: List assets for the library and the editor picker parameters: - { $ref: "#/components/parameters/Query" } - { $ref: "#/components/parameters/AssetKindFilter" } - { $ref: "#/components/parameters/AssetStatusFilter" } - { $ref: "#/components/parameters/Cursor" } - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } post: operationId: uploadStudioAsset tags: [Assets] summary: Upload an image, diagram, or attachment description: | MVP 업로드는 `multipart/form-data`를 사용한다. 향후 presigned/resumable로 교체하더라도 같은 Asset port 뒤에서 처리한다. Backend는 확장자를 신뢰하지 않는다. MIME/type 검증과 크기 제한을 적용하고 검사에 실패하면 `REJECTED` 또는 `QUARANTINED`로 저장한다. `READY` 전환은 검증 완료 후에만 일어난다. `image/svg+xml`을 지원하지만 업로드 원문을 HTML에 inline하지 않는다. Public/Preview는 검증된 delivery URL을 ``로 렌더링한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: required: true content: multipart/form-data: schema: { $ref: "#/components/schemas/AssetUploadForm" } encoding: file: { contentType: "image/png, image/jpeg, image/webp, image/gif, image/svg+xml, application/pdf" } responses: "201": description: Stored asset headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "409": { $ref: "#/components/responses/CommandConflict" } "413": { $ref: "#/components/responses/PayloadTooLarge" } "415": { $ref: "#/components/responses/UnsupportedMediaType" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } /api/v1/studio/assets/{assetId}: parameters: [{ $ref: "#/components/parameters/AssetId" }] get: operationId: getStudioAsset tags: [Assets] summary: Get an asset with its usage responses: "200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/AssetNotFound" } "503": { $ref: "#/components/responses/StudioUnavailable" } put: operationId: updateStudioAsset tags: [Assets] summary: Update asset metadata description: | `altText`, `decorative`, `kind`와 `READY ↔ ARCHIVED` 전환만 허용한다. `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UpdateAssetCommand" } } } } responses: "200": description: Updated asset headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } } "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/AssetNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/AssetRejected" } "503": { $ref: "#/components/responses/StudioUnavailable" } delete: operationId: deleteStudioAsset tags: [Assets] summary: Delete an unused asset description: | 공개 이력이 있는 Asset과 사용 중인 Asset은 hard delete하지 않는다. 두 경우 모두 `ASSET_IN_USE`로 거절하고 `ARCHIVED` 전환을 사용한다. parameters: - { $ref: "#/components/parameters/IdempotencyKey" } - { $ref: "#/components/parameters/CsrfToken" } responses: "204": { description: Deleted } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/AssetNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "503": { $ref: "#/components/responses/StudioUnavailable" } components: securitySchemes: sessionCookie: type: apiKey in: cookie name: TECHLOG_SESSION description: | Backend Session Cookie. `TECH_LOG_ADMIN` 권한이 필요하다. mutating operation은 추가로 `X-CSRF-TOKEN` header를 요구한다. parameters: DocumentId: { name: documentId, in: path, required: true, schema: { type: string, format: uuid } } PublicationId: { name: publicationId, in: path, required: true, schema: { type: string, format: uuid } } PublicationEventId: { name: publicationEventId, in: path, required: true, schema: { type: string, format: uuid } } AssetId: { name: assetId, in: path, required: true, schema: { type: string, format: uuid } } IdempotencyKey: name: Idempotency-Key in: header required: true description: | 동일 key + 동일 normalized request는 최초 결과를 재생한다. 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. schema: { type: string, minLength: 1, maxLength: 200 } CsrfToken: name: X-CSRF-TOKEN in: header required: true description: "`getStudioSession`이 발급한 CSRF 토큰." schema: { type: string, minLength: 1, maxLength: 200 } Query: { name: q, in: query, description: Free-text query normalized as part of the opaque cursor, schema: { type: string, maxLength: 100 } } Cursor: { name: cursor, in: query, description: Opaque cursor bound to normalized filters and sort, schema: { type: string, minLength: 1, maxLength: 2000 } } Limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } DocumentKind: { name: kind, in: query, schema: { $ref: "#/components/schemas/RecordKind" } } PublicationStatus: { name: publicationStatus, in: query, schema: { $ref: "#/components/schemas/PublicationStatus" } } NextAction: { name: nextAction, in: query, schema: { $ref: "#/components/schemas/NextAction" } } ProjectId: { name: projectId, in: query, schema: { type: string, format: uuid } } DocumentSort: { name: sort, in: query, schema: { type: string, enum: [UPDATED_DESC, UPDATED_ASC, TITLE_ASC], default: UPDATED_DESC } } PublicationEventType: { name: type, in: query, schema: { $ref: "#/components/schemas/PublicationEventType" } } CatalogType: { name: type, in: query, required: true, schema: { $ref: "#/components/schemas/CatalogEntryType" } } AssetKindFilter: { name: kind, in: query, schema: { $ref: "#/components/schemas/AssetKind" } } AssetStatusFilter: { name: managementStatus, in: query, schema: { $ref: "#/components/schemas/AssetManagementStatus" } } headers: IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } } responses: MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } CommandConflict: description: | Command conflicts with current state, freshness, or idempotency. `ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE] content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } PreviewRejected: description: Preview 생성이 도메인 규칙으로 거절되었다 x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } PublishRejected: description: | Publication validation이 실패했다. `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 현재 Validation의 WARNING 집합을 덮지 못한 경우다. x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } AssetRejected: description: Asset metadata 변경이 거절되었다 x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } } schemas: # ------------------------------------------------------------- envelope # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고 # 응답만 Envelope으로 감싼다. ResponseMeta: type: object additionalProperties: false required: [requestId, traceId] properties: requestId: { type: string, minLength: 1, maxLength: 200 } traceId: { type: string, minLength: 1, maxLength: 200 } correlationId: { type: [string, "null"], maxLength: 200 } page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." } ApiError: type: object additionalProperties: false required: [code, category, message, retryable] properties: code: type: string enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT, REQUEST_VALIDATION_FAILED, DOCUMENT_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] category: type: string enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL] message: { type: string, minLength: 1, maxLength: 5000 } retryable: { type: boolean } details: oneOf: - $ref: "#/components/schemas/ValidationErrorDetails" - $ref: "#/components/schemas/VersionConflictDetails" - $ref: "#/components/schemas/PublicationConflictDetails" - type: "null" ErrorEnvelope: type: object additionalProperties: false required: [success, error, meta] properties: success: { type: boolean, const: false } error: { $ref: "#/components/schemas/ApiError" } meta: { $ref: "#/components/schemas/ResponseMeta" } ValidationErrorDetails: type: object additionalProperties: false required: [fieldErrors] properties: fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } VersionConflictDetails: type: object additionalProperties: false required: [latestDocument] properties: latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" } conflictingFields: type: array uniqueItems: true maxItems: 200 items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" } PublicationConflictDetails: type: object additionalProperties: false required: [latestPublication] properties: latestPublication: { $ref: "#/components/schemas/PublicationAggregate" } StudioSessionEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/StudioSession" } meta: { $ref: "#/components/schemas/ResponseMeta" } StudioDashboardEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/StudioDashboard" } meta: { $ref: "#/components/schemas/ResponseMeta" } DocumentPageEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/DocumentPage" } meta: { $ref: "#/components/schemas/ResponseMeta" } WorkingCopyDetailEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/WorkingCopyDetail" } meta: { $ref: "#/components/schemas/ResponseMeta" } WorkingCopyEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/WorkingCopy" } meta: { $ref: "#/components/schemas/ResponseMeta" } ValidationReportEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/ValidationReport" } meta: { $ref: "#/components/schemas/ResponseMeta" } PreviewDetailEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/PreviewDetail" } meta: { $ref: "#/components/schemas/ResponseMeta" } PublicPreviewEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/PublicPreview" } meta: { $ref: "#/components/schemas/ResponseMeta" } PublishResultEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/PublishResult" } meta: { $ref: "#/components/schemas/ResponseMeta" } PublicationPageEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/PublicationPage" } meta: { $ref: "#/components/schemas/ResponseMeta" } PublicationSnapshotEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/PublicationSnapshot" } meta: { $ref: "#/components/schemas/ResponseMeta" } CatalogPageEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/CatalogPage" } meta: { $ref: "#/components/schemas/ResponseMeta" } AssetPageEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/AssetPage" } meta: { $ref: "#/components/schemas/ResponseMeta" } AssetDetailEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/AssetDetail" } meta: { $ref: "#/components/schemas/ResponseMeta" } AssetEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/Asset" } meta: { $ref: "#/components/schemas/ResponseMeta" } # ---------------------------------------------------------------- session StudioSession: type: object additionalProperties: false required: [authenticated, displayName, roles, csrfToken, csrfHeaderName] properties: authenticated: { type: boolean } displayName: { type: string, minLength: 1, maxLength: 120 } roles: { type: array, uniqueItems: true, maxItems: 20, items: { type: string, minLength: 1, maxLength: 60 } } csrfToken: { type: string, minLength: 1, maxLength: 200 } csrfHeaderName: { type: string, const: X-CSRF-TOKEN } # ------------------------------------------------------------ enumerations RecordKind: type: string enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] description: | Studio 편집 대상 유형. API projection discriminator이며 Domain Aggregate가 아니다. `PROJECT_DECISION`은 `ProjectDecision` capability로 dispatch된다. PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] } NextAction: type: string enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] description: | 서버가 계산하는 Studio projection이다. Domain state machine이 아니며 `workflow_status` 같은 domain 컬럼에 저장하지 않는다. # --------------------------------------------------------- shared fragments RelationInput: type: object additionalProperties: false required: [id, targetId, reason, order] properties: id: { type: [string, "null"], format: uuid } targetId: { type: [string, "null"], format: uuid } reason: { type: string, maxLength: 100000 } order: { type: integer, minimum: 0 } Relation: type: object additionalProperties: false required: [id, targetId, reason, order] properties: id: { type: string, format: uuid } targetId: { type: string, format: uuid } reason: { type: string, maxLength: 100000 } order: { type: integer, minimum: 0 } OrderedText: type: object additionalProperties: false required: [id, text, order] properties: id: { type: string, format: uuid } text: { type: string, minLength: 1, maxLength: 100000 } order: { type: integer, minimum: 0 } ReferenceRule: type: object additionalProperties: false required: [id, title, body, order] properties: id: { type: string, format: uuid } title: { type: string, minLength: 1, maxLength: 120 } body: { type: string, minLength: 1, maxLength: 100000 } order: { type: integer, minimum: 0 } QuestionOption: type: object additionalProperties: false required: [id, title, description, order] properties: id: { type: string, format: uuid } title: { type: string, minLength: 1, maxLength: 120 } description: { type: string, maxLength: 100000 } order: { type: integer, minimum: 0 } QuestionResolution: type: object additionalProperties: false required: [summary, evidenceTargetId, linkLabel] properties: summary: { type: string, maxLength: 100000 } evidenceTargetId: { type: [string, "null"], format: uuid } linkLabel: { type: string, maxLength: 120 } # -------------------------------------------------------- working copy input WorkingCopyInputBase: type: object description: | 불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 허용한다. 게시 가능 여부는 `validateStudioDocument`가 판단한다. required: [kind, title, slug, summary, topicId, projectId, relations] properties: kind: { $ref: "#/components/schemas/RecordKind" } title: { type: string, maxLength: 120 } slug: oneOf: - { type: string, const: "" } - { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } summary: { type: string, maxLength: 300 } topicId: { type: [string, "null"], format: uuid } projectId: type: [string, "null"] format: uuid description: "`kind=PROJECT_DECISION`은 게시 시점에 non-null이어야 한다. 저장 시점에는 강제하지 않는다." relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/RelationInput" } } CaseInput: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } - type: object required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] properties: kind: { type: string, enum: [CASE] } problem: { type: string, maxLength: 100000 } conclusion: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 } reproduction: { type: string, maxLength: 100000 } lastVerifiedOn: { type: [string, "null"], format: date } bodyMarkdown: type: string maxLength: 100000 description: | Markdown 원문. Asset은 `:::evidence key=""` directive로 참조한다. object storage URL을 원문에 직접 저장하지 않는다. ReferenceInput: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } - type: object required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: kind: { type: string, enum: [REFERENCE] } purpose: { type: string, maxLength: 100000 } rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } verifiedOn: { type: [string, "null"], format: date } QuestionInput: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } - type: object # `resolution` 은 여기 없다. 미해결 질문에는 해결 내용이 없고, 그것을 required 로 두면 # Java 생성기가 nullable 여부와 무관하게 @NotNull 을 찍는다 — oneOf 로 적은 null 을 그 # 생성기는 읽지 못한다. 실제로 그래서 Question 작업본을 만들 수 없었다: 프론트가 계약대로 # resolution: null 을 보냈고 백엔드가 422 로 거절했다. 같은 목록의 `questionStatus` 가 # 통과하는 것은 그쪽이 nullability 를 `type: [string, "null"]` 로 적었기 때문이다. required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation] properties: kind: { type: string, enum: [QUESTION] } questionStatus: type: [string, "null"] enum: [OPEN, RESOLVED, null] description: | Backend Inquiry lifecycle의 축약 view다. `OPEN`은 Domain의 `OPEN`/`INVESTIGATING`/`PAUSED`를 모두 대표하므로 저장 시 Domain 상태를 `OPEN`으로 덮어쓰지 않는다. `RESOLVED`로의 변경만 Resolve command로 해석하며 기존 resolve invariant를 통과해야 한다. facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } nextValidation: { type: string, maxLength: 100000 } resolution: oneOf: - { $ref: "#/components/schemas/QuestionResolution" } - { type: "null" } ProjectDecisionInput: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } - type: object required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] properties: kind: { type: string, enum: [PROJECT_DECISION] } decisionStatus: type: [string, "null"] enum: [PROPOSED, ADOPTED, null] description: | UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 mapper에서 변환하고 Domain enum을 UI 용어 때문에 변경하지 않는다. `supersede`/`reject`는 secondary management 계약이 소유한다. decidedOn: { type: [string, "null"], format: date } statement: { type: string, maxLength: 100000 } rationale: { type: string, maxLength: 100000 } consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } WorkingCopyInput: oneOf: - { $ref: "#/components/schemas/CaseInput" } - { $ref: "#/components/schemas/ReferenceInput" } - { $ref: "#/components/schemas/QuestionInput" } - { $ref: "#/components/schemas/ProjectDecisionInput" } discriminator: propertyName: kind mapping: CASE: "#/components/schemas/CaseInput" REFERENCE: "#/components/schemas/ReferenceInput" QUESTION: "#/components/schemas/QuestionInput" PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput" # ------------------------------------------------------- working copy output WorkingCopyBase: allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } - type: object required: [id, version, relations, updatedAt] properties: id: type: string format: uuid description: source aggregate id를 그대로 사용한다. 별도 Studio surrogate id를 만들지 않는다. version: { type: integer, minimum: 1 } relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/Relation" } } updatedAt: { type: string, format: date-time } CaseWorkingCopy: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyBase" } - type: object required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown] properties: kind: { type: string, enum: [CASE] } problem: { type: string, maxLength: 100000 } conclusion: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 } reproduction: { type: string, maxLength: 100000 } lastVerifiedOn: { type: [string, "null"], format: date } bodyMarkdown: { type: string, maxLength: 100000 } ReferenceWorkingCopy: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyBase" } - type: object required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: kind: { type: string, enum: [REFERENCE] } purpose: { type: string, maxLength: 100000 } rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } verifiedOn: { type: [string, "null"], format: date } QuestionWorkingCopy: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyBase" } - type: object required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: kind: { type: string, enum: [QUESTION] } questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] } facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } nextValidation: { type: string, maxLength: 100000 } resolution: oneOf: - { $ref: "#/components/schemas/QuestionResolution" } - { type: "null" } ProjectDecisionWorkingCopy: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/WorkingCopyBase" } - type: object required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] properties: kind: { type: string, enum: [PROJECT_DECISION] } decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } decidedOn: { type: [string, "null"], format: date } statement: { type: string, maxLength: 100000 } rationale: { type: string, maxLength: 100000 } consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } WorkingCopy: description: | API union이다. DB에 `working_copy` 범용 테이블을 만들지 않는다. ```text WorkingCopy = CaseWorkingCopy | ReferenceWorkingCopy | QuestionWorkingCopy | ProjectDecisionWorkingCopy ``` oneOf: - { $ref: "#/components/schemas/CaseWorkingCopy" } - { $ref: "#/components/schemas/ReferenceWorkingCopy" } - { $ref: "#/components/schemas/QuestionWorkingCopy" } - { $ref: "#/components/schemas/ProjectDecisionWorkingCopy" } discriminator: propertyName: kind mapping: CASE: "#/components/schemas/CaseWorkingCopy" REFERENCE: "#/components/schemas/ReferenceWorkingCopy" QUESTION: "#/components/schemas/QuestionWorkingCopy" PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy" # -------------------------------------------------------------- commands CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" } SaveDocumentCommand: type: object additionalProperties: false required: [expectedVersion, document] properties: expectedVersion: { type: integer, minimum: 1 } document: { $ref: "#/components/schemas/WorkingCopyInput" } ValidateDocumentCommand: type: object additionalProperties: false required: [expectedVersion] properties: { expectedVersion: { type: integer, minimum: 1 } } CreatePreviewCommand: type: object additionalProperties: false required: [expectedVersion, validationId] properties: expectedVersion: { type: integer, minimum: 1 } validationId: { type: string, format: uuid } PublishDocumentCommand: type: object additionalProperties: false required: [expectedVersion, validationId, previewId, acknowledgedWarningCodes] properties: expectedVersion: { type: integer, minimum: 1 } validationId: { type: string, format: uuid } previewId: { type: string, format: uuid } acknowledgedWarningCodes: type: array uniqueItems: true maxItems: 200 items: { type: string, minLength: 1, maxLength: 100 } description: 현재 Validation의 WARNING code 집합을 모두 덮지 못하면 `WARNING_ACKNOWLEDGEMENT_REQUIRED`로 거절한다. UnpublishCommand: type: object additionalProperties: false required: [expectedPublicationRevision] properties: { expectedPublicationRevision: { type: integer, minimum: 1 } } # ------------------------------------------------------------- validation ValidationIssue: type: object additionalProperties: false required: [code, severity, path, message] properties: code: { type: string, minLength: 1, maxLength: 100 } severity: { type: string, enum: [ERROR, WARNING] } path: type: string pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" description: JSON Pointer to the affected field message: { type: string, minLength: 1, maxLength: 1000 } ValidationReport: type: object additionalProperties: false description: | 일급 application artifact다. 실행 결과를 그때그때 반환하고 버리지 않고 `studio_validation`에 영속한다. required: [validationId, documentId, validatedVersion, status, issues, validatedAt, validUntil, dependencyRevision] properties: validationId: { type: string, format: uuid } documentId: { type: string, format: uuid } validatedVersion: { type: integer, minimum: 1 } status: { type: string, enum: [INVALID, WARNINGS, VALID] } issues: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/ValidationIssue" } } validatedAt: { type: string, format: date-time } validUntil: { type: string, format: date-time } dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } DependencyRevision: type: string minLength: 1 maxLength: 200 description: | 검증 결과에 영향을 주는 외부 의존 상태를 대표하는 값이다. ```text Topic/Project 존재와 publishability relation target 상태 Asset READY/QUARANTINED 상태 slug/route ownership catalog revision 필요 시 renderer/content-format version ``` 모든 테이블의 global counter일 필요는 없다. 검증에 사용한 dependency identity/version을 정규화해 hash로 만들 수 있다. Publish 시 동일 dependency set을 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다. # ------------------------------------------------------- public render model DisplayTarget: type: object additionalProperties: false required: [id, label, publicPath] properties: id: { type: string, format: uuid } label: { type: string, minLength: 1, maxLength: 200 } publicPath: { type: [string, "null"], maxLength: 500 } ResolvedRelation: type: object additionalProperties: false required: [id, targetId, targetKind, title, publicPath, reason, order] properties: id: { type: string, format: uuid } targetId: { type: string, format: uuid } targetKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } title: { type: string, minLength: 1, maxLength: 200 } publicPath: { type: [string, "null"], maxLength: 500 } reason: { type: string, maxLength: 100000 } order: { type: integer, minimum: 0 } RenderContext: type: object additionalProperties: false required: [generatedAt, dependencyRevision] properties: generatedAt: { type: string, format: date-time } dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } PublicRenderModelBase: type: object required: [kind, slug, title, summary, publicPath, topic, project, relations, renderContext] properties: kind: { $ref: "#/components/schemas/RecordKind" } slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } title: { type: string, minLength: 1, maxLength: 120 } summary: { type: string, minLength: 1, maxLength: 300 } publicPath: { type: string, minLength: 1, maxLength: 500 } topic: { $ref: "#/components/schemas/DisplayTarget" } project: oneOf: - { $ref: "#/components/schemas/DisplayTarget" } - { type: "null" } relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/ResolvedRelation" } } renderContext: { $ref: "#/components/schemas/RenderContext" } InlineText: type: object additionalProperties: false required: [type, text] properties: type: { type: string, enum: [TEXT] } text: { type: string, minLength: 1, maxLength: 100000 } InlineContainer: type: object required: [type, children] properties: type: { type: string, enum: [EMPHASIS, STRONG] } children: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } InlineCode: type: object additionalProperties: false required: [type, code] properties: type: { type: string, enum: [INLINE_CODE] } code: { type: string, minLength: 1, maxLength: 100000 } InlineLink: type: object additionalProperties: false required: [type, label, href] properties: type: { type: string, enum: [LINK] } label: { type: string, minLength: 1, maxLength: 100000 } href: { type: string, format: uri, maxLength: 2000 } InlineStatus: type: object additionalProperties: false required: [type, label, tone] properties: type: { type: string, enum: [STATUS] } label: { type: string, minLength: 1, maxLength: 120 } tone: { type: string, enum: [warning, evidence, neutral] } Inline: oneOf: - { $ref: "#/components/schemas/InlineText" } - { $ref: "#/components/schemas/InlineEmphasis" } - { $ref: "#/components/schemas/InlineStrong" } - { $ref: "#/components/schemas/InlineCode" } - { $ref: "#/components/schemas/InlineLink" } - { $ref: "#/components/schemas/InlineStatus" } discriminator: propertyName: type mapping: TEXT: "#/components/schemas/InlineText" EMPHASIS: "#/components/schemas/InlineEmphasis" STRONG: "#/components/schemas/InlineStrong" INLINE_CODE: "#/components/schemas/InlineCode" LINK: "#/components/schemas/InlineLink" STATUS: "#/components/schemas/InlineStatus" InlineEmphasis: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/InlineContainer" } - { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } } InlineStrong: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/InlineContainer" } - { type: object, properties: { type: { type: string, enum: [STRONG] } } } HeadingBlock: type: object additionalProperties: false required: [type, id, level, content] properties: type: { type: string, enum: [HEADING] } id: { type: string, minLength: 1, maxLength: 200 } level: { type: integer, minimum: 2, maximum: 4 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } ParagraphBlock: type: object additionalProperties: false required: [type, content] properties: type: { type: string, enum: [PARAGRAPH] } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } BlockquoteBlock: type: object additionalProperties: false required: [type, content] properties: type: { type: string, enum: [BLOCKQUOTE] } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } ListItem: type: object additionalProperties: false required: [id, content] properties: id: { type: string, minLength: 1, maxLength: 200 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } ListBlockBase: type: object required: [type, items] properties: type: { type: string, enum: [UNORDERED_LIST, ORDERED_LIST] } items: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/ListItem" } } UnorderedListBlock: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/ListBlockBase" } - { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } } OrderedListBlock: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/ListBlockBase" } - { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } } CodeBlock: type: object additionalProperties: false required: [type, code, language, label] properties: type: { type: string, enum: [CODE_BLOCK] } code: { type: string, maxLength: 100000 } language: { type: [string, "null"], maxLength: 100 } label: { type: [string, "null"], maxLength: 200 } DataTableColumn: type: object additionalProperties: false required: [id, label, alignment] properties: id: { type: string, minLength: 1, maxLength: 200 } label: { type: string, minLength: 1, maxLength: 500 } alignment: { type: string, enum: [LEFT, CENTER, RIGHT] } DataTableCell: type: object additionalProperties: false required: [columnId, content] properties: columnId: { type: string, minLength: 1, maxLength: 200 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } DataTableRow: type: object additionalProperties: false required: [id, cells] properties: id: { type: string, minLength: 1, maxLength: 200 } cells: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DataTableCell" } } DataTableBlock: type: object additionalProperties: false required: [type, id, caption, rowHeaderColumn, columns, rows] properties: type: { type: string, enum: [DATA_TABLE] } id: { type: string, minLength: 1, maxLength: 200 } caption: { type: string, maxLength: 1000 } rowHeaderColumn: { type: [integer, "null"], minimum: 1 } columns: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/DataTableColumn" } } rows: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/DataTableRow" } } CalloutBlock: type: object additionalProperties: false required: [type, tone, label, content] properties: type: { type: string, enum: [CALLOUT] } tone: { type: string, enum: [warning, info] } label: { type: string, maxLength: 200 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } EvidenceFigureBlock: type: object additionalProperties: false required: [type, key, alt, caption, zoom, asset] description: | `key`는 managed `asset_key`다. object storage key나 raw URL이 아니다. ```text asset_key → Asset lookup → current approved delivery path ``` `alt`는 빈 문자열을 허용한다. 빈 alt 자체를 syntax error로 차단하지 않고 Publication Validation이 Asset metadata와 함께 의미 검증한다. ```text Asset decorative=false + 사용 위치 alt 비어 있음 → PublishValidationFailed Asset decorative=true → alt="" 허용 ``` properties: type: { type: string, enum: [EVIDENCE_FIGURE] } key: { type: string, minLength: 1, maxLength: 200 } alt: { type: string, maxLength: 1000 } caption: { type: string, maxLength: 1000 } zoom: { type: boolean } asset: { $ref: "#/components/schemas/ResolvedAsset" } ResolvedAsset: type: object additionalProperties: false description: | renderer가 사용하는 Asset descriptor다. Preview/Public/Snapshot이 동일한 resolver를 통해 동일 semantic output을 만들어야 한다. SVG도 URL 기반 ``로 렌더링하며 원문을 inline하지 않는다. required: [assetId, assetKey, mediaType, publicPath, width, height, decorative] properties: assetId: { type: string, format: uuid } assetKey: { type: string, minLength: 1, maxLength: 200 } mediaType: { type: string, minLength: 1, maxLength: 200 } publicPath: { type: string, minLength: 1, maxLength: 500 } width: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 } decorative: { type: boolean } CaseRenderBlock: oneOf: - { $ref: "#/components/schemas/HeadingBlock" } - { $ref: "#/components/schemas/ParagraphBlock" } - { $ref: "#/components/schemas/BlockquoteBlock" } - { $ref: "#/components/schemas/UnorderedListBlock" } - { $ref: "#/components/schemas/OrderedListBlock" } - { $ref: "#/components/schemas/CodeBlock" } - { $ref: "#/components/schemas/DataTableBlock" } - { $ref: "#/components/schemas/CalloutBlock" } - { $ref: "#/components/schemas/EvidenceFigureBlock" } discriminator: propertyName: type mapping: HEADING: "#/components/schemas/HeadingBlock" PARAGRAPH: "#/components/schemas/ParagraphBlock" BLOCKQUOTE: "#/components/schemas/BlockquoteBlock" UNORDERED_LIST: "#/components/schemas/UnorderedListBlock" ORDERED_LIST: "#/components/schemas/OrderedListBlock" CODE_BLOCK: "#/components/schemas/CodeBlock" DATA_TABLE: "#/components/schemas/DataTableBlock" CALLOUT: "#/components/schemas/CalloutBlock" EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" CasePublicRenderModel: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/PublicRenderModelBase" } - type: object required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks] properties: kind: { type: string, enum: [CASE] } problem: { type: string, minLength: 1, maxLength: 100000 } conclusion: { type: string, minLength: 1, maxLength: 100000 } environment: { type: string, maxLength: 100000 } reproduction: { type: string, maxLength: 100000 } lastVerifiedOn: { type: string, format: date } bodyBlocks: { type: array, maxItems: 10000, items: { $ref: "#/components/schemas/CaseRenderBlock" } } ReferencePublicRenderModel: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/PublicRenderModelBase" } - type: object required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: kind: { type: string, enum: [REFERENCE] } purpose: { type: string, minLength: 1, maxLength: 100000 } rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } } applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } verifiedOn: { type: string, format: date } ResolvedQuestionResolution: type: object additionalProperties: false required: [summary, evidenceTarget, linkLabel] properties: summary: { type: string, minLength: 1, maxLength: 100000 } evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" } linkLabel: { type: string, minLength: 1, maxLength: 120 } QuestionPublicRenderModel: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/PublicRenderModelBase" } - type: object required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: kind: { type: string, enum: [QUESTION] } status: type: string enum: [OPEN, RESOLVED] description: 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. facts: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } } nextValidation: { type: string, minLength: 1, maxLength: 100000 } resolution: oneOf: - { $ref: "#/components/schemas/ResolvedQuestionResolution" } - { type: "null" } ProjectDecisionPublicRenderModel: unevaluatedProperties: false allOf: - { $ref: "#/components/schemas/PublicRenderModelBase" } - type: object required: [kind, status, decidedOn, statement, rationale, consequences] properties: kind: { type: string, enum: [PROJECT_DECISION] } status: { type: string, enum: [PROPOSED, ADOPTED] } decidedOn: { type: string, format: date } statement: { type: string, minLength: 1, maxLength: 100000 } rationale: { type: string, minLength: 1, maxLength: 100000 } consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } PublicRenderModel: oneOf: - { $ref: "#/components/schemas/CasePublicRenderModel" } - { $ref: "#/components/schemas/ReferencePublicRenderModel" } - { $ref: "#/components/schemas/QuestionPublicRenderModel" } - { $ref: "#/components/schemas/ProjectDecisionPublicRenderModel" } discriminator: propertyName: kind mapping: CASE: "#/components/schemas/CasePublicRenderModel" REFERENCE: "#/components/schemas/ReferencePublicRenderModel" QUESTION: "#/components/schemas/QuestionPublicRenderModel" PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel" # ---------------------------------------------------------------- preview PublicPreview: type: object additionalProperties: false required: [previewId, documentId, previewVersion, validationId, dependencyRevision, createdAt, expiresAt, renderModel] properties: previewId: { type: string, format: uuid } documentId: { type: string, format: uuid } previewVersion: { type: integer, minimum: 1 } validationId: { type: string, format: uuid } dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } createdAt: { type: string, format: date-time } expiresAt: { type: string, format: date-time } renderModel: { $ref: "#/components/schemas/PublicRenderModel" } PreviewDetail: type: object additionalProperties: false required: [preview, state, currentDocumentVersion, currentValidationId] properties: preview: { $ref: "#/components/schemas/PublicPreview" } state: type: string enum: [CURRENT, STALE, EXPIRED] description: | 서버가 계산한다. `STALE`은 working version 또는 dependency revision이 달라진 경우, `EXPIRED`는 `expiresAt`이 지난 경우다. currentDocumentVersion: { type: integer, minimum: 1 } currentValidationId: { type: [string, "null"], format: uuid } # ------------------------------------------------------------ publication PublicationAggregate: type: object additionalProperties: false description: 현재 게시 상태다. 게시 이력(`PublicationEvent`)과 구분한다. required: [publicationId, documentId, status, publishedVersion, publicationRevision, latestEventId, publicPath, updatedAt] properties: publicationId: { type: string, format: uuid } documentId: { type: string, format: uuid } status: { type: string, enum: [PUBLISHED, UNPUBLISHED] } publishedVersion: { type: integer, minimum: 1 } publicationRevision: { type: integer, minimum: 1 } latestEventId: { type: string, format: uuid } publicPath: { type: string, minLength: 1, maxLength: 500 } updatedAt: { type: string, format: date-time } PublicationEventType: { type: string, enum: [PUBLISHED, REPUBLISHED, UNPUBLISHED] } PublicationEvent: type: object additionalProperties: false description: 불변 이력이다. 생성 후 수정하지 않는다. required: [publicationEventId, publicationId, documentId, type, occurredAt, publishedVersion, sourcePublishedEventId, snapshotAvailable] properties: publicationEventId: { type: string, format: uuid } publicationId: { type: string, format: uuid } documentId: { type: string, format: uuid } type: { $ref: "#/components/schemas/PublicationEventType" } occurredAt: { type: string, format: date-time } publishedVersion: { type: integer, minimum: 1 } sourcePublishedEventId: type: [string, "null"] format: uuid description: "`UNPUBLISHED` Event가 참조하는 마지막 공개 Snapshot의 Event id다." snapshotAvailable: { type: boolean } PublicationSnapshot: type: object additionalProperties: false description: | `PUBLISHED`/`REPUBLISHED` 시점의 불변 `PublicRenderModel`이다. 현재 source에서 재생성하지 않는다. required: [event, renderModel, contentFormatVersion, rendererContractVersion] properties: event: { $ref: "#/components/schemas/PublicationEvent" } renderModel: { $ref: "#/components/schemas/PublicRenderModel" } contentFormatVersion: { type: string, minLength: 1, maxLength: 50 } rendererContractVersion: { type: string, minLength: 1, maxLength: 50 } PublishResult: type: object additionalProperties: false required: [publication, event] properties: publication: { $ref: "#/components/schemas/PublicationAggregate" } event: { $ref: "#/components/schemas/PublicationEvent" } # ---------------------------------------------------------------- listing DocumentSummary: type: object additionalProperties: false required: [id, title, kind, project, updatedAt, publicationStatus, publishedVersion, hasUnpublishedChanges, nextAction] properties: id: { type: string, format: uuid } title: { type: string, maxLength: 120 } kind: { $ref: "#/components/schemas/RecordKind" } project: oneOf: - { $ref: "#/components/schemas/DisplayTarget" } - { type: "null" } updatedAt: { type: string, format: date-time } publicationStatus: { $ref: "#/components/schemas/PublicationStatus" } publishedVersion: { type: [integer, "null"], minimum: 1 } hasUnpublishedChanges: type: boolean description: "`currentWorkingVersion != currentPublication.publishedVersion`. 게시 취소 상태에서도 과거 publishedVersion과 비교한다." nextAction: { $ref: "#/components/schemas/NextAction" } PublicationAction: { type: string, enum: [VIEW_SNAPSHOT, VIEW_SOURCE_SNAPSHOT, UNPUBLISH] } PublicationListItem: type: object additionalProperties: false required: [event, publication, document, availableActions] properties: event: { $ref: "#/components/schemas/PublicationEvent" } publication: { $ref: "#/components/schemas/PublicationAggregate" } document: { $ref: "#/components/schemas/DocumentSummary" } availableActions: { type: array, uniqueItems: true, maxItems: 3, items: { $ref: "#/components/schemas/PublicationAction" } } WorkingCopyDetail: type: object additionalProperties: false required: [document, currentValidation, latestPreview, currentPublication, dependencyRevision, nextAction] properties: document: { $ref: "#/components/schemas/WorkingCopy" } currentValidation: oneOf: - { $ref: "#/components/schemas/ValidationReport" } - { type: "null" } latestPreview: oneOf: - { $ref: "#/components/schemas/PublicPreview" } - { type: "null" } currentPublication: oneOf: - { $ref: "#/components/schemas/PublicationAggregate" } - { type: "null" } dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } nextAction: { $ref: "#/components/schemas/NextAction" } StudioDashboard: type: object additionalProperties: false required: [continueWriting, readyToPublish, recentPublications, totals] properties: continueWriting: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } } readyToPublish: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } } recentPublications: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/PublicationListItem" } } totals: { $ref: "#/components/schemas/DashboardTotals" } DashboardTotals: type: object additionalProperties: false required: [documents, needsValidation, readyToPublish, publications] properties: documents: { type: integer, minimum: 0 } needsValidation: { type: integer, minimum: 0 } readyToPublish: { type: integer, minimum: 0 } publications: { type: integer, minimum: 0 } DocumentPage: type: object additionalProperties: false required: [items, nextCursor] properties: items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DocumentSummary" } } nextCursor: { type: [string, "null"], maxLength: 2000 } PublicationPage: type: object additionalProperties: false required: [items, nextCursor] properties: items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/PublicationListItem" } } nextCursor: { type: [string, "null"], maxLength: 2000 } # ---------------------------------------------------------------- catalog CatalogEntryType: { type: string, enum: [TOPIC, PROJECT, RELATION, EVIDENCE] } CatalogEntry: type: object additionalProperties: false required: [id, type, label, dependencyRevision] properties: id: { type: string, format: uuid } type: { $ref: "#/components/schemas/CatalogEntryType" } label: { type: string, minLength: 1, maxLength: 200 } kind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } publicPath: { type: string, minLength: 1, maxLength: 500 } dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } CatalogPage: type: object additionalProperties: false required: [items, nextCursor] properties: items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/CatalogEntry" } } nextCursor: { type: [string, "null"], maxLength: 2000 } # ----------------------------------------------------------------- assets AssetKind: { type: string, enum: [IMAGE, DIAGRAM, ATTACHMENT] } AssetManagementStatus: type: string enum: [READY, ARCHIVED, REJECTED, QUARANTINED] description: | `READY`만 Public Preview/Publish에 사용할 수 있다. `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. Asset: type: object additionalProperties: false required: [id, assetKey, kind, mediaType, originalFilename, byteSize, width, height, altText, decorative, managementStatus, publicPath, usageCount, version, createdAt, updatedAt] properties: id: { type: string, format: uuid } assetKey: type: string minLength: 1 maxLength: 200 description: | Public content가 사용하는 안정적인 key다. object storage key나 raw URL이 아니다. immutable이며 공개 이력 이후 재사용을 금지한다. kind: { $ref: "#/components/schemas/AssetKind" } mediaType: { type: string, minLength: 1, maxLength: 200 } originalFilename: { type: string, minLength: 1, maxLength: 500 } byteSize: { type: integer, minimum: 0 } width: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 } altText: { type: [string, "null"], maxLength: 1000 } decorative: { type: boolean } managementStatus: { $ref: "#/components/schemas/AssetManagementStatus" } publicPath: { type: [string, "null"], maxLength: 500 } usageCount: { type: integer, minimum: 0 } version: { type: integer, minimum: 1 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } AssetUsage: type: object additionalProperties: false required: [documentId, documentKind, title, published] properties: documentId: { type: string, format: uuid } documentKind: { $ref: "#/components/schemas/RecordKind" } title: { type: string, minLength: 1, maxLength: 200 } published: { type: boolean } AssetDetail: type: object additionalProperties: false required: [asset, usages, hasPublicationHistory] properties: asset: { $ref: "#/components/schemas/Asset" } usages: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/AssetUsage" } } hasPublicationHistory: type: boolean description: true면 hard delete를 금지하고 `ARCHIVED` 전환만 허용한다. AssetUploadForm: type: object additionalProperties: false required: [file, kind] properties: file: { type: string, format: binary } kind: { $ref: "#/components/schemas/AssetKind" } altText: { type: string, maxLength: 1000 } decorative: { type: boolean, default: false } UpdateAssetCommand: type: object additionalProperties: false required: [expectedVersion] properties: expectedVersion: { type: integer, minimum: 1 } kind: { $ref: "#/components/schemas/AssetKind" } altText: { type: [string, "null"], maxLength: 1000 } decorative: { type: boolean } managementStatus: { type: string, enum: [READY, ARCHIVED] } AssetPage: type: object additionalProperties: false required: [items, nextCursor] properties: items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/Asset" } } nextCursor: { type: [string, "null"], maxLength: 2000 } # ------------------------------------------------------------------ errors FieldError: type: object additionalProperties: false required: [path, message] properties: path: type: string pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" description: JSON Pointer to the invalid field message: { type: string, minLength: 1, maxLength: 1000 }