Commit Graph
37 Commits
Author SHA1 Message Date
DongHyeonka f1fd56fcb5 chore: decision 미리보기기 계약 수정 2026-08-24 18:12:38 +09:00
DongHyeonkaandClaude Opus 5 bd66fb3610 fix: Decision 미리보기가 열리도록 프로젝트 공개 경로를 catalog 에 싣는다
Decision 문서는 즉시 미리보기가 어떤 문서에서도 열리지 않았다.

Decision 의 공개 주소는 자기 slug 가 아니라 `<프로젝트 경로>/decisions#<slug>` 다.
그래서 렌더 모델이 프로젝트의 공개 경로를 요구하는데, PROJECT catalog 는 그 자리를
언제나 null 로 돌려주고 있었다 — 게시된 프로젝트인지 아닌지와 무관하게 상수 null
이었다. 미리보기는 "PROJECT public path is required" 로 멈췄고, 화면에는 무엇이
모자란지 나오지 않았다.

게시된 프로젝트만 경로를 싣는다. 게시되지 않았으면 공개 주소가 실제로 없고, 없는
주소를 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 17:25:31 +09:00
DongHyeonkaandClaude Opus 5 a7e2b7d7fe feat: 게시가 프로젝트 활동을 남기게 한다
프로젝트 활동은 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다 (publish 19단계).

문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로
타임라인에 다시 나타나지 않아야 한다 — `operation_key` 를
`publication:<documentId>` 로 두고 `uq_project_activity_operation_key` 충돌을 무시한다.

`origin` 은 `AUTO` 다. 손으로 적은 줄과 구분해 두면, 삭제 금지 규칙이 실제 사건의
흔적만 지킨다.

V10 은 이 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는 상태를
남겨 두면 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다. `occurred_at` 은 최초
PUBLISHED 사건의 시각이다 — now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼 보인다.

함께 고친 것: 마이그레이션 버전 목록이 7 에서 멈춰 있었다. TechLog 가 들어오며 8·9 가
붙었는데 이 테스트를 같이 고치지 않아, 그 빨간색은 "스키마가 잘못됐다" 가 아니라
"목록을 안 고쳤다" 를 뜻하는 상태로 두 번 지나갔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:45:36 +09:00
DongHyeonkaandClaude Opus 5 6aa140077d feat: 프로젝트 주제를 저장하고 공개 응답에 싣는다
프로젝트 화면의 "주요 주제" 가 늘 비어 있었다. `project_topic` 은 테이블도 있고
공개 조회가 조인할 수도 있었지만, 응답에 실을 자리가 없었고 저장할 경로도 없었다 —
`ProjectUpdateRequest.topicIds` 는 계약에 있었지만 명령이 그 값을 들고 다니지 않았다.

주제는 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고,
화면도 목록 하나를 한 번에 저장하므로 그쪽과도 맞는다. 고른 순서가 곧 화면 순서이므로
목록의 자리를 display_order 에 그대로 적는다.

공개 조회는 ACTIVE 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이
비어 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:33:41 +09:00
DongHyeonkaandClaude Opus 5 ca63d7d3ec fix: 스캔되는 스프링 컴포넌트의 생성자를 하나로 고정한다
새 활동 어댑터가 생성자를 둘 갖고 있었다 — 하나는 운영용, 하나는 테스트가 id 생성기를
넣기 위한 것. 둘 중 어느 것에도 @Autowired 가 없어 컴포넌트 스캔은 고르지 못하고
기본 생성자를 찾다가 실패했다.

컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부
통과했다. 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다. 운영에서 파드가
CrashLoopBackOff 로 들어갔고, 그때서야 드러났다.

생성자를 하나로 줄이고 — id 는 어댑터가 만들면 되고 통합 테스트는 그 값을 볼 필요가
없다 — 같은 실수를 다시 못 하게 D20 규칙을 세운다: 스캔되는 컴포넌트는 생성자가
하나이거나, 여럿이면 그중 하나에 @Autowired 가 붙어야 한다. 규칙이 실제로 잡는지
결함을 되돌려 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 20:14:47 +09:00
DongHyeonkaandClaude Opus 5 4c14f1eb8f feat: 프로젝트 활동을 만들고 고치고 지울 수 있게 한다
공개 프로젝트 화면의 "활동" 이 언제나 비어 있었다. 계약에는 목록·생성·수정이
선언돼 있었지만 구현이 없었고, `project_activity` 는 0행이었다.

이 테이블은 투영을 거치지 않는다 — 공개 조회가 `visibility = 'PUBLIC'` 조건으로
직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이다.

`origin` 은 어댑터가 정한다. 이 경로로 들어오는 것은 언제나 `MANUAL` 이고 `AUTO` 는
게시 파이프라인의 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 `AUTO` 를
붙여 삭제 금지를 우회할 수 있다.

생성이 붙잡는 버전은 활동이 아니라 프로젝트의 것이다. 활동은 아직 없으므로 자기
버전을 가질 수 없고, 두 사람이 같은 타임라인을 동시에 고치는 것을 막으려면 붙잡을
것이 프로젝트뿐이다.

모든 쓰기에 `project_id` 조건이 붙는다. 경로가 둘을 함께 요구하므로 활동 id 만으로
수정하면 남의 프로젝트 줄을 고칠 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:33:12 +09:00
DongHyeonkaandClaude Opus 5 561d02ae3a feat: 프로젝트 게시와 홈 focus, 그리고 기록 사이 연결을 실제로 가능하게 한다
계약에 선언만 되어 있고 구현이 없던 네 오퍼레이션과, 의도된 스텁으로 남아 있던
catalog 두 종류가 공개 화면 다섯 곳을 조용히 비워 두고 있었다.

