feat: add TechLog feature contracts

This commit is contained in:
DongHyeonka
2026-08-15 18:07:12 +09:00
parent 5479101c8c
commit 01ed1e9300
9 changed files with 2787 additions and 0 deletions
@@ -0,0 +1,181 @@
export type RecordKind = "CASE" | "REFERENCE" | "QUESTION";
export type QuestionStatus =
| "OPEN"
| "INVESTIGATING"
| "PAUSED"
| "RESOLVED"
| "ARCHIVED";
export type PublicRelation = {
reason: string;
title: string;
path: string;
};
export type RecordSection = {
id: string;
title: string;
paragraphs: ReadonlyArray<string>;
bullets?: ReadonlyArray<string>;
};
type PublicRecordBase = {
kind: RecordKind;
slug: string;
title: string;
summary: string;
path: string;
topic: string;
topicSlug: string;
projectSlug: string;
projectTitle: string;
publishedAt: string;
publishedLabel: string;
visibility: "PUBLIC";
relations: ReadonlyArray<PublicRelation>;
};
export type CaseRecord = PublicRecordBase & {
kind: "CASE";
problem: string;
conclusion: string;
environment: string;
verification: string;
lastVerifiedLabel: string;
sections: ReadonlyArray<RecordSection>;
};
export type ReferenceRule = {
title: string;
body: string;
};
export type ReferenceRecord = PublicRecordBase & {
kind: "REFERENCE";
purpose: string;
rules: ReadonlyArray<ReferenceRule>;
applyWhen: ReadonlyArray<string>;
exceptions: ReadonlyArray<string>;
examples: ReadonlyArray<string>;
verifiedAt: string;
};
export type QuestionOption = {
title: string;
description: string;
};
export type QuestionRecord = PublicRecordBase & {
kind: "QUESTION";
questionStatus: QuestionStatus;
facts: ReadonlyArray<string>;
assumptions: ReadonlyArray<string>;
unknowns: ReadonlyArray<string>;
constraints: ReadonlyArray<string>;
options: ReadonlyArray<QuestionOption>;
nextValidation: string;
resolution?: {
summary: string;
path: string;
linkLabel: string;
};
};
export type PublicRecord = CaseRecord | ReferenceRecord | QuestionRecord;
export type ProjectDecision = {
id: string;
status: "ADOPTED" | "PROPOSED";
date: string;
title: string;
statement: string;
rationale: string;
consequences: ReadonlyArray<string>;
evidence: ReadonlyArray<{ title: string; path: string }>;
};
export type ProjectActivity = {
id: string;
date: string;
dateTime: string;
type: "PUBLICATION" | "PROJECT UPDATE" | "QUESTION";
title: string;
summary: string;
path: string;
recordPath?: string;
};
export type Project = {
slug: string;
title: string;
summary: string;
thesis: string;
stage: "DESIGN" | "VALIDATION";
currentGoal: string;
nextStep: string;
topics: ReadonlyArray<string>;
decisions: ReadonlyArray<ProjectDecision>;
activity: ReadonlyArray<ProjectActivity>;
};
export type Release = {
version: string;
path: string;
title: string;
summary: string;
publishedAt: string;
publishedLabel: string;
changes: ReadonlyArray<string>;
reasons: ReadonlyArray<string>;
impacts: ReadonlyArray<string>;
related: ReadonlyArray<{ title: string; path: string }>;
};
export type FocusKey = "current" | "question" | "decision";
export type HomeFocusItem = {
key: FocusKey;
label: string;
title: string;
summary: string;
details: ReadonlyArray<{ label: string; value: string }>;
targetPath: string;
};
export type RecordFilters = {
kind?: RecordKind;
topic?: string;
project?: string;
openQuestionsOnly?: boolean;
};
export type SearchablePublicEntity = {
contentType: RecordKind | "PROJECT" | "RELEASE";
title: string;
summary: string;
path: string;
topic?: string;
topics?: ReadonlyArray<string>;
project?: string;
publishedAt?: string;
};
/**
* The application-facing boundary for the immutable source Public catalog.
* Method signatures intentionally retain the source query argument and return shapes.
*/
export type PublicContentQueries = Readonly<{
listRecords(filters?: RecordFilters): PublicRecord[];
getRecord<K extends RecordKind>(
kind: K,
slug: string,
): Extract<PublicRecord, { kind: K }> | undefined;
getProject(slug: string): Project | undefined;
getRelease(version: string): Release | undefined;
getProjectRecords(projectSlug: string): PublicRecord[];
getProjectDecisions(projectSlug: string): ProjectDecision[];
getProjectActivity(projectSlug: string): ProjectActivity[];
getHomeFocusItems(): HomeFocusItem[];
searchPublicContent(query: string): SearchablePublicEntity[];
}>;
@@ -0,0 +1,32 @@
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
export class StudioGatewayError extends Error {
readonly problem: ProblemDetails;
readonly status: number;
readonly code: ProblemDetails["code"];
readonly retryable: boolean;
constructor(problem: ProblemDetails, options?: ErrorOptions) {
super(problem.detail, options);
this.name = "StudioGatewayError";
this.problem = problem;
this.status = problem.status;
this.code = problem.code;
this.retryable = problem.retryable ?? false;
}
}
export function isStudioGatewayError(value: unknown): value is StudioGatewayError {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<StudioGatewayError>;
return (
typeof candidate.message === "string" &&
typeof candidate.status === "number" &&
typeof candidate.code === "string" &&
typeof candidate.retryable === "boolean" &&
typeof candidate.problem === "object" &&
candidate.problem !== null &&
candidate.problem.status === candidate.status &&
candidate.problem.code === candidate.code
);
}
@@ -0,0 +1,34 @@
import type {
CatalogPage, CreateDocumentInput, CreatePreviewCommand, DocumentPage,
PublicationPage, PublicationSnapshot, PublicPreview, PublishDocumentCommand,
PreviewDetail, PublishResult, SaveDocumentCommand, StudioDashboard, UnpublishCommand,
ValidationReport, ValidateDocumentCommand, WorkingCopy, WorkingCopyDetail,
} from "../../contracts/studio/contract.ts";
export type RequestOptions = { signal?: AbortSignal };
export type IdempotentOptions = RequestOptions & { idempotencyKey: string };
export type ListDocumentsQuery = {
q?: string; kind?: "CASE" | "REFERENCE" | "QUESTION";
publicationStatus?: "NEVER_PUBLISHED" | "PUBLISHED" | "UNPUBLISHED";
nextAction?: "CONTINUE_EDITING" | "VALIDATE" | "FIX_VALIDATION" | "CREATE_PREVIEW" | "PUBLISH" | "NONE";
projectId?: string; sort?: "UPDATED_DESC" | "UPDATED_ASC" | "TITLE_ASC";
cursor?: string; limit?: number;
};
export type ListPublicationsQuery = { q?: string; type?: "PUBLISHED" | "REPUBLISHED" | "UNPUBLISHED"; cursor?: string; limit?: number };
export type CatalogQuery = { type: "TOPIC" | "PROJECT" | "RELATION" | "EVIDENCE"; q?: string; cursor?: string; limit?: number };
export interface StudioGateway {
getDashboard(options?: RequestOptions): Promise<StudioDashboard>;
listDocuments(query: ListDocumentsQuery, options?: RequestOptions): Promise<DocumentPage>;
createDocument(input: CreateDocumentInput, options: IdempotentOptions): Promise<WorkingCopy>;
getDocument(documentId: string, options?: RequestOptions): Promise<WorkingCopyDetail>;
saveDocument(documentId: string, command: SaveDocumentCommand, options: IdempotentOptions): Promise<WorkingCopyDetail>;
validateDocument(documentId: string, command: ValidateDocumentCommand, options: IdempotentOptions): Promise<ValidationReport>;
createPreview(documentId: string, command: CreatePreviewCommand, options: IdempotentOptions): Promise<PublicPreview>;
getCurrentPreview(documentId: string, options?: RequestOptions): Promise<PreviewDetail>;
publishDocument(documentId: string, command: PublishDocumentCommand, options: IdempotentOptions): Promise<PublishResult>;
unpublishPublication(publicationId: string, command: UnpublishCommand, options: IdempotentOptions): Promise<PublishResult>;
listPublications(query: ListPublicationsQuery, options?: RequestOptions): Promise<PublicationPage>;
getPublicationSnapshot(publicationEventId: string, options?: RequestOptions): Promise<PublicationSnapshot>;
getCatalog(query: CatalogQuery, options?: RequestOptions): Promise<CatalogPage>;
}
@@ -0,0 +1,15 @@
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
import type { StudioGateway } from "./ports/studio-gateway.ts";
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
export type TechLogFeatureInput = Readonly<{
publicContent: PublicContentQueries;
createStudioGateway(): StudioGateway;
}>;
declare module "../../../application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"tech-log": TechLogFeatureInput;
}
}
@@ -0,0 +1,27 @@
import type { components } from "./generated.ts";
type Schemas = components["schemas"];
export type RecordKind = Schemas["RecordKind"];
export type WorkingCopy = Schemas["WorkingCopy"];
export type WorkingCopyInput = Schemas["WorkingCopyInput"];
export type WorkingCopyDetail = Schemas["WorkingCopyDetail"];
export type StudioDashboard = Schemas["StudioDashboard"];
export type DocumentPage = Schemas["DocumentPage"];
export type CreateDocumentInput = Schemas["CreateDocumentInput"];
export type SaveDocumentCommand = Schemas["SaveDocumentCommand"];
export type ValidateDocumentCommand = Schemas["ValidateDocumentCommand"];
export type ValidationReport = Schemas["ValidationReport"];
export type CreatePreviewCommand = Schemas["CreatePreviewCommand"];
export type PublicPreview = Schemas["PublicPreview"];
export type PreviewDetail = Schemas["PreviewDetail"];
export type PublishDocumentCommand = Schemas["PublishDocumentCommand"];
export type PublishResult = Schemas["PublishResult"];
export type UnpublishCommand = Schemas["UnpublishCommand"];
export type PublicationAggregate = Schemas["PublicationAggregate"];
export type PublicationEvent = Schemas["PublicationEvent"];
export type PublicationListItem = Schemas["PublicationListItem"];
export type PublicationPage = Schemas["PublicationPage"];
export type PublicationSnapshot = Schemas["PublicationSnapshot"];
export type CatalogPage = Schemas["CatalogPage"];
export type ProblemDetails = Schemas["ProblemDetails"];
export type PublicRenderModel = Schemas["PublicRenderModel"];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,984 @@
openapi: 3.1.0
info:
title: TechLog Studio API
version: 1.0.0
description: Transport contract for the TechLog Studio authoring frontend.
license: { name: Proprietary, identifier: LicenseRef-Proprietary }
servers:
- url: /
security: []
paths:
/api/v1/studio/dashboard:
get:
operationId: getStudioDashboard
summary: Get the Studio dashboard
responses:
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboard" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/documents:
get:
operationId: listStudioDocuments
summary: List working copies
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/DocumentPage" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
post:
operationId: createStudioDocument
summary: Create a working copy
parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
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/WorkingCopy" } } }
"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
summary: Get a working copy and its current state
responses:
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
put:
operationId: saveStudioDocument
summary: Save a full working copy
parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
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/WorkingCopyDetail" } } }
"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
summary: Validate a saved working copy
parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
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/ValidationReport" } } }
"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
summary: Get the latest preview and its computed state
responses:
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetail" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PreviewNotFound" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
post:
operationId: createStudioPreview
summary: Create a public-layout preview
parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
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/PublicPreview" } } }
"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}/publish:
parameters: [{ $ref: "#/components/parameters/DocumentId" }]
post:
operationId: publishStudioDocument
summary: Publish or republish a validated preview
parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
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/PublishResult" } } }
"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/publications/{publicationId}/unpublish:
parameters: [{ $ref: "#/components/parameters/PublicationId" }]
post:
operationId: unpublishStudioPublication
summary: Unpublish the current publication
parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
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/PublishResult" } } }
"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:
get:
operationId: listStudioPublications
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/PublicationPage" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/publications/{publicationEventId}/preview:
parameters: [{ $ref: "#/components/parameters/PublicationEventId" }]
get:
operationId: getStudioPublicationSnapshot
summary: Get an immutable publication snapshot
responses:
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshot" } } } }
"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
summary: List catalog entries for editor pickers
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/CatalogPage" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
components:
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 } }
IdempotencyKey: { name: Idempotency-Key, in: header, required: true, 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" } }
headers:
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
responses:
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
CommandConflict:
description: Command conflicts with current state, freshness, or idempotency
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
schemas:
RecordKind: { type: string, enum: [CASE, REFERENCE, QUESTION] }
PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] }
NextAction: { type: string, enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] }
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 }
WorkingCopyInputBase:
type: object
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 }
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, const: 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 }
ReferenceInput:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, const: 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
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties:
kind: { type: string, const: 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" }
WorkingCopyInput:
oneOf:
- { $ref: "#/components/schemas/CaseInput" }
- { $ref: "#/components/schemas/ReferenceInput" }
- { $ref: "#/components/schemas/QuestionInput" }
discriminator:
propertyName: kind
mapping:
CASE: "#/components/schemas/CaseInput"
REFERENCE: "#/components/schemas/ReferenceInput"
QUESTION: "#/components/schemas/QuestionInput"
WorkingCopyBase:
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [id, version, relations, updatedAt]
properties:
id: { type: string, format: uuid }
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, const: 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, const: 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, const: 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" }
WorkingCopy:
oneOf:
- { $ref: "#/components/schemas/CaseWorkingCopy" }
- { $ref: "#/components/schemas/ReferenceWorkingCopy" }
- { $ref: "#/components/schemas/QuestionWorkingCopy" }
discriminator:
propertyName: kind
mapping:
CASE: "#/components/schemas/CaseWorkingCopy"
REFERENCE: "#/components/schemas/ReferenceWorkingCopy"
QUESTION: "#/components/schemas/QuestionWorkingCopy"
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 } }
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
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: { type: string, minLength: 1, maxLength: 200 }
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 } }
UnpublishCommand:
type: object
additionalProperties: false
required: [expectedPublicationRevision]
properties: { expectedPublicationRevision: { type: integer, minimum: 1 } }
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: { type: string, minLength: 1, maxLength: 200 }
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, const: 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, const: INLINE_CODE }
code: { type: string, minLength: 1, maxLength: 100000 }
InlineLink:
type: object
additionalProperties: false
required: [type, label, href]
properties:
type: { type: string, const: 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, const: 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, const: EMPHASIS } } }
InlineStrong:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, const: STRONG } } }
HeadingBlock:
type: object
additionalProperties: false
required: [type, id, level, content]
properties:
type: { type: string, const: 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, const: PARAGRAPH }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
BlockquoteBlock:
type: object
additionalProperties: false
required: [type, content]
properties:
type: { type: string, const: 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, const: UNORDERED_LIST } } }
OrderedListBlock:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, const: ORDERED_LIST } } }
CodeBlock:
type: object
additionalProperties: false
required: [type, code, language, label]
properties:
type: { type: string, const: 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, const: 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, const: 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]
properties:
type: { type: string, const: EVIDENCE_FIGURE }
key: { type: string, minLength: 1, maxLength: 200 }
alt: { type: string, minLength: 1, maxLength: 1000 }
caption: { type: string, maxLength: 1000 }
zoom: { 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, const: 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, const: 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, const: QUESTION }
status: { type: string, enum: [OPEN, RESOLVED] }
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" }
PublicRenderModel:
oneOf:
- { $ref: "#/components/schemas/CasePublicRenderModel" }
- { $ref: "#/components/schemas/ReferencePublicRenderModel" }
- { $ref: "#/components/schemas/QuestionPublicRenderModel" }
discriminator:
propertyName: kind
mapping:
CASE: "#/components/schemas/CasePublicRenderModel"
REFERENCE: "#/components/schemas/ReferencePublicRenderModel"
QUESTION: "#/components/schemas/QuestionPublicRenderModel"
PublicPreview:
type: object
additionalProperties: false
required: [previewId, documentId, previewVersion, validationId, createdAt, expiresAt, renderModel]
properties:
previewId: { type: string, format: uuid }
documentId: { type: string, format: uuid }
previewVersion: { type: integer, minimum: 1 }
validationId: { type: string, format: uuid }
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] }
currentDocumentVersion: { type: integer, minimum: 1 }
currentValidationId: { type: [string, "null"], format: uuid }
PublicationAggregate:
type: object
additionalProperties: false
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
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 }
snapshotAvailable: { type: boolean }
PublicationSnapshot:
type: object
additionalProperties: false
required: [event, renderModel]
properties:
event: { $ref: "#/components/schemas/PublicationEvent" }
renderModel: { $ref: "#/components/schemas/PublicRenderModel" }
PublishResult:
type: object
additionalProperties: false
required: [publication, event]
properties:
publication: { $ref: "#/components/schemas/PublicationAggregate" }
event: { $ref: "#/components/schemas/PublicationEvent" }
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 }
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]
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: { type: string, minLength: 1, maxLength: 200 }
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, readyToPublish, publications]
properties:
documents: { 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 }
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: { type: string, minLength: 1, maxLength: 200 }
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 }
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 }
ProblemDetails:
type: object
additionalProperties: true
required: [type, title, status, detail, code]
properties:
type: { type: string, format: uri-reference }
title: { type: string, minLength: 1, maxLength: 200 }
status: { type: integer, minimum: 400, maximum: 599 }
detail: { type: string, minLength: 1, maxLength: 5000 }
code:
type: string
enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND, PUBLICATION_NOT_FOUND, PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND, VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, REQUEST_VALIDATION_FAILED, STUDIO_UNAVAILABLE]
instance: { type: string, format: uri-reference }
traceId: { type: string, maxLength: 200 }
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
conflictingFields:
type: array
uniqueItems: true
maxItems: 200
items:
type: string
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
retryable: { type: boolean }
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
import {
TECH_LOG_FEATURE_ID,
type TechLogFeatureInput,
} from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
import type { PublicContentQueries } from "../../../src/features/tech-log/application/ports/public-content-queries.ts";
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
function acceptsApplicationFeatureInput(
input: ApplicationFeatureInputs["tech-log"],
): TechLogFeatureInput {
return input;
}
describe("TechLog feature input", () => {
it("uses the fixed application feature identifier", () => {
expect(TECH_LOG_FEATURE_ID).toBe("tech-log");
});
it("accepts public queries and a Studio gateway factory through the application registry", () => {
const publicContent = {} as PublicContentQueries;
const gateway = {} as StudioGateway;
const input = acceptsApplicationFeatureInput({
publicContent,
createStudioGateway: () => gateway,
});
expect(input.publicContent).toBe(publicContent);
expect(input.createStudioGateway()).toBe(gateway);
});
});
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
import {
isStudioGatewayError,
StudioGatewayError,
} from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import type {
StudioGateway,
} from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
type Inline = components["schemas"]["Inline"];
type CaseRenderBlock = components["schemas"]["CaseRenderBlock"];
const inlines: Inline[] = [
{ type: "TEXT", text: "plain" },
{ type: "EMPHASIS", children: [{ type: "TEXT", text: "emphasis" }] },
{ type: "STRONG", children: [{ type: "TEXT", text: "strong" }] },
{ type: "INLINE_CODE", code: "findById" },
{ type: "LINK", label: "TechLog", href: "https://example.com" },
{ type: "STATUS", label: "검증 필요", tone: "warning" },
];
const blocks: CaseRenderBlock[] = [
{ type: "HEADING", id: "problem", level: 2, content: [] },
{ type: "PARAGRAPH", content: [] },
{ type: "BLOCKQUOTE", content: [] },
{ type: "UNORDERED_LIST", items: [] },
{ type: "ORDERED_LIST", items: [] },
{ type: "CODE_BLOCK", code: "select 1", language: "sql", label: null },
{
type: "DATA_TABLE",
id: "query-counts",
caption: "Query counts",
rowHeaderColumn: null,
columns: [{ id: "query-counts-column-1", label: "Case", alignment: "LEFT" }],
rows: [],
},
{ type: "CALLOUT", tone: "warning", label: "주의", content: [] },
{ type: "EVIDENCE_FIGURE", key: "fetch-plan", alt: "Fetch plan", caption: "Measured fetch plan", zoom: true },
];
const gatewayMethodNames = [
"getDashboard",
"listDocuments",
"createDocument",
"getDocument",
"saveDocument",
"validateDocument",
"createPreview",
"getCurrentPreview",
"publishDocument",
"unpublishPublication",
"listPublications",
"getPublicationSnapshot",
"getCatalog",
] as const;
function gatewayWithEveryRequiredMethod(): StudioGateway {
return {
getDashboard: async () => undefined as never,
listDocuments: async () => undefined as never,
createDocument: async () => undefined as never,
getDocument: async () => undefined as never,
saveDocument: async () => undefined as never,
validateDocument: async () => undefined as never,
createPreview: async () => undefined as never,
getCurrentPreview: async () => undefined as never,
publishDocument: async () => undefined as never,
unpublishPublication: async () => undefined as never,
listPublications: async () => undefined as never,
getPublicationSnapshot: async () => undefined as never,
getCatalog: async () => undefined as never,
};
}
describe("TechLog Studio contracts", () => {
it("preserves every public wire discriminator exactly once", () => {
expect(inlines.map((inline) => inline.type)).toEqual([
"TEXT",
"EMPHASIS",
"STRONG",
"INLINE_CODE",
"LINK",
"STATUS",
]);
expect(blocks.map((block) => block.type)).toEqual([
"HEADING",
"PARAGRAPH",
"BLOCKQUOTE",
"UNORDERED_LIST",
"ORDERED_LIST",
"CODE_BLOCK",
"DATA_TABLE",
"CALLOUT",
"EVIDENCE_FIGURE",
]);
expect(new Set(blocks.map((block) => block.type)).size).toBe(blocks.length);
});
it("requires the exact Studio gateway operation set", () => {
expect(Object.keys(gatewayWithEveryRequiredMethod()).sort()).toEqual(
[...gatewayMethodNames].sort(),
);
});
it("keeps RFC 9457-style gateway failures inspectable at the application boundary", () => {
const error = new StudioGatewayError({
type: "https://techlog.dev/problems/conflict",
title: "Conflict",
status: 409,
detail: "The working copy has changed.",
code: "DOCUMENT_VERSION_CONFLICT",
retryable: false,
});
expect(error).toMatchObject({
name: "StudioGatewayError",
status: 409,
code: "DOCUMENT_VERSION_CONFLICT",
retryable: false,
});
expect(isStudioGatewayError(error)).toBe(true);
expect(isStudioGatewayError(new DOMException("Aborted", "AbortError"))).toBe(false);
});
});