Creating a Question was the one document kind that failed: the request carried
`resolution: null`, exactly as the contract describes, and validation rejected
it with "must not be null".
The generated model is where the two disagree. The contract writes the field's
nullability as `oneOf: [$ref, {type: "null"}]`, which this generator does not
read as nullable, so it saw only the `required` list and emitted @NotNull.
`questionStatus` sits in the same required list and worked, because it spells
its nullability `type: [string, "null"]`. The contract now leaves `resolution`
out of the input's required list, and the regenerated getter is @Nullable.
Case, Reference and Decision were never affected — none of them has a nullable
$ref in a required list, which is why only Question broke.
Deleting a working copy returned 500. The reference check queried
`public_resource_projection.document_id`, and that column does not exist — the
table addresses records by `(resource_type, resource_id)` because one table
holds cases, questions, projects and releases alike. Five of the six columns in
that query were verified against the migrations; this one was assumed, and it
was the one that was wrong.
It also has no foreign key to `document`, so it was never going to block a
delete the way the check implied. What it can do is outlive the record: the
projection is derived data with nothing to cascade it away, and a row left
behind points the public site at something that is gone. So publication is now
checked directly on the projection as well as on `workflow_status` — the two
live in different tables and can disagree — and a withdrawn projection is
removed with the record, which cascades its public routes.
The real failure was that this SQL had never run. The neighbouring integration
test says so in its own header: the standard `check` does not start
Testcontainers, so persistence SQL passes the build without ever being
executed, and neither compilation nor a unit test catches a column name. The
delete path was simply outside it. It has its own task now, and eight scenarios
that run against real PostgreSQL — including the exact query that failed.
An author who opens a draft and thinks better of it had no way out — the
working-copy list could create, edit, validate and publish, and that was all.
The three delete operations existed in the contract with no implementation.
Deleting is not a cascade, which is the part worth being careful about. A
document's own rows follow it: the detail row, its tags, the relations it
points outward. But five tables reference `document` without ON DELETE CASCADE
— another document's relation target, a question's link, a project's
membership, a topic's featured list, a decision's source case — and two do the
same for `open_question`. Deleting through any of them is a foreign-key
violation, which reaches the author as a 500 that explains nothing. So the
delete checks first and refuses with DOCUMENT_IN_USE, the same refusal
TOPIC_IN_USE already makes. Quietly editing someone else's record to make room
is the worse option.
A published record is refused outright. Public pages, search and other records
link to it, and one that vanishes leaves all of them pointing at nothing —
unpublishing is the way out, and it already exists.
Case and Reference share one table split by `document_type`, so the type is
part of the lookup: without it, the Case route would happily delete a
Reference. Decisions have no delete at all, and that is the contract's
judgment rather than an omission — accept, reject and supersede record what
happened instead of erasing it.
The public site has a Releases page and a footer link to the latest release,
and both were empty — the read side has existed since the public surface
landed, but nothing could ever create a row. The seven release operations were
in the contract with no implementation, so the changelog was a page that could
only ever be blank.
The release model is not a blob of prose. It splits into six markdown sections
because a release note answers fixed questions — why, what, what changes for a
reader, what it leaves in the code, how it was verified, what is still missing
— and a single text column cannot say which of those went unanswered.
Publishing is the only thing that makes a release public: the public query
filters on `workflow_status = 'PUBLISHED'` and nothing else. So publish is
where the contract's required fields are actually enforced. Saving stays
permissive — a draft you cannot save until it is complete is a draft you cannot
write — and the two demands are deliberately different.
`version_label` is NOT NULL UNIQUE but a draft has no version yet, so creation
writes a placeholder derived from the row id and publication refuses to ship
one. Relaxing the column instead would open a window where a published release
is publicly visible with no version at all.
A published release cannot be deleted, only archived: a public changelog entry
that vanishes leaves everyone who linked it with no way to learn what happened.
Also registers `adapter-outbound-objectstorage` as an app-bootstrap runtime
member. It was added as a dependency when asset upload was fixed but never
registered, and `verifyRuntimeModuleMembership` had not been run since.
createTopic answered 500: 'could not determine data type of parameter $2'.
The uniqueness check reads
WHERE normalized_name = :name AND (:except IS NULL OR id <> :except)
and on a create there is no id to exclude, so :except is null. Postgres infers
a parameter's type from how it is used, and `IS NULL` tells it nothing — with
the other use behind an OR it never gets a second chance.
Casting both uses to uuid gives it the type without changing the predicate.
Project creation passed the same code path only because it never reaches
slugTaken: a draft opens with no slug, so the check is skipped. The topic path
runs it on every create.
JdbcProjectRepositoryAdapter asked for com.fasterxml.jackson.databind.ObjectMapper
— Jackson 2. This build is on Jackson 3 (tools.jackson.databind), so no bean
of that type exists and the context failed to refresh: the pod crash-looped
with 'Parameter 1 ... required a bean of type ObjectMapper that could not be
found'.
Compilation could not catch it. The Jackson 2 types are still on the classpath
through some transitive dependency, so the wrong import resolves and only the
container tells you. PublicJson in the sibling package was already on Jackson 3
and is the shape this now follows.
readTree/asString rather than readValue with a TypeReference: Jackson 3 does
not throw a checked exception here, so the surrounding try/catch narrows to
RuntimeException and the method keeps its contract of degrading to an empty
list rather than failing the whole edit screen.
Publishing was impossible on an empty database. Validation requires a topic,
the studio catalog answered zero topics, and nothing in the two implemented
contracts could create one — `studio-management-v1.yaml` owned that surface
and none of its 79 operations existed. Every path to a published record ran
through a door with no handle.
This implements the nine that unblock authoring: topics (list/create/update/
delete) and projects (list/get/create/update/delete). The remaining seventy
stay unimplemented; each has its own consumer and its own moment.
The contract was converted to the response envelope first (ADR-006), which is
what its own header said to do at implementation time. Doing it after would
have meant changing the wire shape of endpoints the frontend had already been
written against.
ManagementError is a separate enum rather than an extension of StudioError.
Each contract enumerates its own ApiError.code set, so a code reachable from
the wrong surface makes that contract false. It deliberately omits
INTERNAL_ERROR: the skeleton's OperationalError owns that code with
retryable=true, and declaring it twice with different values leaves the
registry with no answer. PublicError made the same call for the same reason.
Two contract defects surfaced while implementing. TopicEdit had neither id nor
version, so a listed topic could not be addressed by the `/topics/{id}` path
and a client had no source for the expectedVersion the write operations
require; both are fixed in the design package. The AWS SDK BOM had to be
imported in app-bootstrap as well — module-scoped dependency management does
not propagate to consumers, and this is the first runtime consumer of that
pattern.
Topic and project deletion refuse while records still reference them rather
than cascading. A topic disappearing should not silently reclassify the
documents that used it; moving them first is the caller's decision to make.
ActuatorSecurityHttpTest.healthEndpointIsPermitAll fails on this branch before
this change as well; it is untouched here.
`ObjectStorageAssetBinaryAdapter` bridges Studio's asset port to the object
storage port through an `ObjectProvider`, which means it compiles whether or
not an implementation is on the classpath. None was: app-bootstrap never
depended on `:adapter:outbound:objectstorage`, so the provider was always
empty and every upload and delete answered STUDIO_UNAVAILABLE with the message
"set ca-skeleton.objectstorage.* to enable" — configuration advice for a
missing dependency, which sends the reader looking in the wrong place.
The AWS SDK BOM has to be imported here as well. The objectstorage module
imports it at module scope on purpose (its comment explains: keep the strict
locking blast radius contained), and Spring's dependency management does not
propagate to consumers, so assembling the runtime here left s3 and
netty-nio-client without versions. The grpc module has the same shape and did
not surface it because app-bootstrap only consumes grpc from a test
configuration; this is the first runtime consumer of that pattern.
Lock state regenerated for the SDK's transitive set.
ActuatorSecurityHttpTest.healthEndpointIsPermitAll fails on this branch before
this change as well; it is untouched here.
main이 마지막으로 본 것은 스켈레톤 초기화(697fc74)까지였다. 그 뒤 develop에 쌓인
Tech Log 백엔드 전체를 가져온다.
- Studio 백엔드 기반(Plan 01)과 남은 17개 operation → studio-v1 19/19
- release-gate 수정 4건(authz 배선, BFF 로그인 경로, 계약 nullable/오류 코드, 체크리스트)
- 공개 조회 백엔드 → public-v1 18/18
각 판단의 근거는 해당 커밋 메시지에 있다.
검증: ./gradlew check BUILD SUCCESSFUL (248 task). PostgreSQL 통합 테스트
(Studio 11 + 공개 조회 24) 통과. 실제 앱 기동 후 public-v1 18개 operation 실호출 5xx 0건.
알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
redis가 호스트 포트에 노출되지 않아 헬스가 DOWN인 환경 문제이며 코드와 무관하다
(기저 커밋에서도 동일하게 재현됨).
AGENTS.md의 commit 정책은 human-only다. 이 머지는 사용자가 "develop과 main에
반영하도록" 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
public-v1.yaml의 18개 operation 전부와, 그 과정에서 드러난 결함들의 수정을 통합한다.
studio-v1(19/19)에 이어 public-v1도 18/18이 되어 프론트엔드가 소비할 두 계약이 모두
서버에 존재한다.
함께 들어오는 것
- 743fee3까지의 release-gate 수정 4건(Studio authz 배선, BFF 로그인 경로, 계약의
nullable/오류 코드 정정, 로컬 체크리스트 마감)
- 그 수정을 지키던 미추적 테스트 2개
이 머지에 담긴 판단은 각 커밋 메시지에 있다. 요지는 하나다 — 코드를 쓴 것만으로는
드러나지 않고 실제 PostgreSQL과 실제 기동이 잡아낸 결함이 다섯 건 있었다.
생성기의 조용한 필드 누락, 계약이 선언했는데 무시되던 필터, 하드코딩된 빈 응답 필드,
계약 밖 enum 유출, 그리고 배포 직후 홈 화면을 깨뜨리던 NULL 기본값.
검증: ./gradlew check BUILD SUCCESSFUL (248 task), 공개 조회 통합 테스트 24/24,
실제 앱 기동 후 18개 operation 실호출 5xx 0건.
AGENTS.md의 commit 정책은 human-only다. 이 머지는 사용자가 "develop과 main에
반영하도록" 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제
2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어
public-v1도 18/18이다.
생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias
swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다.
거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고,
validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt,
ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가
사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까.
원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고
snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는
alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를
받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다.
- 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가
실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다
- verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다.
이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다
계약이 선언했는데 서버가 무시하던 필터를 채웠다
지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다.
- exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음
- listPublicProjectDecisions: status 필터 없음
- listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음
- 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다
- enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다
응답 봉투와 오류 경계
- 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006)
- PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를
...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터
오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는
public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다
- FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다
실행이 잡아낸 결함
컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다.
- profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다
- latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의
LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다
- home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를
required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다.
HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다
V9__techlog_public_surface.sql
설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config,
profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행
시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다.
게이트
- PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다.
계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해
"비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다
- PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조.
INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로
고정한다. vendored 계약의 MANIFEST 해시도 확인한다
- postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제
PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가
없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를
함께 심어 어느 경로로도 새지 않는지 확인한다
검증
./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24.
실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종
전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다.
알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아
헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다.
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
37d5614("fix: make Studio authorization actually work, and stop it failing as a
500")가 고친 배선을 지키는 테스트인데 커밋에 들어가지 않아 작업 트리에만 있었다.
추적되지 않으면 브랜치를 옮길 때 조용히 사라지고, 다른 사람이 같은 저장소를 받아도
그 회귀 게이트를 갖지 못한다.
- StudioAuthzWiringTest: MethodSecurityConfig의 advisor가 AuthorizationPort를
생성자로 받는 인프라 빈이라, auto-proxy보다 먼저 만들어지며 AuthorizationAdapter →
RolePermissionRegistry → RolePermissionPolicy를 BeanPostProcessor 등록 전에 끌어
올린다는 사실을 고정한다. 바인딩만 따로 보면 통과하지만 앱에서는 죽는 경우다
- StudioAuthzEnvironmentPostProcessorTest: 같은 수정의 환경 설정 쪽
내용은 손대지 않았다. spotless가 요구한 줄바꿈 두 곳만 정규화됐다(의미 변경 없음).
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Duplicate relations. Connecting the same target twice saved without a
word: the contract carries no uniqueItems on relations (only maxItems 20)
and the validator checked order uniqueness but not target. The document
then renders the same row twice publicly, and removing one leaves the
other behind — "삭제했는데 그대로". Rejected now, alongside the existing
order check.
two distinct targets 201
same target twice 422 REQUEST_VALIDATION_FAILED
The prod DDL guard ran too late. JpaSchemaSafetyValidator was a
SmartInitializingSingleton, which fires after every singleton exists —
including entityManagerFactory, which Hibernate builds by applying
ddl-auto. Booting prod with ddl-auto=update logged "Initialized JPA
EntityManagerFactory" first and the PROFILE_MISMATCH second, with the
tables Hibernate created in between still in the schema. The guard stopped
traffic but not schema mutation, so a misconfigured deploy had already
changed the production database by the time it refused to start. It is a
BeanFactoryPostProcessor now, before any bean is instantiated.
fs_* tables dropped, prod booted with ddl-auto=update
exit 71, no EntityManagerFactory line, 0 tables created
Object storage inside a database transaction. UploadStudioAssetUseCase
called binaries.store from inside inWrite, holding a connection and its
locks for the length of a network round-trip — a slow storage backend
becomes connection-pool exhaustion. It bought nothing: storage does not
join the transaction, so a failed commit leaves the bytes written either
way. Storage now happens first and the database write is a short
transaction; a failed write deletes the object it just uploaded, and a
failed delete is attached with addSuppressed rather than replacing the
error the caller needs to see.
Full build passes apart from one fileserver flake
(LocalPersistentControlPlaneTest.heldOperationReentrancyIsScopedToThe
AttestedRoot) that passes in isolation and touches none of these files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two contract mismatches, both found by driving the API and both invisible
from inside the repository because nothing compares the wire to the spec.
Nullable-but-required. The contract says required means "the key is
present", not "the value is set" — WorkingCopyInputBase spells it out:
"불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을
허용한다". The generator moves `required` straight to @NotNull, so
topicId, projectId, lastVerifiedOn, verifiedOn, decidedOn, decisionStatus
and questionStatus all became non-null, and saving a partial draft failed:
{"projectId": null} → 400 NOT_NULL "Required value is missing"
prepareStudioCodegenSpec already derives a codegen-only copy of the spec,
so the relaxation happens there — 33 properties leave `required` in that
copy and the canonical file is untouched, which matters because the
frontend reads the same file and its reading is the correct one. Value
constraints stay: title still carries @NotNull @Size(max = 120).
all-null / omitted / empty slug 201
title 121 chars 422
slug "Bad Slug!" 422
Error codes. Body validation fell through to the template's handler and
answered 400 VALIDATION_FAILED, a code the Studio contract does not
declare (it knows REQUEST_VALIDATION_FAILED and DOCUMENT_VALIDATION_
FAILED); denials answered AUTHZ_INSUFFICIENT_PERMISSION where the
contract assigns STUDIO_ACCESS_DENIED to 403. The frontend validates the
envelope's code against an enum, so an undeclared code breaks parsing
rather than surfacing as the error it is. Both now map in
StudioExceptionHandler, which is already scoped to the techlog package so
fileserver and healthcheck keep their existing shapes.
body validation 422 REQUEST_VALIDATION_FAILED
denial 403 STUDIO_ACCESS_DENIED
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
auth-mode=redis-session was unreachable: getStudioSession answered 503 on
every call because the CSRF token is null when CsrfFilter never runs, and
CsrfFilter only runs in the session branch, which could not be selected
because AuthenticationModeCompositionConfig requires a
`redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair
and only the second existed. Even with the pair present nothing could
create a session — there was no login endpoint and no client registration.
This is the surface the contract already describes: securitySchemes
declares a session cookie plus X-CSRF-TOKEN on mutations, not a bearer
token, and SecurityConfig's session branch (cookie CSRF repository,
session-fixation migration) plus RedisSessionWebConfig (servlet filter,
host-only cookie) were already written for it. The SPA never holds a
token; the backend owns the session.
- StudioSessionInfrastructureConfig supplies the missing repository
under the name the composition validator looks for. @EnableRedisHttp
Session is not used because it pins the bean name to sessionRepository.
- StudioOidcLoginSuccessHandler converts the OidcUser into an
AuthenticatedPrincipal. PrimitiveSessionSecurityContextRepository
rejects anything else on save — deliberately, so credentials and
framework object graphs never cross the session boundary — and it
restores the same type on load. Roles are unioned from realm_access
and resource_access exactly as the JWT converter does, so both modes
resolve the same set and the studio:read / studio:write mapping
behaves identically.
- SecurityConfig wires oauth2Login (only when a success handler bean is
present, so JWT mode is untouched) and a /logout that invalidates the
session. The envelope 401 stays the entry point: an unauthenticated
API call must not answer 302, which an XHR cannot follow. The SPA
navigates the browser to /oauth2/authorization/{id} instead.
Verified in a browser against a real Keycloak realm:
/oauth2/authorization/keycloak → Keycloak → callback
TECHLOG_SESSION cookie set, httpOnly
GET /api/v1/studio/session 200 {authenticated, displayName, roles,
csrfToken, csrfHeaderName}
POST /api/v1/studio/documents 403 without the CSRF header
201 with it
GET /api/v1/studio/documents 200
Also removes the same broken placeholder-in-map-key role mapping from the
dev profile that the previous commit fixed in local and prod.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four linked defects, found by driving the 19 operations against a running
backend on PostgreSQL behind a real Keycloak realm.
1. The role→permission mapping never bound. Both profiles wrote it as
role-permissions:
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
and Spring Boot resolves placeholders in @ConfigurationProperties
*values*, not in Map *keys* — the key bound as the literal
"${APP_STUDIO_AUTHOR_ROLE:studio-author}", matched no real role, and
left RolePermissionRegistry empty. Every Studio write answered 403, in
local and prod alike. Setting APP_STUDIO_AUTHOR_ROLE explicitly did not
help; a literal key returned 201 immediately. StudioAuthzEnvironmentPost
Processor now resolves the role name as a scalar (where placeholders do
work) and contributes the mapping under a literal key, so the name stays
deployment-configurable. Registered the same way the tracing bridge is.
2. Reads were unguarded. Only WRITE carried @RequiresPermission, so any
authenticated caller could list every draft and fetch one by id:
listStudioDocuments 200, 2 drafts getStudioDocument 200
getStudioDashboard 200 listStudioAssets 200
The nine read use cases now declare studio:read. They lose `final` for
the same CGLIB reason the write ones already document.
3. Failures were masked. IdempotencyExecutor's catch called store.discard,
whose @Modifying bulk delete needs a transaction and had none, so it
threw TransactionRequiredException over the original exception — the 403
above surfaced as 500 INTERNAL_ERROR with no cause in the log, which is
why this shipped. discard now runs REQUIRES_NEW (cleanup must survive
the failed work's rollback) and a cleanup failure is attached with
addSuppressed instead of replacing what actually went wrong.
4. Reservations leaked. With discard throwing every time, failed requests
left their idempotency rows behind. After the fix only the successful
call's COMPLETED row remains.
Verified end to end: studio-author writes with no extra configuration;
an unprivileged caller gets 403 on all five read operations and on write;
create → save → validate → preview → publish → unpublish all succeed;
optimistic lock returns 409 VERSION_CONFLICT; the publication reaches
public_resource_projection and flips to WITHDRAWN on unpublish.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
studio-v1.yaml 19개 operation을 19/19로 완성한다. Plan 01이 남긴 17개(문서 CRUD,
검증·미리보기, 게시, Asset)와 Plan 02로 미뤘던 생성기 union 차단 요인 해소.
fix/release-gate-backend의 e615c24(Studio를 계약 경로로 서빙)도 함께 들어온다 —
이 브랜치가 거기서 갈라져 나왔고, 컨트롤러가 /api 접두사를 중복하지 않는다는
그 수정이 이번 구현의 전제다.
범위는 spec §2.1의 studio-v1 19개다. public-v1(18)/studio-management-v1(79)은
spec §2.2가 선언한 out of scope로 남는다.
AGENTS.md의 commit 정책은 human-only다. 사용자가 명시적으로 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.
Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다
설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
(source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다
영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.
nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.
렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다
검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.
게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다
Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다
검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다
가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록
범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.
검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PresentationWebConfig prefixes every controller mapping with
ca-skeleton.presentation.api-base-path ("/api"), which is why every other
controller in this repository declares its path without it — healthcheck
is "/healthcheck", uploads are "/v1/uploads", files are "/v1/files". The
two Studio controllers declared "/api/v1/studio/..." instead, so the
prefix landed on top of a path that already had it and both operations
were served at /api/api/v1/studio/... — nowhere near the address
studio-v1.yaml declares (servers: "/", paths: /api/v1/studio/...). The
frontend calls the contract path, so nothing connected.
Verified against a running backend on PostgreSQL behind a real Keycloak:
/api/v1/studio/catalog?type=TOPIC 200, 3 items (was 404)
/api/api/v1/studio/catalog?type=TOPIC 404 (was 200)
Why the tests were green while production was broken: the three slice
tests and both nested apps in StudioContractDriftTest build contexts that
never include PresentationWebConfig, so no prefix was applied and the
controllers' literal "/api/v1/..." matched. They now import it and supply
the same "/api" the real app uses, which makes the paths they exercise the
effective ones. Re-introducing the bug fails five of them.
StudioContractDriftTest needed two more repairs to stay meaningful:
springdoc's own endpoint is prefixed too, so the published document is
read from /api/v3/api-docs; and publishedStudioOperationsMatchTheContract
skips any path not starting with /api/v1/studio/, so a missing prefix
would have made it compare nothing and pass. It now asserts it compared at
least one path — a gate that cannot see drift is not the same as no drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.
계약과 생성
- src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록
- openapi-generator로 DTO(model)만 생성한다. generateApis 대신
globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다
- useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이
깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다
- 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated
API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다
오류 계약
- StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier)
- StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을
바꾸지 않기 위해서다
- 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는
로그 전용이다(ApiErrorCarrier javadoc의 요구)
- 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다
게이트
- TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를
늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다
- StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증
- StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라
새 컨트롤러가 자동으로 걸린다
- StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의
csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를
테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다
스키마
- V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존
idempotency_record 재사용)와 범위 밖 6종을 제외했다
- 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway
세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다
알려진 제약
- getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을
반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로
200을 만들지 않았다
- 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다
이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로
작성됐다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>