프로젝트 게시 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데,
공개 조회들(프로젝트 목록·상세·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는
경로가 없었으므로 프로젝트는 만들 수는 있어도 공개될 수는 없었다. 릴리스가 자체 경로를
갖는 것과 같은 이유로, 문서 파이프라인에 끼워 넣지 않고 원본 상태·투영·canonical
route 세 곳을 한 트랜잭션에서 함께 세운다.

홈 focus — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을
읽고 쓸 use case 가 없었다. 지목한 대상이 실제로 있는지는 여기서 확인한다. 테이블에
FK 가 없어(설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 도 저장되고,
그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 아무것도 안 나오는,
이유를 알 수 없는 실패가 된다. 대상이 공개인지는 확인하지 않는다: 미리 지목해 두고
게시와 동시에 뜨게 하는 것이 정상적인 순서다.

catalog RELATION/EVIDENCE — 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 로
남아 있었다. 그래서 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성
중에 고르는 것이라 작업본까지 포함하고(두 문서를 같이 쓰면서 서로 잇는 것이 정상적인
순서다), EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다.

계약은 이 네 오퍼레이션을 ProblemDetails 모양으로 두고 있었다. 백엔드가 모든 JSON
응답을 envelope 으로 감싸므로 구현하는 순간 어긋난다 — 나머지와 같은 모양으로 옮겼다.
`/home-focus` 는 케밥 세그먼트라 D19(AIP-122)를 위반해 `/home/focus` 로 나눴다.

함께 고친 것들 (모두 이 작업 전부터 빨간 상태였다):
- `error-codes.yaml` 의 DOCUMENT_NOT_FOUND 가 표면마다 하나씩 두 행이었다. 이
  레지스트리의 식별자는 code 하나뿐이라 로딩 자체가 깨졌고, 그 여파로 거버넌스 테스트
  네 개와 outbox 계약 테스트가 함께 넘어졌다. 같은 코드는 같은 말을 해야 한다.
- 벤더된 계약 세 개의 MANIFEST.sha256 이 실제 파일과 어긋나 있었다.
- ActuatorSecurityHttpTest 는 "DB·Redis 없는 슬라이스"라고 적어 두고 Redis 자동설정을
  막지 않아, localhost:6379 연결 실패가 /actuator/health 를 503 으로 만들었다. 보안
  태세와 무관한 이유로 빨개지던 테스트다.
- 테스트 JVM 힙이 Gradle 기본 512m 이라 Spring context 캐시 + ArchUnit +
  Testcontainers 조합에서 OOM 이 났다. 증상이 테스트 실패가 아니라 "Executor 를 완료할
  수 없음"이어서 원인을 가리켰다.
- APP_SESSION_TIMEOUT 이 env-keys 레지스트리에 없었다.

새 SQL 은 실제 PostgreSQL 위에서 돌린다 — 컴파일도 단위 테스트도 컬럼 이름을 검증하지
못한다는 것이 이 파일이 존재하는 이유다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:19:07 +09:00
DongHyeonka 23d82bd250 fix: 오류 수정 2026-08-22 14:41:19 +09:00
DongHyeonka 857e6a9c08 fix: refuse deletion only while a record is live, and say why
Deleting from Studio was unpredictable: some records went, others answered "in
use", and the reasons did not match what the author saw on screen.

Publication history was the wrong gate. A record that had ever been published
could never be deleted — including one the author had just unpublished, which
is usually the first half of removing it. Now only a record that is currently
published is refused, and an unpublished one takes its history with it. That
history describes what happened to a record; once the record is gone it
describes nothing, and the rows left behind are exactly what crashed the
dashboard and the publication list earlier.

Publishing itself stopped demanding a finished document. A Case required seven
filled fields, so an author with something worth showing could not show it.
Title and slug remain — a record with no title cannot be listed and one with no
path cannot be addressed — and everything else became a warning. The render
models had to move with it: they declared the author's prose non-empty, so
relaxing the validator alone would have turned a friendly warning into a schema
failure at publish time.

Publication rows are removed in one statement rather than in sequence. The
publication and its events point at each other and only one direction is
deferred, so any order leaves a moment where one constraint is broken; inside a
single statement that moment does not exist.
2026-08-21 19:28:52 +09:00
donghyeon-ka 8d228255e9 style: apply the formatter to the image dimension reader
Spotless normalization only; no behaviour change.
2026-08-21 18:10:35 +09:00
DongHyeonka e65b9e2c33 fix: record an uploaded image's dimensions
Every uploaded image was invisible, and the endpoint that serves them was not
the reason — it answers 200 with the right bytes. The browser never asked for
them.

Upload stored null for width and height, so the renderer had nothing to lay out
with and fell back to 1x1. A 1x1 box with `loading="lazy"` never intersects the
viewport, so the fetch is never made: the figure was not slow or broken, it was
never requested.

Dimensions now come from the file header — PNG, GIF and JPEG, read directly
rather than decoded. ImageIO would pull in `java.desktop`, and a runtime image
without that module would fail every upload rather than one figure. WebP and
SVG are left unread and answer "unknown", which is true: WebP has three header
shapes and an SVG may carry no pixel size at all.

The offsets are covered by tests against real bytes. They are not something
review can check by eye, and the JPEG case walks past an earlier segment —
reading the first one it finds would have produced a confident wrong answer.
2026-08-21 17:57:09 +09:00
DongHyeonka 96521a94d4 feat: serve uploaded media, delete a decision, and stop orphaning publications
Three gaps that all showed up as something broken on screen.

An uploaded image could never be fetched. Every asset address the backend
builds pointed at a path nothing served, so a figure was missing in the Studio
preview and in the published page alike. There is now a public media endpoint
under the prefix that is already anonymous and already routed, so no edge
configuration has to change for an image to appear. Only READY assets are
served; narrowing it to assets a published record uses would break previewing,
which is the one thing an author does before publishing.

Deleting a working copy left wreckage. Publications, validations and previews
address a document by (kind, id) with no foreign key, so deleting one left rows
pointing at nothing — and the dashboard and publication history read a title
off that nothing and crashed. Both screens went down in production this way. A
record with publication history is now refused outright, because that history
says what was once public; validations and previews go with the record, because
they are its scaffolding.

A Decision could not be deleted at all while every other kind could. It can
now, under the same two rules, and refuses when another decision supersedes it
or the home page features it — neither cascades, so deleting would have been a
foreign-key violation reaching the author as a 500.

The persistence tests cover every one of these queries against real PostgreSQL.
That suite exists because a column name I assumed rather than checked reached
production once already; three more assumptions were caught here before it
could happen again.
2026-08-21 17:21:02 +09:00
DongHyeonka af91f7d768 fix: accept a Question working copy with no resolution
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.
2026-08-21 14:32:08 +09:00
DongHyeonka 37f474ade2 fix: read the public projection by the columns it actually has
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.
2026-08-21 14:17:46 +09:00
DongHyeonka 1befdc37a4 feat: let an author delete a working copy
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.
2026-08-21 13:30:37 +09:00
DongHyeonka 386f360122 feat: implement release authoring, so the changelog can be written
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.
2026-08-21 02:47:15 +09:00
DongHyeonka 48517b9e65 fix: cast the nullable uuid so Postgres can type the existence check
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.
2026-08-21 00:06:40 +09:00
DongHyeonka 0da7c7e2db fix: use the Jackson 3 mapper the persistence module actually has
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.
2026-08-20 23:54:18 +09:00
DongHyeonka bb6d2330bb feat: implement topic and project management, so documents can be authored
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.
2026-08-20 22:54:24 +09:00
DongHyeonka bde5826cfd fix: ship the object storage adapter, so Studio asset uploads have a backend
`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.
2026-08-20 19:10:57 +09:00
DongHyeonkaandClaude Opus 5 55a71fb33d merge: develop — Tech Log 백엔드 계약 2종 완성 (studio-v1 19/19, public-v1 18/18)
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>
2026-08-20 18:35:04 +09:00
DongHyeonkaandClaude Opus 5 0854d42c67 merge: feature/techlog-public-v1 — Tech Log 공개 조회 백엔드 (public-v1 18/18)
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>
2026-08-20 18:34:49 +09:00
DongHyeonkaandClaude Opus 5 365560efb6 feat: Tech Log 공개 조회 백엔드 — public-v1 18개 operation 구현
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>
2026-08-20 18:34:18 +09:00
DongHyeonkaandClaude Opus 5 e3254def57 test: 미추적으로 남아 있던 Studio authz 배선 테스트 2개를 추적에 넣는다
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>
2026-08-20 18:33:32 +09:00
DongHyeonkaandClaude Opus 5 743fee3907 fix: close the last three local checklist items
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>
2026-08-20 10:36:47 +09:00
DongHyeonkaandClaude Opus 5 c8a891c407 fix: honour the contract's nullable fields and its error codes
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>
2026-08-20 01:49:05 +09:00
DongHyeonkaandClaude Opus 5 a828b5d9fe feat: give redis-session mode a way to authenticate — the BFF login path
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>
2026-08-20 01:19:28 +09:00
DongHyeonkaandClaude Opus 5 37d5614129 fix: make Studio authorization actually work, and stop it failing as a 500
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>
2026-08-20 00:14:18 +09:00
DongHyeonkaandClaude Opus 5 d5889d644a merge: feature/techlog-studio-slices-2-5 — Tech Log Studio 백엔드 17개 operation (슬라이스 2~5)
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>
2026-08-19 23:22:07 +09:00
DongHyeonkaandClaude Opus 5 48ff648112 feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)
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>
2026-08-19 23:21:43 +09:00
DongHyeonkaandClaude Opus 5 e615c24152 fix: serve Studio at the contract path instead of /api/api/v1/...
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>
2026-08-19 18:26:34 +09:00
DongHyeonka ab0447a0f9 fix: 설계 문서 제거 2026-08-19 15:56:29 +09:00
DongHyeonkaandClaude Opus 5 6eec8a0656 merge: feature/techlog-studio-backend — Tech Log Studio 백엔드 기반 (Plan 01)
계약 배선·오류 코드 23종·bounded context 경계 규칙 7종·V7 스키마 28테이블·
엔드포인트 2종(getStudioSession, listStudioCatalog)·드리프트 게이트 4종.

검증: 1951 tests / 0 failures / 4 skipped
  app-bootstrap 828 · adapter:inbound:web 356 · application-core 568 · persistence-jpa 199

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:15:07 +09:00
DongHyeonkaandClaude Opus 5 91e6d99654 feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종
설계 패키지의 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>
2026-08-19 15:14:52 +09:00
DongHyeonka 697fc740e6 chore: remove sample portfolio module 2026-08-13 21:47:15 +09:00
DongHyeonka e552e317d6 chore: initialize tech log backend 2026-08-13 20:32:52 +09:00
DongHyeonka e64e701fe5 chore: initialize from backend template 0a6dd0e 2026-08-13 20:31:02 +09:00