diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java index babf0b9..ab5b9f2 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java @@ -162,22 +162,50 @@ public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort { } @Override - public boolean hasPublicationHistory(String sourceKind, UUID id) { + public boolean isCurrentlyPublished(String sourceKind, UUID id) { return Boolean.TRUE.equals( jdbcClient .sql( - "SELECT EXISTS (" - + " SELECT 1 FROM publication" - + " WHERE source_kind = :kind AND source_id = :id" - + " UNION ALL SELECT 1 FROM publication_event" - + " WHERE source_kind = :kind AND source_id = :id" - + ")") + "SELECT EXISTS (SELECT 1 FROM publication" + + " WHERE source_kind = :kind AND source_id = :id AND status = 'PUBLISHED')") .param("kind", sourceKind) .param("id", id) .query(Boolean.class) .single()); } + /** + * 이벤트와 게시를 한 문장으로 지운다. + * + *

둘은 서로를 가리키고, 한쪽 방향만 지연 검사다 — 어떤 순서로 나눠 지워도 중간 상태에서 한쪽 제약이 깨진다. 한 문장 안에서는 외래키 검사가 문장 끝에 한 번 + * 도는 덕에 그 중간 상태가 존재하지 않는다. 순서를 맞추는 대신 순서가 필요 없게 만든다. + */ + @Override + public int deletePublicationHistory(String sourceKind, UUID id) { + int snapshots = + jdbcClient + .sql( + "DELETE FROM publication_snapshot WHERE publication_event_id IN" + + " (SELECT publication_event_id FROM publication_event" + + " WHERE source_kind = :kind AND source_id = :id)") + .param("kind", sourceKind) + .param("id", id) + .update(); + int removed = + jdbcClient + .sql( + "WITH gone_events AS (" + + " DELETE FROM publication_event" + + " WHERE source_kind = :kind AND source_id = :id" + + " RETURNING publication_event_id" + + ")" + + " DELETE FROM publication WHERE source_kind = :kind AND source_id = :id") + .param("kind", sourceKind) + .param("id", id) + .update(); + return snapshots + removed; + } + /** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */ @Override public int deleteWorkArtifacts(String sourceKind, UUID id) { diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java index 471efac..3d24c57 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java @@ -271,16 +271,36 @@ class ManagementPersistenceIntegrationTest { } @Test - void seesPublicationHistoryThroughTheKindAndIdPair() { - // 게시 이력은 문서를 외래키 없이 (source_kind, source_id) 로 가리킨다. 이 쿼리가 없어서 - // 지운 문서를 가리키는 이력이 남았고, 대시보드와 게시 기록 화면이 null 을 읽고 죽었다. + void seesOnlyAPublicationThatIsStillLive() { + // 막아야 하는 것은 "게시한 적이 있다" 가 아니라 "지금 읽히고 있다" 다 — 게시를 취소한 기록을 + // 영영 지울 수 없게 하면, 작성자가 공개를 취소하는 이유 자체가 막힌다. UUID id = insertCase("게시된 적 있는 기록"); - assertThat(deletion.hasPublicationHistory("CASE", id)).isFalse(); + assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse(); insertPublication(id); + assertThat(deletion.isCurrentlyPublished("CASE", id)).isTrue(); + assertThat(deletion.isCurrentlyPublished("QUESTION", id)).isFalse(); - assertThat(deletion.hasPublicationHistory("CASE", id)).isTrue(); - assertThat(deletion.hasPublicationHistory("QUESTION", id)).isFalse(); + jdbcClient + .sql("UPDATE publication SET status = 'UNPUBLISHED' WHERE source_id = :id") + .param("id", id) + .update(); + assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse(); + } + + @Test + void clearsThePublicationHistoryInDependencyOrder() { + // 이력은 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다. + // 외래키가 없어 DB 가 대신 지워 주지 않으므로, 순서까지 여기서 확인한다. + UUID id = insertCase("이력을 남긴 기록"); + insertPublication(id); + assertThat(countIn("publication", "source_id", id)).isEqualTo(1); + assertThat(countIn("publication_event", "source_id", id)).isEqualTo(1); + + // 반환값은 지운 publication 행 수다 — 이벤트는 같은 문장에서 함께 사라지므로 따로 세지 않는다. + assertThat(deletion.deletePublicationHistory("CASE", id)).isEqualTo(1); + assertThat(countIn("publication", "source_id", id)).isZero(); + assertThat(countIn("publication_event", "source_id", id)).isZero(); } @Test diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java index 529cd52..eae0541 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java @@ -46,10 +46,18 @@ public interface DocumentDeletionPort { boolean questionReferenced(UUID id); /** - * 게시 이력이 남아 있는지. {@code publication}/{@code publication_event} 는 문서를 외래키 없이 {@code (source_kind, - * source_id)} 로 가리키므로 DB 가 막아 주지 않는다 — 실제로 그래서 지운 문서를 가리키는 이력이 남아 대시보드와 게시 기록 화면이 null 을 읽고 죽었다. + * 지금 공개돼 있는지. + * + *

처음에는 "게시한 적이 있는가" 로 막았는데, 그러면 게시를 취소한 기록을 영영 지울 수 없다 — 작성자가 공개를 취소하는 이유가 대개 없애려는 것인데도. 막아야 + * 하는 것은 지금 읽히고 있는 것이 소리 없이 사라지는 일 이므로 조건도 그것이다. */ - boolean hasPublicationHistory(String sourceKind, UUID id); + boolean isCurrentlyPublished(String sourceKind, UUID id); + + /** + * 게시 이력을 치운다. 이력은 어떤 기록에 무슨 일이 있었는지 말하는 것이라, 그 기록이 사라지면 아무것도 가리키지 않는다 — 실제로 그렇게 남은 행들이 대시보드와 게시 + * 기록 화면을 죽였다. {@code publication} 계열은 외래키가 없어 DB 가 대신 해 주지 않는다. + */ + int deletePublicationHistory(String sourceKind, UUID id); /** Decision 은 프로젝트에 속한다 — 경로가 둘 다 들고 있으므로 둘로 찾는다. */ record DeletableDecision(UUID id, String decisionStatus, long version) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java index 17384fa..bcc6064 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java @@ -49,19 +49,15 @@ public class DeleteDocumentDraftUseCase { () -> ManagementException.of( ManagementError.DOCUMENT_NOT_FOUND, "no such working copy")); + // 지금 읽히고 있는 것만 막는다. 세 곳이 공개 여부를 따로 들고 있으므로 셋 다 본다 — + // 어긋난 상태로 지우면 공개 화면이 없는 기록을 가리키게 된다. if ("PUBLISHED".equals(current.workflowStatus()) - || documents.publiclyProjected(documentType, command.id())) { + || documents.publiclyProjected(documentType, command.id()) + || documents.isCurrentlyPublished(documentType, command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_PUBLISHED, "the record is published; unpublish it before deleting"); } - // 게시된 적이 있으면 이력이 남아 있다. 그 이력은 무슨 일이 있었는지에 대한 기록이고, - // 문서만 지우면 아무것도 가리키지 않는 이력이 되어 화면을 깨뜨린다. - if (documents.hasPublicationHistory(documentType, command.id())) { - throw ManagementException.of( - ManagementError.DOCUMENT_IN_USE, - "the record has publication history; it cannot be deleted"); - } if (documents.documentReferenced(command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_IN_USE, @@ -71,6 +67,7 @@ public class DeleteDocumentDraftUseCase { // 대신 해 주지 않고, 남겨 두면 없는 기록을 가리키는 행이 된다. documents.deleteProjection(documentType, command.id()); documents.deleteWorkArtifacts(documentType, command.id()); + documents.deletePublicationHistory(documentType, command.id()); if (documents.deleteDocument(command.id(), command.expectedVersion()) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java index aa1a605..746e62b 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java @@ -50,10 +50,10 @@ public class DeleteProjectDecisionUseCase { () -> ManagementException.of( ManagementError.DECISION_NOT_FOUND, "no such decision")); - if (documents.hasPublicationHistory("PROJECT_DECISION", decisionId)) { + if (documents.isCurrentlyPublished("PROJECT_DECISION", decisionId)) { throw ManagementException.of( ManagementError.DECISION_IN_USE, - "the decision has publication history; it cannot be deleted"); + "the decision is published; unpublish it before deleting"); } if (documents.decisionReferenced(decisionId)) { throw ManagementException.of( @@ -62,6 +62,7 @@ public class DeleteProjectDecisionUseCase { } documents.deleteProjection("PROJECT_DECISION", decisionId); documents.deleteWorkArtifacts("PROJECT_DECISION", decisionId); + documents.deletePublicationHistory("PROJECT_DECISION", decisionId); if (documents.deleteDecision(decisionId, expectedVersion) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java index b0bd700..6ceecfe 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java @@ -43,16 +43,12 @@ public class DeleteQuestionUseCase { () -> ManagementException.of( ManagementError.QUESTION_NOT_FOUND, "no such question")); - if (documents.publiclyProjected("QUESTION", command.id())) { + if (documents.publiclyProjected("QUESTION", command.id()) + || documents.isCurrentlyPublished("QUESTION", command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_PUBLISHED, "the question is published; unpublish it before deleting"); } - if (documents.hasPublicationHistory("QUESTION", command.id())) { - throw ManagementException.of( - ManagementError.QUESTION_IN_USE, - "the question has publication history; it cannot be deleted"); - } if (documents.questionReferenced(command.id())) { throw ManagementException.of( ManagementError.QUESTION_IN_USE, @@ -60,6 +56,7 @@ public class DeleteQuestionUseCase { } documents.deleteProjection("QUESTION", command.id()); documents.deleteWorkArtifacts("QUESTION", command.id()); + documents.deletePublicationHistory("QUESTION", command.id()); if (documents.deleteQuestion(command.id(), command.expectedVersion()) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java index d6cb3e7..0c1a864 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java @@ -55,6 +55,15 @@ public final class StudioDocumentValidator { return new Outcome(status, issues.toList()); } + /** + * 게시를 막는 것은 두 가지뿐이다: 제목과 slug. + * + *

예전에는 Case 하나에 일곱 칸을 요구했고, 보여 줄 만한 초안을 가진 작성자가 그것을 보여 줄 수 없었다 — 기록을 남기라고 만든 도구가 기록을 막고 있었다. + * 무엇을 얼마나 쓸지는 작성자가 정하고, 덜 쓴 기록은 덜 쓴 채로 공개된다. + * + *

남긴 둘은 취향이 아니라 도달 가능성이다. 제목이 없으면 목록에 실을 수 없고, slug 가 없으면 가리킬 주소가 없다. 그 밖에 오류로 남은 것들은 전부 "가리키는 + * 대상이 없다" 는 문제다 — 없는 주제·프로젝트·관계·Asset 을 가리킨 채 공개하면 읽는 쪽에서 깨진다. + */ private static void validateBase(WorkingCopyBaseInput base, ValidationIssues issues) { requireText(issues, base.title(), "TITLE_REQUIRED", "/title", "a title is required to publish"); // 렌더 모델의 slug 는 minLength 3 + 패턴이다. 저장은 빈 slug 를 허용하지만 게시는 못 한다. @@ -63,10 +72,10 @@ public final class StudioDocumentValidator { } else if (base.slug().length() < 3) { issues.error("SLUG_TOO_SHORT", "/slug", "a slug must be at least 3 characters"); } - requireText( + warnIfBlank( issues, base.summary(), "SUMMARY_REQUIRED", "/summary", "a summary is required to publish"); if (base.topicId() == null) { - issues.error("TOPIC_REQUIRED", "/topicId", "a topic is required to publish"); + issues.warning("TOPIC_REQUIRED", "/topicId", "a topic is required to publish"); } } @@ -85,7 +94,8 @@ public final class StudioDocumentValidator { == dev.caskeleton.application.techlog.studio.model.RecordKind.PROJECT_DECISION && document.base().projectId() == null) { // 계약: "kind=PROJECT_DECISION 은 게시 시점에 non-null 이어야 한다." - issues.error("PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project"); + issues.warning( + "PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project"); } List relations = document.base().relations(); @@ -112,33 +122,33 @@ public final class StudioDocumentValidator { private static void validateKindSpecific(WorkingCopyView document, ValidationIssues issues) { switch (document) { case WorkingCopyView.CaseWorkingCopyView value -> { - requireText( + warnIfBlank( issues, value.problem(), "PROBLEM_REQUIRED", "/problem", "a problem is required"); - requireText( + warnIfBlank( issues, value.conclusion(), "CONCLUSION_REQUIRED", "/conclusion", "a conclusion is required"); if (value.lastVerifiedOn() == null) { - issues.error( + issues.warning( "LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "a verification date is required"); } } case WorkingCopyView.ReferenceWorkingCopyView value -> { - requireText( + warnIfBlank( issues, value.purpose(), "PURPOSE_REQUIRED", "/purpose", "a purpose is required"); if (value.rules().isEmpty()) { - issues.error("RULES_REQUIRED", "/rules", "at least one rule is required"); + issues.warning("RULES_REQUIRED", "/rules", "at least one rule is required"); } if (value.applyWhen().isEmpty()) { - issues.error( + issues.warning( "APPLY_WHEN_REQUIRED", "/applyWhen", "at least one application condition is required"); } if (value.verifiedOn() == null) { - issues.error("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required"); + issues.warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required"); } requireOrderedText(issues, value.applyWhen(), "/applyWhen"); requireOrderedText(issues, value.exceptions(), "/exceptions"); @@ -146,12 +156,12 @@ public final class StudioDocumentValidator { } case WorkingCopyView.QuestionWorkingCopyView value -> { if (value.questionStatus() == null) { - issues.error("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required"); + issues.warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required"); } if (value.facts().isEmpty()) { - issues.error("FACTS_REQUIRED", "/facts", "at least one established fact is required"); + issues.warning("FACTS_REQUIRED", "/facts", "at least one established fact is required"); } - requireText( + warnIfBlank( issues, value.nextValidation(), "NEXT_VALIDATION_REQUIRED", @@ -159,7 +169,7 @@ public final class StudioDocumentValidator { "the next validation step is required"); if (value.questionStatus() == QuestionStatusView.RESOLVED && (value.resolution() == null || isBlank(value.resolution().summary()))) { - issues.error( + issues.warning( "RESOLUTION_REQUIRED", "/resolution", "a resolved question needs its resolution"); } requireOrderedText(issues, value.facts(), "/facts"); @@ -169,18 +179,18 @@ public final class StudioDocumentValidator { } case WorkingCopyView.ProjectDecisionWorkingCopyView value -> { if (value.decisionStatus() == null) { - issues.error("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required"); + issues.warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required"); } if (value.decidedOn() == null) { - issues.error("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required"); + issues.warning("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required"); } - requireText( + warnIfBlank( issues, value.statement(), "STATEMENT_REQUIRED", "/statement", "a statement is required"); - requireText( + warnIfBlank( issues, value.rationale(), "RATIONALE_REQUIRED", @@ -238,6 +248,14 @@ public final class StudioDocumentValidator { } } + /** 비어 있음을 경고로 알린다. 게시를 막지는 않는다 — 무엇을 얼마나 쓸지는 작성자가 정한다. */ + private static void warnIfBlank( + ValidationIssues issues, String value, String code, String path, String message) { + if (value == null || value.isBlank()) { + issues.warning(code, path, message); + } + } + private static void requireText( ValidationIssues issues, String value, String code, String path, String message) { if (isBlank(value)) { @@ -245,13 +263,12 @@ public final class StudioDocumentValidator { } } - /** 계약의 {@code OrderedText.text} 는 minLength 1 이다 — 빈 항목이 있으면 렌더 모델이 계약을 어긴다. */ + /** 빈 항목은 빈 항목으로 그려진다. 작성자가 거기 그렇게 둔 것이므로 알리기만 한다. */ private static void requireOrderedText( ValidationIssues issues, List items, String path) { for (int i = 0; i < items.size(); i++) { if (isBlank(items.get(i).text())) { - issues.error( - "ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "an empty item cannot publish"); + issues.warning("ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "this item is empty"); } } } diff --git a/src/config/openapi/studio-v1.yaml b/src/config/openapi/studio-v1.yaml index f630344..8a2edbd 100644 --- a/src/config/openapi/studio-v1.yaml +++ b/src/config/openapi/studio-v1.yaml @@ -871,7 +871,7 @@ components: required: [id, text, order] properties: id: { type: string, format: uuid } - text: { type: string, minLength: 1, maxLength: 100000 } + text: { type: string, maxLength: 100000 } order: { type: integer, minimum: 0 } ReferenceRule: type: object @@ -1242,7 +1242,7 @@ components: 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 } + summary: { type: string, maxLength: 300 } publicPath: { type: string, minLength: 1, maxLength: 500 } topic: { $ref: "#/components/schemas/DisplayTarget" } project: @@ -1257,7 +1257,7 @@ components: required: [type, text] properties: type: { type: string, enum: [TEXT] } - text: { type: string, minLength: 1, maxLength: 100000 } + text: { type: string, maxLength: 100000 } InlineContainer: type: object required: [type, children] @@ -1483,8 +1483,8 @@ components: 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 } + problem: { type: string, maxLength: 100000 } + conclusion: { type: string, maxLength: 100000 } environment: { type: string, maxLength: 100000 } reproduction: { type: string, maxLength: 100000 } lastVerifiedOn: { type: string, format: date } @@ -1497,7 +1497,7 @@ components: required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn] properties: kind: { type: string, enum: [REFERENCE] } - purpose: { type: string, minLength: 1, maxLength: 100000 } + purpose: { type: string, 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" } } @@ -1508,7 +1508,7 @@ components: additionalProperties: false required: [summary, evidenceTarget, linkLabel] properties: - summary: { type: string, minLength: 1, maxLength: 100000 } + summary: { type: string, maxLength: 100000 } evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" } linkLabel: { type: string, minLength: 1, maxLength: 120 } QuestionPublicRenderModel: @@ -1528,7 +1528,7 @@ components: 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 } + nextValidation: { type: string, maxLength: 100000 } resolution: oneOf: - { $ref: "#/components/schemas/ResolvedQuestionResolution" } @@ -1543,8 +1543,8 @@ components: 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 } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } PublicRenderModel: oneOf: