Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 2420fcee9f fix: 공개 문서가 실제로 그리는 주제 링크도 탐색 필터로 돌린다
주제 링크를 고쳤는데 화면은 그대로 `/topics/:slug` 로 갔다. 공개 문서의 머리말을 그리는
것은 `PublicDocumentHeader` 의 breadcrumb 이 아니라 렌더 모델의 `topic.publicPath` 이고,
같은 파일 안에서 두 자리가 같은 경로를 만들고 있었다. 한 자리만 고쳤으니 배포하고 눌러
보기 전까지는 고친 것처럼 보였다.

테스트도 이 링크를 묻지 않고 있었다. 머리말 검사가 `Case/JPA/Backend Skeleton` 이라는
글자만 확인해서, 그 글자가 어디로 가는지는 아무도 보지 않았다. 되돌려 보면 공개 문서
여섯 개가 모두 빨개진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:55:19 +09:00
DongHyeonkaandClaude Opus 5 2632850322 fix: 최근 기록에 Open Question 을 싣고, 주제 링크를 탐색 필터로 돌린다
게시한 질문이 홈 최근 기록에 나오지 않았다. 계약의 `LatestEntry.entryType` 이 네 값만
허용해 백엔드가 담을 수 없었다 — design-package ef49d3a 에서 `QUESTION` 을 더했고 여기서
계약을 다시 생성한다. 포트의 `LatestRecordEntry.entryType` 도 같이 넓히고, 홈의 표기는
목록의 다른 이름들과 같은 규칙(대문자에 공백)을 따라 `OPEN QUESTION` 으로 적는다.

문서 머리말의 주제 링크는 `/topics/:slug` 로 가고 있었는데, 그 화면은 `jpa`/`authentication`
/`redis` 세 개를 하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 됐다. 게시한 모든 문서의
주제 링크가 거기로 갔다.

주제 페이지를 채우는 대신 링크를 탐색 필터로 돌린다. 지금 그 페이지만 줄 수 있는 것 —
설명, 범위, 선별한 대표 기록 — 이 전부 비어 있고, Studio 의 주제 만들기는 `{name, slug}` 만
보내므로 설명을 쓸 칸조차 없다. 독자가 거기서 기대하는 것은 같은 주제의 기록 목록이고,
그것은 `/explore?topic=` 이 그대로 준다. 주제가 여러 개가 되고 설명을 쓸 수 있게 되면 그때
페이지를 만드는 것이 순서다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:44:20 +09:00
DongHyeonkaandClaude Opus 5 ad8f322b43 fix: 질문 머리말이 관계에 담긴 프로젝트를 읽는다
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트 칸이 늘
비어 있었다.

그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 옮겨 준다 — slug 는 경로의 마지막 마디다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:56:21 +09:00
DongHyeonkaandClaude Opus 5 ab4d822956 fix: 게시된 Open Question 의 공개 상세가 열리게 한다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `points`
를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 `QuestionPointGroup` 은
`facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는 객체다. 객체에는 `.filter` 가
없으니 매핑이 통째로 터졌다.

목록은 이 칸들을 빈 배열로 두고 만들기 때문에 탐색에서는 멀쩡히 보였다. 그래서 "게시했는데
public 에 안 뜬다" 로만 드러났고 어느 층이 깨졌는지는 보이지 않았다. `as` 캐스트가 그
어긋남을 타입 검사에서 가렸다 — 이제 계약의 타입을 그대로 써서 모양이 바뀌면 컴파일이
먼저 막는다.

관계도 같은 종류로 어긋나 있었다. 계약이 주는 이름은 `resultCase`/`producedDecision`/
`derivedReferences` 인데 매퍼는 `derivedCases`/`projectDecisions`/`relatedQuestions` 를
찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는
관계가 아니라 이 질문이 속한 프로젝트이므로 관계 목록에서 뺀다 — 머리말이 이미 보여 준다.

이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
때문이다. 화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
않는다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣는다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:50:14 +09:00
DongHyeonkaandClaude Opus 5 344a163d84 fix: 탐색 주제 필터가 slug 를 보내고, 편집기를 넓혀 두 칸이 함께 스크롤한다
주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가
`<option>{이름}</option>` 이라 값이 없어 이름이 그대로 나갔고 — `topic=OAuth/OIDC 인증
경계` — API 는 slug 로 거르므로 0건을 돌려줬다. 프로젝트 선택지는 처음부터
`value={slug}` 였고, 그래서 프로젝트만 멀쩡했다. 주제도 같은 모양으로 맞춘다.

테스트가 이 결함을 통과시킨 이유는 픽스처의 주제 이름이 `JPA`, `Authentication` 처럼
slug 와 구분되지 않는 값이어서다. 이름과 slug 가 다른 값을 쓰는 운영에서만 드러났다.
선택지의 값이 slug 인지 직접 묻는 단언을 넣는다.

`RecordFilters.topic` 은 어댑터마다 뜻이 달랐다. 정적 어댑터는 이름으로, HTTP 어댑터는
그 값을 그대로 API 에 넘겨 slug 로 걸렀다. 프로젝트가 이미 slug/제목 둘 다 받는 것과
같이 주제도 둘 다 받게 해서 두 어댑터가 같은 값을 이해하게 한다. 주제 페이지도 이름
대신 경로의 slug 로 묻는다.

「전체」를 고른 칸은 조건이 아니다. 빈 값까지 실어 보내고 있었고, URL 이 지저분해질 뿐
아니라 이 값을 그대로 API 에 넘기는 화면에서는 `topic=` 이 "slug 가 빈 문자열인 주제"로
해석되어 0건이 된다.

편집기는 미리보기를 붙박이로 두고 자체 스크롤을 줬다. 편집기를 내려도 미리보기는
제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가 둘을 같이 보는 것인데
움직임이 갈라지면 그 이점이 없다. 둘 다 페이지 스크롤을 그대로 타게 한다.

폭도 넓힌다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께 쓰는데, 본문 두 벌이
들어가야 하는 이 화면에서는 한 칸이 566px 였다. 편집기가 놓인 경우에만 1600px 로
넓히고 헤더도 같이 넓혀 좌우 끝을 맞춘다. 다른 화면은 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 17:30:11 +09:00
DongHyeonkaandClaude Opus 5 60c8c82097 fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서
치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로
없어진다.

작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께
쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다.
화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다.

탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을
가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를
대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이
묶음이 무엇인지가 바뀐 것은 아니다.

미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가
좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지
세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서
아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 15:49:18 +09:00
DongHyeonkaandClaude Opus 5 b3119952d5 fix: 제목 아래에 문서의 요약을 그린다
Reference 머리말에 「이 기준을 쓰는 이유」와 같은 글이 나오고 있었다. Case 도 마찬가지로
바로 아래 「문제」와 같은 글을 두 번 말했다.

문서가 스스로 밝히는 한 줄 요약이 공개 응답에 없어서, 화면이 유형별 요약
(`problemSummary` / `scopeSummary`)을 대신 쓰고 있었다. Question 에는 이미 `summary` 가
있었으므로 셋이 같은 자리를 갖게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 21:01:52 +09:00
DongHyeonkaandClaude Opus 5 7211dd1a92 fix: 공개 Reference 가 계약이 주는 이름을 읽게 한다
Reference 를 공개했는데 Studio 에서는 다 보이고 공개 화면만 비어 있었다.

게이트웨이가 읽던 이름이 계약에 없는 것들이었다 — `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 계약이 주는 이름은
`scopeSummary`, `appliesTo`, `excludedScope` 다. 전부 undefined 로 떨어졌고, `as string`
단언 때문에 타입 검사는 아무 말도 하지 않았다.

규칙은 `content` 마크다운을 잘라 만들고 있었다. Reference 의 본문은 마크다운 한 덩어리가
아니라 제목이 붙은 규칙의 목록이고, Studio 의 편집기가 그렇게 받아 `body_markdown` 은
비워 둔다 — 자를 것이 없으니 언제나 빈 목록이었다. 계약이 구조로 주는 것을 그대로 쓴다.

값이 아니라 이름을 지키는 테스트를 둔다. 계약에서 그 칸이 사라지면 `satisfies` 가 먼저
깨진다 — 이번 결함은 값을 검사해서는 잡히지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 19:08:37 +09:00
DongHyeonkaandClaude Opus 5 3036b8d788 feat: Ctrl+S 로 저장한다
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장
대화상자가 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다.

문서·프로젝트·릴리즈 세 편집기에서 기본 동작을 막고 그 화면의 저장으로 돌린다.

저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.

Ctrl+Shift+S 와 Alt 조합은 가로채지 않는다 — 다른 뜻으로 쓰는 곳이 있다.

버튼에 단축키를 적어 둔다. 단축키는 알려 주지 않으면 없는 것과 같다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 17:05:14 +09:00
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00
DongHyeonkaandClaude Opus 5 e5770dfbc8 fix: Decision 미리보기 오류가 할 일을 말하게 한다
"PROJECT public path is required" 는 사실이지만 작성자가 무엇을 해야 하는지 말하지
않는다. Decision 의 공개 주소는 프로젝트 주소 아래에 있으므로, 프로젝트를 먼저
게시해야 한다는 것과 그 화면이 어디인지 적는다.

프로젝트를 고르지 않았을 때의 문구도 같이 바꾼다.

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

공개 화면에서는 링크를 없앤다. 각 줄에 그 기록으로 가는 링크가 있으면 같은 글에 닿는
길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
활동은 날짜와 무엇을 올렸는지만 말하고, 글을 읽는 자리는 기록 화면 하나로 둔다.

Studio 의 활동 칸도 읽기 전용 로그가 된다 — 만들기·고치기·지우기 폼을 걷어낸다.

맥락에서 경계와 시스템 개요 칸을 뺀다. 저장은 되지만 공개 화면 어디에도 나오지 않는다
— 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 어딘가 실릴 것처럼 보이는
칸이었고 실제로는 아무도 읽지 않았다. 열의 데이터는 남겨 두고 서버 값을 그대로
왕복시킨다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:33:43 +09:00
DongHyeonkaandClaude Opus 5 ab67eb912c fix: 릴리즈 편집 하단 버튼이 두 벌 나오던 것을 하나로 되돌린다
저장·공개·공개에서 내리기가 각각 두 개씩 나왔다.

편집 폼을 목록에서 떼어 낼 때 잘라 온 조각에 이미 그 푸터가 들어 있었는데, 새 화면을
조립하면서 같은 푸터를 한 번 더 붙였다. 둘 다 같은 핸들러를 부르므로 어느 쪽을 눌러도
동작은 했고, 그래서 화면을 열어 개수를 세기 전까지는 드러나지 않았다.

자식이 하나뿐이라 감쌀 이유가 없어진 프래그먼트도 함께 걷어낸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 02:32:12 +09:00
DongHyeonkaandClaude Opus 5 e9b866184a fix: 릴리즈 목록에서 편집 흔적을 걷어내고, 타입 검사가 실제로 돌게 한다
앞 커밋에서 편집 폼만 떼어 내고 그것을 움직이던 상태와 핸들러를 목록에 남겨 두었다.
`GuardedStudioLink` import 는 빠졌고 `navigate` 는 아예 정의된 적이 없다. 운영에서
릴리즈 목록이 ReferenceError 로 비었다.

`npx tsc --noEmit` 이 통과했기 때문에 이것을 못 봤다. 루트 tsconfig 는 `"files": []` 에
project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다 — 실제 검사는
`npm run check:types` 가 여섯 개 프로젝트를 돌며 한다. 그 명령으로 돌리자 이 저장소에
남아 있던 다른 오류도 함께 드러났다:

- `CatalogEntry` 는 `contracts/studio/contract.ts` 가 export 하지 않는다 (project-editor,
  home-focus-editor). 문서 편집기처럼 generated 에서 지역 타입으로 뽑는다.
- 라우트 파라미터는 `unknown` 으로 들어온다. `params.id` 를 그대로 문자열 자리에 넘기고
  있었다.
- `message("route.auth.returnTo", …)` 는 파라미터를 받는 키로 등록되지 않았다.
- `ReleaseIndexItem` 에는 `summary` 가 없다 — 목록 행은 마크다운을 싣지 않는다. 대신
  공개 경로를 적는다.

목록의 남은 로직도 정리한다. 새 릴리즈를 만들면 그 행을 `aria-current` 로 짚어 준다 —
목록이 길면 어느 것이 새것인지 알기 어렵다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:47:41 +09:00
DongHyeonkaandClaude Opus 5 84d72c4f60 feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:41:04 +09:00
DongHyeonkaandClaude Opus 5 6b9dc3f0a2 fix: 릴리즈 하단 버튼을 한 줄에 세우고, 로그인을 화면으로 만든다
릴리즈 편집 맨 아래에서 저장과 공개가 서로 다른 높이에 서 있었다.
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는데
하단 푸터에서 primary 만 그 여백을 풀어 주고 있었다 — 그래서 저장은 위에, 공개와
공개에서 내리기는 24px 아래에 앉았다.

로그인은 화면이 없었다. `ui-page` 위에 제목 한 줄과 버튼 하나가 전부라, 보호된 주소를
열었을 때 페이지처럼 보이지 않았다. 카드 하나로 세우고 eyebrow·제목·설명·행동을 갖춘
뒤, 로그인하면 어디로 돌아오는지까지 적는다 — 그것이 이 화면이 답해야 할 질문이다.

오류 화면과 다른 모양이어야 한다. 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 00:56:16 +09:00
DongHyeonkaandClaude Opus 5 f9d20e8946 fix: setState 업데이터 안에서 event.currentTarget 을 읽지 않는다
프로젝트 편집 화면에서 활동 유형이나 제목을 한 글자 치면 화면이 통째로 죽었다 —
"Cannot read properties of null (reading 'value')".

setState 업데이터는 핸들러가 끝난 뒤 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로, 업데이터 안에서 그것을 읽으면
반드시 터진다. 타입 검사도 lint 도 이것을 잡지 못했고, 첫 입력에서야 드러났다.

값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.

같은 실수를 다시 못 하도록 lint 규칙을 세운다: `setXxx(...)` 에 곧바로 넘기는 화살표
함수 안에서는 `currentTarget` 을 읽을 수 없다. 처음 쓴 선택자는 동기 핸들러까지 잡아
(map 콜백, 즉시 호출되는 update 등 아홉 자리) 너무 넓었으므로, 실제로 위험한 setter
업데이터만 겨냥하도록 좁혔다. 결함을 되돌려 규칙이 잡는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:50:47 +09:00
DongHyeonkaandClaude Opus 5 014f21b9e1 feat: 프로젝트 주제와 활동 연결을 열고, Case 목차를 되살리고, Studio 화면을 기존 디테일에 맞춘다
주요 주제 — 프로젝트 화면은 처음부터 `project.topics` 를 읽고 있었는데 공개 계약에
그 자리가 없었다. `project_topic` 은 테이블도 있고 조인할 수도 있었지만 응답에 실을
곳이 없었고, 고를 화면도 없었다. 계약에 자리를 만들고, 편집 화면에 주제 선택을 붙이고,
공개 조회가 ACTIVE 인 주제만 고른 순서대로 내보내게 한다.

활동 위치 열기 — 그 링크는 활동의 관련 기록을 따라간다. 백엔드는 처음부터 그 경로를
유도하고 있었지만, 활동을 만들 때 기록을 고를 방법이 없어 모든 줄이 관련 기록 없이
저장됐다. 활동 추가 폼에 유형·일시·제목·요약·연결할 기록·노출을 모두 둔다.

Case 목차 — 오른쪽 목차가 어떤 문서에서도 나오지 않았다. Case 렌더러가 둘이었고
어느 쪽을 쓸지 `publicPath === "/cases/collection-fetch-join-pagination"` 이라는 슬러그
비교가 정했다. 설계 픽스처 문서 하나만 breadcrumb·목차가 있는 완성된 배치를 받고, 실제로
작성한 Case 는 전부 축약본으로 떨어졌다. 배치를 하나로 합치고, 목차는 본문에 제목이
있을 때만 그린다.

Studio UI — 새로 만든 화면들이 기존 디테일을 따라가지 않고 있었다. `<label htmlFor>` +
별도 컨트롤 대신 편집기가 쓰는 `<label><span>` 묶음을, 임시로 만든 폼 클래스 대신
`studio-editor-section`·`studio-field-grid` 를 쓴다. 릴리즈 목록 행에는 유형 라벨이
빠져 격자 첫 열이 비어 있었고(제목이 당겨져 다른 목록과 줄이 맞지 않았다) 버튼 둘이
묶이지 않은 채였다. 프로젝트 행에는 단계·공개 범위를 한국어로 적고 "내용 편집" 을
다음 행동으로 둔다.

로그인 화면 — "인증 연동 지점을 확인하기 위한 보호 라우트" 는 이 뼈대를 만들던 사람에게
하는 말이었다. 이 화면을 보는 사람은 글을 쓰러 온 작성자다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:22:58 +09:00
DongHyeonkaandClaude Opus 5 16e5b9f4ec feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다
홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다.
프로젝트 페이지의 "활동" 도 늘 비어 있었다.

프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무
값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의
`updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다.

`/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 —
프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와
같은 자리에서 보는 편이 낫다.

이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로,
저장만 하면 공개 화면에는 예전 값이 남는다.

단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을
보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다.

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동
접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트
기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96…
을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:45:00 +09:00
DongHyeonkaandClaude Opus 5 0eb3c86839 fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다
관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its
contract." 한 줄만 남았다. 저장은 통째로 실패했다.

새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를
붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이
그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로
거절했다.

목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도
이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다.

Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의
consequences 는 모두 같은 이유로 저장되지 않았을 것이다.

`createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로
(`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할
것은 형식뿐이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:03:27 +09:00
DongHyeonkaandClaude Opus 5 c87e0a2338 fix: home focus 경로를 계약과 맞춘다
계약이 `/api/v1/studio/home-focus` 에서 `/api/v1/studio/home/focus` 로 옮겼다 —
케밥 세그먼트가 백엔드의 AIP-122 규칙을 위반했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:20:32 +09:00
DongHyeonkaandClaude Opus 5 b3aa304975 feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다
공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울
방법이 없었다"는 같은 모양이었다.

홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만
넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다.
세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도
나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다.

홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다.
그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로
빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고
있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다.

프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지
못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을
세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던
`publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다.

문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가
`List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은
작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈
수 있어야 하므로 공개된 것만 포함한다.

본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트
줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽
끝에 세우고 읽는 단을 56rem 으로 넓힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 17:38:49 +09:00
DongHyeonka c03b0c77b8 fix: 오류 수정 2026-08-22 14:40:49 +09:00
DongHyeonka 1801414592 fix: show the reason the server gave for a failed action
Every failure in the management screens printed a guess. Deleting a working
copy said "it may be published, or something may reference it, or someone may
have edited it first" — three maybes, while the server had answered with
exactly one: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다". A version
conflict read as "in use" because the same sentence covered both, and an author
watching some deletions succeed and others fail had no way to tell them apart.

The gateway now carries the server's client-safe message and the screens show
it. The canned sentences remain only as a fallback for a failure that never
reached the server.

The topic status label said "사용 중" for every active topic, including one
created seconds earlier that nothing references. Next to a refusal about
records that use a topic, the two read as the same statement. It says "활성"
now, which is what the status means.

Publishing also stops demanding a finished document — the mock validator moves
with the real one, so what an author sees against fixtures matches production.
2026-08-21 19:29:04 +09:00
DongHyeonka 7345500ef3 feat: show the way to publish, and what is blocking it, in the editor
Publishing was reachable only by walking the whole chain blind. The editor
offered one link — "저장본 검증" — and the word "게시" appeared nowhere until
three screens later, so an author with a finished draft could not tell how to
publish it. The working-copy list already named the next step, but the name was
plain text with nowhere to go.

The editor now shows the whole path: 검증 → 미리보기 → 게시, each a link. The
list's next step is a link to that step. Neither weakens the gates — an
unvalidated document is still refused at preview, an unpreviewed one at
publish. What changes is that the order stops being a secret.

What is blocking publication now appears where it gets fixed. The validation
report lived on its own screen, so an author read the list, navigated back, and
had to remember which field each item meant. The editor shows the same issues
above the fields, in red, and says plainly when they describe an older saved
version rather than the current one.

The clock is read during render, not captured as an effect dependency. It is a
new function on every render of the provider, so depending on it refetched the
document endlessly — the editor never settled, and a tab click did not even
register. A value you are asking about now does not belong in a dependency
array.
2026-08-21 18:43:10 +09:00
DongHyeonka fb478f951b fix: say which version a stale validation judged, before showing its errors
The validation screen presented a report from an earlier version with the same
weight as a current one — same status badge, same full error list — and marked
the difference with two small words. An author read a version 2 report on a
version 5 document and understood those errors as the document's present state.
Every error in that list had already been fixed.

The report now leads with what it is: which version it judged, that the
document has changed since, and where to re-run it. The badge stops claiming a
verdict and the issue list recedes, because a judgement about an older version
is not a verdict about this one.
2026-08-21 18:10:51 +09:00
DongHyeonka 7289ce97bb fix: stop the smoke sweep reporting an expected 404 as a failure
A document with no preview yet answers 404 when the screen asks for its current
one, and the screen turns that into "make a preview". The sweep counted it as a
failure, so every run ended with the same red line under a healthy deployment.

A check that cries wolf on every run stops being read, and a real failure would
have sat unnoticed beside it. The session probe's 401 before sign-in is the
same kind of expected answer and is excluded on the same terms; every other
4xx and 5xx still fails the sweep.
2026-08-21 18:00:14 +09:00
DongHyeonka 348420618d fix: stop claiming a 1x1 size for an image whose dimensions are unknown
An uploaded figure never appeared, and no request for it was ever made. The
resolver substituted 1x1 when an asset carried no dimensions, and a 1x1 box
with `loading="lazy"` never enters the viewport — so the browser had no reason
to fetch it. The image was not failing to load; it was never asked for.

Unknown dimensions now say so. The figure omits the attributes and loads
eagerly, letting the browser size the image from the file, and reserves layout
space only when the size is actually known. Guessing a number to fill an
attribute is what turned a missing measurement into a missing picture.
2026-08-21 17:57:10 +09:00
DongHyeonka 89a73c13c6 feat: delete a decision, manage assets while writing, and sweep before deploying
Four things an author could not do, and the check that should have caught them.

A Decision could not be deleted. Case, Reference and Question all could, so an
author who opened a decision draft had no way to close it. The contract gained
the operation and the list now offers it for every kind. Its path carries the
project because a decision belongs to one; a row with no project says so rather
than failing.

Assets could only be managed by leaving the document. The picker now deletes
one in place — the server still refuses an asset a document uses — so a
mistaken upload does not cost the author their editing session.

Zoom was decided for the author and could not be changed: only a DIAGRAM got
it, so a screenshot uploaded as an image or attachment went in with zoom off
and no way to turn it on. It now defaults on for images and the picker offers
the choice. The toggle is a picker control, not a document field, and carries
its own class — wearing the field class put it in the editor's field list.

`scripts/smoke/production-sweep.ts` walks every public and Studio screen and
the document flow, reporting console errors, failed API calls and error text.
It exists because verifying only the screen I had just changed is what let
broken screens reach production repeatedly; this runs before a deploy, not
after a report.
2026-08-21 17:20:42 +09:00
DongHyeonka 21f8425f1e fix: keep line breaks in the fields that are not Markdown
The earlier fix covered the Markdown body and stopped there, so the preview
still ran lines together — which is exactly what it looked like from the
outside: nothing had changed.

Summary, problem, conclusion, environment and the rest are plain text. They
never pass through the Markdown parser, so their newlines sit in a text node
and HTML collapses them, and the renderer that now emits <br> for the body was
never asked about them.

They render through the same rule now. One helper, one behaviour: a line the
author broke stays broken, wherever they typed it.
2026-08-21 15:54:28 +09:00
DongHyeonka 7093d84ab5 fix: give a document a slug, and say so when saving fails
Validation reported a slug the author could see on screen as missing. Both
halves of that were the editor's fault.

The document slug must match `^[a-z0-9]+(?:-[a-z0-9]+)*$`, which the editor
never said and never helped with. A Korean slug was rejected by the server with
a 422 carrying no details — and the editor answered that only through
`setRequestAnnouncement`, which is an aria-live region and shows a sighted
author nothing at all. The value stayed in the field, so it looked saved. Then
validation, which reads the saved version by design, correctly reported no
slug, and the author read that as the tool contradicting itself.

An empty slug is now derived from the title, romanizing Hangul the same way
topic slugs do, so a Korean title produces a valid slug and the author never
has to learn the rule. A slug that cannot work is refused before the request,
naming the rule instead of letting the server answer with an unexplained 422.
Every save failure now renders where the author is looking, not only where a
screen reader would hear it.

The rail shows one message rather than two: a conflict already says what to do,
so it outranks the server's wording, and everything else shows the server's
reason.
2026-08-21 15:35:23 +09:00
DongHyeonka d2c289c650 fix: keep the line breaks an author typed
Text written across several lines rendered as one run-on line. Markdown reads a
single newline as a space that joins a paragraph, the parser leaves that
newline inside the text node, and HTML then collapses it — so the break the
author pressed Enter for disappeared at the last step.

This was never a preview artifact: the Studio preview and the public page go
through the same renderer, so a published record ran its lines together too.

Newlines inside a paragraph now render as <br>. Paragraphs separated by a blank
line are already two paragraphs by the time they reach here, so this only
affects the breaks an author put inside one.
2026-08-21 15:10:27 +09:00
DongHyeonka 5cffe30200 fix: derive a topic slug that survives a Korean name
Creating a topic failed intermittently — "a topic with that slug already
exists" — and worked when the author retried with a different name. The rule
was never intermittent, only invisible: the slug kept `[a-z0-9]` and dropped
everything else, so a Korean name contributed nothing. Whatever Latin word or
number happened to be in it became the entire slug.

Two ways that goes wrong, and the author hit both. `인증` reduced to an empty
string, which the form refused before a request was ever sent. `Redis 캐시` and
`Redis 클러스터` both reduced to `redis`, so the second one collided with the
first — a real conflict, reported honestly, about a slug the author never chose
and could not see.

Hangul is now romanized rather than discarded. Syllables decompose
arithmetically into initial, medial and final jamo, so this needs no table and
is deterministic: `백엔드 아키텍처` becomes `baekendeu-akitekcheo`. Only the
jamo mapping from Revised Romanization is applied — the sound-change rules are
deliberately left out, because a slug is read, not pronounced, and those rules
would make one name produce different slugs in different contexts.

The output keeps the shape document slugs already use
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`), so the repository has one slug rule rather than
two, and the tests assert exactly that.
2026-08-21 14:56:03 +09:00
DongHyeonka 197b2c7e72 chore: pick up the regenerated Question input contract
`resolution` leaves the input's required list, so the generated type makes it
optional. The editor already sent null for an unresolved question; nothing in
the frontend changes behaviour.
2026-08-21 14:32:14 +09:00
DongHyeonka c5e8735041 feat: read the profile's topics from Studio, and add working-copy deletion
Two things an author could not control from Studio.

The profile's "주요 관심 주제" was four strings in the JSX. Creating or removing
a topic in Studio changed nothing, and correcting the list meant a rebuild and
a redeploy. It now renders the published topic list. The old literal opened
with "Backend Architecture", which no record in the catalogue actually carries
— the profile was advertising a topic that did not exist, and nothing could
have caught that while the list lived in the markup.

The working-copy list gained a delete control. It routes by kind because the
contract and the storage both do: Case and Reference share one table split by
type, Question is its own. Decision has no delete — its lifecycle is accept,
reject, supersede, which records what happened rather than erasing it — so the
control does not appear for it.

The list summary carries no version, so deletion reads the working copy first
and uses the version it finds. A stale version from a list left open should
fail as a conflict, not delete whatever is there now.
2026-08-21 13:30:37 +09:00
DongHyeonka ab8c6c14db fix: derive the Studio serving patterns from the route contract
`/studio/releases` answered a plain-text 404 from nginx. The route existed, the
chunk was built, and the SPA could reach the screen by client-side navigation —
but a hard load or a reload never got that far, because the web server had
never been told the path exists.

The public half of the serving contract derives its patterns from the route
registry. The Studio half was a hand-maintained array, and it failed the way
hand-maintained arrays fail: the comment above `^/studio/assets$` records that
exact bug being fixed once already, and adding a route repeated it immediately.
Both halves now come from the same source, so a Studio route that exists is
served without anyone having to remember.

Deriving them yields one pattern per route rather than the old alternation that
folded the four document sub-screens together. Same matched set, and it no
longer needs a human to keep the grouping honest.
2026-08-21 03:29:47 +09:00
DongHyeonka 3754269118 feat: add the Studio release editor and point the footer at the changelog
The public site shipped a Releases page and a footer link to a release, and
neither could ever have content: the read path existed, the write path did not.
This adds the seven release operations to the contract contribution and the
gateway, and a Studio screen that can actually write one.

The editor is six markdown fields rather than one, because that is what the
contract models and what a release note is — why, what, what a reader notices,
what it leaves in the code, how it was verified, what is still open. Publishing
is separate from saving: a draft saves in any state, but the public query keys
on PUBLISHED alone, so publish is where completeness is demanded.

No new CSS. The screen reuses the document editor's field classes and the
working-copy list's row classes, so it inherits Studio's spacing and type
instead of introducing a second look.

The footer previously linked `/releases/0.1.0` — a version that did not exist,
so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now
points at the changelog index, which is the only place that knows what the
latest release is and which reads correctly when there are none.

Route inventory, navigation order, message catalog, manual accessibility
evidence, artifact baseline, and the pinned gate-shape digest all move with the
new route. The digest was recomputed by first reproducing the previous constant
from the previous gates.json, so the computation is known to be the one it was
pinned under.
2026-08-21 03:08:41 +09:00
DongHyeonka 760071156d fix: give each API surface its own error-code enum
The public site answered every screen with the terminal error surface. Three
defects stacked, and each one hid the next.

The first refused the request outright: `attachCredentials` asks the Studio
helper, which returns null for a profile it does not own, and the fallback
below read the session and rejected anything not authenticated. Public reads
declare the ANONYMOUS profile, so a signed-out visitor — the public site's
entire audience — never got a request out of the browser. An anonymous profile
carries no credentials by definition and must never consult the session.

With requests flowing, the second surfaced: `envelopeError()` pinned
`ApiError.code` to the Studio enum and all three surfaces shared it. Public and
Management each declare their own enum in their own contract, so every error
they returned failed validation and arrived as a CONTRACT_VIOLATION — an
unclassifiable transport fault — rather than the domain error it was. A strict
enum checked against the wrong surface's contract still looks strict, which is
why no gate caught it. Each surface now passes its own contract's codes.

The third was the not-found path: it read `status` and `code` off the problem
body, but the envelope has no `status` and names the code for its surface
(PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport
is the authoritative signal and the only one that holds across both shapes.

The regression test composes the real runtime adapters against the deployed
backend's actual 404 body. Neither the gateway tests (which stub the executor)
nor the screen tests (which stub the gateway) cover this seam, and the whole
outage lived in it.

Two page-level fixes came out of the same investigation: the profile page asked
for two project slugs that only ever existed in the static fixture, and the
index pages held their fixed header copy behind a request that had nothing to
do with it. Headers now paint immediately; only the sections that are actually
waiting show a fallback, and an empty list says so instead of rendering blank.
2026-08-21 01:14:20 +09:00
DongHyeonka 03986da3d6 fix: let a signed-out visitor read the public site
Every public screen rendered its terminal error surface, and the network log
explained why: no request to /api/v1/public ever left the browser.

The credential collaborator asks the Studio helper first, which returns null
for any profile it does not own — "not mine, use your own logic". Below that,
the fallback reads the session and refuses anything that is not authenticated.
The public operations declare the ANONYMOUS profile, so they fell into that
fallback, and a signed-out visitor is exactly who the public site is for.

An anonymous profile carries no credentials by definition — the registry
refuses to install one that even allows a credential header — so it must never
consult the session. It now short-circuits with an empty credential patch,
keyed on the profile's transport rather than a profile id, so any anonymous
operation is covered rather than one named surface.

This could only appear once the public source became HTTP; until this week
that path had never run in a browser. The suites did not catch it because they
exercise the gateway and the screens, not the composition root's credential
decision — that seam has no test, and this is what it costs.
2026-08-21 00:43:54 +09:00
DongHyeonka 31dca00857 fix: keep the public screens usable on an empty site, and centre the dialogs
The published site showed one line of text — "요청한 문구를 표시할 수 없습니다."
— instead of any UI. Two independent faults produced it.

The screens ask for the whole catalogue by calling searchPublicContent(""). The
fixture answered that with everything it had, and four screens lean on it: the
home timeline, the project index, the release index, and the explore filter.
The contract has no such meaning — `q` is required, and an empty one answers
400 — so all four turned into error surfaces the moment the source became HTTP.
The adapter now assembles that catalogue from the list endpoints the contract
does provide, and only sends a real query to the search endpoint. Filtering
client-side instead would have been the other option, and it would silently
lose every result past the first page.

The message that surfaced was missing too. Twenty-two of the thirty-eight
failure kinds had no copy, so `errorMessage` fell through to
`common.unavailable` — which says nothing about what failed or what to do.
That is a systemic gap, not one absent key, so all twenty-two are written, in
both catalogues. They are phrased for the reader: what did not happen and what
to try, not the internal classification.

The Studio dialogs opened against the top-left corner. A modal dialog centres
through the UA's `margin: auto`, which is not surviving in this build; the
public `.search-dialog` already states `position/inset/margin` explicitly for
the same reason. The three that did not — unsaved-changes, asset upload, and
the publication flow — now follow it, with a max-height so a long dialog
scrolls rather than running off the screen.
2026-08-21 00:25:51 +09:00
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00
DongHyeonka 4b62bf3b1f chore: re-vendor both contracts from the merged design package
Only the recorded source revision moves (55a9599 -> b98eaf9). The vendored
specs and the generated types are byte-identical, which is the useful part:
the envelope redefinition landed on a merge commit, not a schema change, so
nothing downstream has to move with it.
2026-08-20 18:42:04 +09:00
DongHyeonka 6784eb1ce6 fix: serve the public routes the router declares, not the slugs the build saw
The serving contract enumerated every public path the bundled fixture happened
to contain, and the generated nginx published exactly those as `location =`
blocks. A record published after the build — the entire point of having a
backend — answered 404 at the edge before the SPA was ever asked, and no amount
of correct routing inside the bundle could recover it. Twenty-seven frozen
paths, and any twenty-eighth was unreachable.

The route contract already declares which paths exist; the catalog only decides
which of them currently resolve, and that is the SPA's call rather than the web
server's. So the contract now emits one regex per registered Public route,
derived from the router, the way the Studio half has always worked.

A parameter matches one segment and never a slash, so /cases/a/b stays a 404
instead of quietly rendering a case page. The catch-all route is dropped rather
than translated: serving index.html for every unmatched URL would turn an edge
404 into a soft 200 and hide broken links from crawlers and from us.

Verified against a built image: /cases/a-brand-new-slug now answers 200 while
/nope and /cases/a/b still answer 404.

schemaVersion goes to 2 because the field changed shape, not just contents —
a consumer reading publicSpaPaths would otherwise see an absent key rather than
a version it can refuse.
2026-08-20 17:48:39 +09:00
DongHyeonka 24c01aedf2 feat: give the public surface an HTTP adapter, and a switch to reach it
The public read port had one implementation and no way to add another. This is
the second one: the 18 operations of the public contract, mapped to the nine
methods the screens call.

The contract and the screens disagree about shape, and translating here is what
keeps the presentation components untouched. The server speaks in what it
stores — timestamps, one markdown body, relations grouped by why they relate.
The screens were built against a catalog that spoke in what a page renders —
formatted labels, titled sections, one flat relation list whose group name is
the reason. Neither is wrong. Where the contract has no counterpart the value is
left empty and the gap is named where it happens rather than guessed at: a
Case's verification line, a decision's consequences, a question's options.

Sections are split from markdown here rather than through the Studio parser.
That parser produces the canonical render-block union the editor needs — inline
marks, evidence directives, tables — which is a richer tree than RecordSection
can hold, so reusing it would mean flattening away exactly the blocks that made
it worth using.

A 404 is unwrapped, not thrown. A slug that is not published is an answer the
port already has a shape for, and throwing would put a terminal-error surface on
a page whose real state is "this does not exist".

`listRecords` is one method over two endpoints, because the contract pages and
filters knowledge separately from questions. Only the unfiltered call fans out:
asking for one kind must not pay for the other.

The operations declare the ANONYMOUS auth profile, which forbids credentials
outright. That is the point — a later change that starts sending the session
cookie on a public read fails the profile check instead of quietly making a
cache-friendly surface user-specific.
2026-08-20 17:36:18 +09:00
DongHyeonka 4566f2d7a8 refactor: make the public read port async so a network adapter can implement it
`PublicContentQueries` returned arrays, not promises. That signature is only
implementable by something already in memory, so the port could hold exactly
one adapter — the bundled fixture — and no amount of configuration could put
the public site on the backend. Turning it async is the change that makes a
second adapter possible; the adapter itself follows.

The markup is untouched. Every page reads a value and hands it to a
presentational component, so the shape those components receive is mapped at
the adapter boundary and nothing below the page changes.

Screens load through one query, not one per read. Several pages read in a loop
— the home timeline walks every project for its activity, the explore filter
walks search results to resolve titles — and a hook per read would mean a
variable number of hooks per render, which React forbids. `usePublicContent`
takes the whole screen's reads as one loader, where a loop is a loop and
`Promise.all` is available; the loops that used to be N sequential lookups now
issue together.

Two places deliberately do not show the loading surface. The explore filter
sits inside a page that already renders one, so a second skeleton would move
the layout under it — it keeps its structure and fills its options in when they
arrive. The search dialog is a type-ahead: re-querying per keystroke would
replace the results with a skeleton on every key, so it loads the catalog once
and applies the same predicate locally.

`usePublicContent` requires an object because `undefined` is how the query
layer says "no result yet". A loader returning the record itself would make a
missing slug indistinguishable from a request in flight, and the page would sit
on a skeleton instead of rendering its not-found route.

Studio's `resolvePublishedLabel` stays synchronous. It is called from inside
the public renderer, so making it async would push awaits through the render
tree; the shell loads the catalog once and the callback remains a lookup.

The component tests now assemble the query providers the running app assembles.
Without them the render throws "No QueryClient set" — not a harness quirk, but
the same failure the app would produce if it were mounted without its query
layer.
2026-08-20 16:53:51 +09:00
DongHyeonka c362ec6100 feat: vendor the public read contract, and give it its own source switch
The public surface — 17 of the 28 registered routes — reads from a 29KB
TypeScript fixture and never touches the backend. `TECH_LOG_STUDIO_SOURCE`
only ever switched the Studio gateways; `publicContent` was wired to the
static adapter unconditionally, so no configuration could make the public
site show published content. This is the first half of closing that: the
contract and the switch, with the adapter still to come.

The generator now vendors both canonical contracts instead of one. They are
independent — different services on different schedules — so each carries
its own digest and operation list, and updating one leaves the other's drift
gate quiet.

`TECH_LOG_PUBLIC_SOURCE` is deliberately a second flag rather than a rename
of the Studio one. The combination that matters right now is exactly the one
a single flag cannot express: the authoring backend is live while the public
read API does not exist yet. production stays on MOCK for that reason —
pointing it at HTTP today would empty the live site — and moves when the
backend serves /api/v1/public.

Also records the compatibility evidence the registry gate wanted for the
Studio access change in fff5e6f. That gate has been failing since, which is
on me: the change was real and breaking, and it shipped without the note
explaining that route ids and schemas are untouched and only the access
classification moves.
2026-08-20 16:19:53 +09:00
DongHyeonka 83409bef7a feat: give the frontend a deployment artifact, and show its logo
The repository had no container image and no production-shaped serving
configuration. `dist/server.mjs` is a preview server that applies neither
the security headers nor the cache policy `config/hosting/` declares, so a
deployment had nothing correct to run.

`scripts/generate-nginx-config.ts` derives the server block from
`dist/tech-log-serving-contract.json` plus the two hosting policy files, so
the served headers and cache lifetimes cannot drift from what the contract
declares. It emits no TLS and no proxy blocks: the edge terminates TLS and
routes /api, and baking a backend address into the image would tie the
bundle to one deployment. Static surfaces use `alias` because a base-path
build serves /dev/assets/... out of dist/assets/..., which `root` plus URI
would look for one directory too deep.

The image copies that config next to the bundle and normalises permissions:
the build writes config.json 0600, which nginx cannot read, so the container
came up healthy and answered 403 for the one file the SPA needs to boot.

index.html never referenced public/favicon.svg. The file shipped and nginx
served it, but browsers asked for /favicon.ico, got a 404, and fell back to
the default icon. `%BASE_URL%` rather than an absolute path so a prefixed
deployment points at its own copy.

development.json moves to the HTTP Studio source; the mock source has no
backend to authenticate against, which is the whole point of that profile.
2026-08-20 16:14:09 +09:00
DongHyeonkaandClaude Opus 5 5e2b1a5586 fix: remove the footer layout shift, and hide the Studio chrome from signed-out visitors
CLS was 0.192 on every public route, and one element accounted for all of
it: the footer moved at t≈482ms, right when the lazily loaded route chunk
arrived. The site frame laid the footer out in normal flow, so before the
content existed it sat at the bottom edge of the viewport — visible — and
then dropped out of view when the page grew to 2400px.

The frame is a flex column now with the footer pinned by `margin-top:
auto`, and the content slot holds a viewport of height so the footer
starts below the fold and only ever moves further out of sight. The slot
is `main` once the route renders and `section.ui-page` while Suspense is
pending; covering only the first left the shift in place, which is what
the intermediate measurements showed.

  /  /explore  /projects  /releases  /search  /profile   0.1924 → 0.0001
  360 / 768 / 1440 across four routes: no horizontal overflow

Separately, the route gate stopped the Studio page but not the Studio
shell, so a signed-out visitor who typed /studio still got the whole
workspace navigation — 작업본, 게시 기록, 새 문서, by name. No data crosses
an href, but "비로그인 사용자가 Studio 화면을 볼 수 없다" is not satisfied by
hiding the contents of a screen while showing the screen. The header is
drawn only for an authenticated session; `children` is already the
router's sign-in surface, which is the whole of what such a visitor gets.

  signed out   h1 "세션이 필요합니다."   0 nav, 0 studio links
  signed in    h1 "작업 흐름"           2 nav, 8 links, sign-out present

Three test updates follow from behaviour that changed rather than broke:
the frozen route inventory now derives `access` from each route's own
layoutGroup instead of asserting "public" for all 28 — so a Studio route
added without a gate fails that table too — and the router and shell
harnesses supply the session the Studio surface now reads. The router
suite also gains a test that a signed-out visitor gets neither the Studio
heading nor its navigation, which is the regression the gate exists for.

test:all is 1811 passed; the eight remaining failures are the three
load-dependent flake families (ci-artifact-contract,
provider-guardian-transaction, security-followup), all of which pass in
isolation and reference none of the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:01:29 +09:00
DongHyeonkaandClaude Opus 5 f1498feee5 feat: let the SPA see the backend session, and give it a way out
Two gaps that only showed up once the backend's BFF login worked.

The SPA could not tell it was signed in. `AUTH_MODE: "external"` delegates
the session to whoever hosts the bundle, via
`window.__CA_FRONTEND_AUTH_OWNER__`; nothing installed one, so the runtime
fell back to `createUnavailableSessionAdapter` and a browser holding a
valid TECHLOG_SESSION cookie still saw "로그인 연동이 필요합니다".

Tech Log's host is its own backend. The session is an httpOnly cookie the
SPA cannot read, so the only way to observe it is to ask — which is what
`getStudioSession` already is, the contract's bootstrap operation that
issues the CSRF token. The owner probes it on creation and publishes the
result: 200 authenticated, 401/403 unauthenticated, anything else
integration-failed (claiming "signed out" on a 5xx would push the user
through a login they do not need). It starts at `recovery-pending` so a
signed-in user does not get a sign-in flash on every reload, and signs in
by navigating the browser to the authorization endpoint — the code flow is
a redirect chain an XHR cannot follow.

Installed from create-runtime-composition, before the adapters resolve it,
and only for AUTH_MODE=external with the HTTP Studio: MOCK has no backend
to ask and demo keeps its own adapter.

There was also no way to sign out. `signOut` is wired all the way to the
port and the label exists in both catalogs, but the button lives in the
template's AppShell, which TechLog never renders — it supplies its own
public and studio shells. The Studio header now carries it, drawn only
when authenticated so it does not duplicate the sign-in the auth gate
already offers.

Sign-out sends the CSRF header it caches from the session probe, and
reports failure instead of swallowing it. The first attempt did neither:
`/logout` is a mutation, answered 403 without the header, and the owner
published `unauthenticated` from a `finally` — so the cookie survived
while the UI claimed the user was out. That is the one failure someone on
a shared machine would never think to check, so a sign-out that did not
happen now throws and leaves the state alone.

Verified against the running backend with a production-profile build:

  /studio signed out   401 probe → sign-in surface → Keycloak
  after login          session 200, dashboard 200, real data rendered
  sign out             204, TECHLOG_SESSION cleared, /studio/documents
                       back to the sign-in surface

check:types, lint, check:architecture, check:tech-log-contract,
check:dev-release-manifest and check:browser-security all pass. test:all
is 1817 passed with two load-dependent flakes that pass in isolation
(provider-guardian-transaction, security-followup) and reference none of
the changed files — security-followup kills process groups, which is also
what was killing the Gradle daemon when both suites ran at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:24:09 +09:00
DongHyeonkaandClaude Opus 5 5fe355483e docs: complete the locally-verifiable half of the release checklist
Second pass. The first pass called several sections impossible; most were
not. The Studio mock implements all 18 contract operations with real
semantics (optimistic locking, validation staleness, preview expiry,
warning acknowledgement, idempotency), so the whole authoring flow is
exercisable without a backend, and the Public surface's UI behaviour is
testable against its static content.

Corrections to the first pass:
  - prod refusing to boot on the committed .env is the design working,
    not a defect: five startup validators reject development values, two
    of which were observed firing in order. The real gap is that no
    production value set exists anywhere yet.
  - ddl-auto=validate failing is a constraint, not a blocker -- prod
    accepts none as well, which is how this run booted.
  - two first-pass findings were false positives: the "Studio exposure"
    hits were release-note body text (zero /studio links on any public
    page), and the missing code block was a test artifact (no static
    document contains one; injecting one renders correctly).

New defects found:
  - no way to log out: the session button lives in the template's
    AppShell, which TechLog never renders -- it supplies its own shells.
  - duplicate relations are not prevented, at the contract level, so a
    backend implementation would inherit the same hole.
  - CLS 0.192, from a single footer shift at t=538ms.
  - no index on navigation_path: 236ms seq scan over 20k rows for the
    slug lookup the checklist names as a query pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:28:05 +09:00
DongHyeonkaandClaude Opus 5 44caa477e3 docs: record the release-gate verification run against a live backend
Runs both repositories locally -- backend on PostgreSQL 16 behind a real
Keycloak realm, frontend as a production-profile build -- and records what
each checklist section actually did, with the command output behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:50:42 +09:00
DongHyeonkaandClaude Opus 5 fff5e6f59e fix: gate Studio routes behind a session and repair the broken main build
Three defects found while running the release checklist against a live
backend, all on main.

1. Every TechLog route registered `access: "public"`, including the whole
   Studio surface. `decideRouteAccessForDefinition` was therefore a no-op
   for Studio: a signed-out visitor who typed /studio, /studio/documents,
   or /studio/assets got the Studio shell rendered, and the page went on
   to issue Studio API calls. Access is now derived from the spec's own
   `layoutGroup`, so a newly added Studio route is gated by construction
   rather than by remembering to restate it.

   Verified against a production-profile build: /studio* now renders the
   sign-in surface, /  and /explore are unchanged, and after signing in
   the router returns to the originally requested Studio screen.

2. `public/release-manifest.json` still declared the contract set at
   2.0.0 while the vendored contract had moved to 3.0.0 (eb86708). Boot
   verification fails closed on that mismatch, so `pnpm dev` served a
   blank screen. Regenerated from the same producer `dist/` uses.

3. `release-manifest.test.ts` asserted the same stale 2.0.0. The literal
   is deliberately independent of `EXPECTED_CONTRACT_SET_PACKAGES` (see
   the comment above it), so it is updated in place, not derived.

Also drops a dead `= null` initializer that failed `no-useless-assignment`.

check:types, lint, check:architecture, check:tech-log-contract and
check:dev-release-manifest all pass. test:all is 1818 passed with one
pre-existing load-dependent flake (provider-guardian-transaction, passes
in isolation, untouched by this change).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:34:31 +09:00
DongHyeonkaandClaude Opus 5 eb86708076 merge: feature/studio-response-envelope — Studio 응답 봉투를 전송 경계에서 언랩
계약 v3.0.0(ADR-006)에 맞춰 재생성하고, envelopeData/envelopeError가
{success,data,meta}를 언랩한다. 앱·도메인 계층과 StudioGateway 포트는 무변경 —
언랩이 전송 경계에서 끝난다.

검증: check:tech-log-contract / test:tech-log 337건 / tsc --noEmit 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:16:10 +09:00
DongHyeonkaandClaude Opus 5 68c5dbdaa3 docs: state when contract-set verification actually fails the dev boot
The README framed a missing `contractSet` package as a caveat of switching to
`HTTP`. It is not conditional on `TECH_LOG_STUDIO_SOURCE` at all —
`public/config.json` never set that key, and `pnpm dev` still failed to boot in
the default `MOCK` mode. Filing a total dev-server outage under an opt-in
switch is what let it sit unnoticed.

Says plainly that verification runs unconditionally at boot, which half can
drift, and what keeps the two in sync. Drops the "graceful, non-blank error
screen" reassurance for the default path: a developer running `pnpm dev` and
getting a boot error has a broken dev server whatever the screen looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:07:40 +09:00
DongHyeonkaandClaude Opus 5 4090c8681d test: gate the dev release manifest against the compiled contract set
Nothing in the gate set read `public/*.json`. Every static gate passed and the
whole suite passed while `pnpm dev` rendered the boot-error screen instead of
the app, which is the only reason the drift survived two contract changes.

`check:dev-release-manifest` compares the fixture's `setAlgorithm`, `setDigest`
and package set against what `generate-contract-set.ts` composes, and is
registered on FE-GATE-010 next to `check-tech-log-contract` so CI executes it.
Unlike the refresh wired into contract generation, this observes the composed
set directly, so it also catches a contribution added to or removed from
`installed-contract-contributions.ts`.

`CANONICAL_GATE_SHAPE_SHA256` recomputed by hand, as always: the committed
constant 98d19911... was first reproduced from the committed `gates.json` with
an independent transcription of `canonicalGateShapeSha256`, and only then was
b4096244... hashed from the new one. Command counts move 84/96 -> 85/97;
artifacts stay at 130 because the gate publishes no evidence file, matching
`check-tech-log-contract`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:07:40 +09:00
DongHyeonkaandClaude Opus 5 b75c9d0956 fix: declare the compiled contract set in the dev release manifest
`corepack pnpm dev` did not boot. Plain `vite` serves
`public/release-manifest.json` verbatim, and that hand-maintained fixture still
declared `"packages": []` after the first real contract contribution was
registered. `verifyContractSet` runs unconditionally at boot — before any
adapter is chosen, so in the default `MOCK` mode as much as in `HTTP` — saw the
build had compiled `@tech-log/studio-contract` and failed closed with
`CONTRACT_SET_PACKAGE_MISSING`. Production builds were never affected:
`scripts/generate-build-manifest.ts` derives `dist/release-manifest.json`'s
block from the same composed set.

Editing the fixture by hand is not the fix — it had already gone stale twice,
once when the package first appeared and once when the contract moved 2.0.0 ->
3.0.0, because every regeneration changes the package digest. So
`generate:tech-log-contract` now refreshes the block itself, as its last step
and through a dynamic import so it reads the canonical source it just wrote.
`generate:dev-release-manifest` does the same refresh on its own.

The composed set can also change without the contract being regenerated — a
contribution added to or removed from `installed-contract-contributions.ts`
moves it. Tying the refresh to contract generation is therefore necessary but
not sufficient; the CI gate that follows is what closes that half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:07:26 +09:00
DongHyeonkaandClaude Opus 5 3e2406349a contract: studio 계약의 VALIDATION_FAILED 개명(DOCUMENT_VALIDATION_FAILED) 반영
canonical studio-v1.yaml(tech-log-design-package b20d7a2)이 VALIDATION_FAILED를
DOCUMENT_VALIDATION_FAILED로 개명했다 — 스켈레톤 전역 OperationalError.
VALIDATION_FAILED(400)와 code 문자열이 충돌해 같은 code가 두 HTTP status를
갖던 문제를 해소한다.

- generate:tech-log-contract로 vendor된 계약·생성 타입·canonical-source.json 재생성
- STUDIO_ERROR_CODES(손수 유지되는 계약 미러)의 해당 항목 개명

계약 밖 코드는 STUDIO_UNAVAILABLE로 접히므로 이 배열이 계약과 어긋나면 안 된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:13:38 +09:00
DongHyeonkaandClaude Opus 5 aaaf0ac343 contract: ResponseMeta.page 스키마 확장 반영해 studio 계약 재생성
design-package 6a85d81이 ResponseMeta.page를 순수 null 타입에서
[object, null]로 넓혔다(openapi-generator가 순수 null 타입을 다루지
못해 생긴 계약 결함 수정, wire 의미는 그대로 — page는 여전히 항상
null). page의 생성 타입이 `null`에서 `{ [key: string]: unknown } | null`로
넓어졌다 — 의도된 변경이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:39:52 +09:00
DongHyeonkaandClaude Opus 5 9244f5c15d contract: discriminator enum 정정 반영해 studio 계약 재생성
design-package d170392이 discriminator 판별 필드를 const에서 단일값
enum으로 바꿨다(wire 의미 동일, openapi-generator 7.18.0의
discriminator+const NPE를 피하려는 정정). vendor된 계약과 생성 타입을
다시 맞춘다. 실질적인 리터럴 타입은 그대로다 — JSDoc 태그만
@constant에서 @enum {string}으로 바뀌었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:32:32 +09:00
DongHyeonkaandClaude Opus 5 d23a18f659 fix: ProblemDetails를 전송 계층이 실제로 만드는 모양 하나로 합친다
Task 3 리뷰 finding(Important): contract.ts의 손수 유지되는 ProblemDetails가
tech-log-studio-contract-contribution.ts의 envelopeError()가 실제로
반환하는 모양과 별도로 정의돼 있었다. envelopeError()는 ApiError의
type/title/status/detail/code/retryable/category/details만 채우므로
ProblemDetails가 갖고 있던 옛 평면 필드(instance/traceId/fieldErrors/
latestDocument/latestPublication/conflictingFields)는 production에서
항상 undefined였다 — mock만 채워서 mock이 아무것도 검증하지 못하는
상태였다.

- contract.ts: ProblemDetails에서 옛 평면 필드를 제거하고 details를
  wire와 같은 union 타입(ValidationErrorDetails | VersionConflictDetails
  | PublicationConflictDetails | null)으로 정확히 준다. 세 타입을 이제
  개별 export한다.
- tech-log-studio-contract-contribution.ts: envelopeError()가
  contract.ts의 ProblemDetails를 그대로 반환 타입으로 쓴다(로컬
  StudioProblemShape 제거) — 이제 한 곳에만 정의가 있다.
- mock-studio-gateway.ts / cursor.ts: fieldErrors/latestDocument/
  conflictingFields/latestPublication을 wire와 같은 자리(details 안)로
  옮긴다.
- mock-studio-gateway.test.ts: 위 이동에 맞춰 details를 캐스트로 좁혀
  읽도록 갱신.

retryable은 optional로 유지했다 — 여러 테스트가 생략하고 만들며, 이번
finding과 무관해 required로 좁히면 관련 없는 파일들이 깨진다.

리뷰가 보류한 2건(asset-upload-transport.ts의 CODES.has 중복 검사,
apiErrorSchema.category가 enum이 아닌 것)은 손대지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:13:33 +09:00
DongHyeonkaandClaude Opus 5 25a6b63d27 feat: Studio 응답 봉투를 전송 경계에서 언랩한다
studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.

- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
  도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
  교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
  outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
  실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
  계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
  거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
  envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
  발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
  만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
  contract-generation)를 봉투 형태로 갱신

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:55:45 +09:00
DongHyeonkaandClaude Opus 5 d84b57bb3f fix: let the mock's dependency revision observe the Asset store
`createMockStudioGateway`'s default `dependencyRevision.current()` returned a
literal constant and never consulted `dependencies.assets`, so the staleness
guards in `createStudioPreview` and `publishStudioDocument` could not fire for
an asset-store mutation between validate and preview.

The live case: a document references an evidence key with `alt=""` and the
store holds only a `decorative: true` Asset for it, so validation is correctly
VALID with zero issues. A newer `decorative: false` Asset then wins that key.
Preview succeeds, the figure resolves to `decorative: false, alt: ""`, and
publish snapshots it verbatim -- a meaningful image with no accessible name,
validated clean, with nothing anywhere reporting an error.

The default now folds the Asset store into the revision. Each Asset is reduced
to the fields the mock's own validation and projection read -- identity and
resolution order (`id`, `assetKey`, `updatedAt`), resolvability
(`managementStatus`, `publicPath`), the alt rule (`decorative`, `altText`), and
what the published `ResolvedAsset` carries (`mediaType`, `width`, `height`) --
canonicalized with `stableStringify`, sorted, and folded into a 128-bit FNV-1a
digest. Sorting the canonical strings is what makes it order-independent, which
this mock's reproducibility across the suite depends on.

It is a projection rather than the whole record because the excluded fields cost
sensitivity without buying any. `usageCount` is the clearest: it counts
referencing documents, so on a real backend publishing any document that uses an
Asset would invalidate every other author's in-flight validation, while changing
nothing the validator or renderer reads.

An empty store still reports the bare catalog constant -- that is the world the
seeded fixtures were validated against, and `fixtures.ts` now shares the one
definition rather than retyping the literal.

`findResolvableAsset` is untouched: it is a single-point-in-time predicate and
is correct as it stands. Seeing a change *between* two points is the revision's
job. A caller-supplied `dependencyRevision` still wins outright.

The asset-picker test that reached `failureOf`'s `ContentFormatError` branch did
so only because the revision could not move; it now pins its own revision to
keep reaching the projection, and asserts the problem detail so the two 409
paths cannot be confused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:44:53 +09:00
DongHyeonkaandClaude Opus 5 cf45bcc7dc test: give the Asset Library a visual golden, and a URL that resolves
`/studio/assets` was the one route in the TechLog route contract missing
from `TECH_LOG_CANONICAL_ROUTES` and `TECH_LOG_STUDIO_STATE_PATHS`, so it
had no golden, no responsive-overflow check, and no Axe scan. That gap was
not hypothetical: the responsive overflow just fixed in the Asset search row
shipped in two places and was only caught in the Picker, which has a golden;
the Library's identical copy would have gone unseen.

Verifying the render before capturing anything turned up a second defect.
`studioSpaPathPatterns` in the production serving contract never listed
`/studio/assets`, so a hard navigation or reload of that URL was answered
with the in-shell Studio 404 -- the screen was reachable only by client-side
navigation from another Studio page. A golden taken then would have frozen a
404. With the pattern added, the route serves 200 text/html like its
siblings, and the page renders correctly at both breakpoints:
scrollWidth == clientWidth == viewport at 360 and 1440, `main` and the `h1`
visible, the search label/input/button all inside the 360px viewport, and no
critical or serious Axe violations in chromium.

Three new goldens, no existing golden regenerated:
tech-log-studio-assets-{360,1440} from the canonical route list and
tech-log-studio-asset-library-1440 from the Studio state list, matching how
every other Studio route appears in both. `pnpm test:visual` is 133 passed,
up from 130.

The demo profile seeds no assets, so the golden captures the empty state --
which still covers the header, heading, search row and status region, the
surface the overflow regression lived on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:12:09 +09:00
DongHyeonkaandClaude Opus 5 b2f577ef49 test: stop DOM-element assertions from OOM-killing the worker
`node:assert` builds its `AssertionError` eagerly, running `util.inspect`
over both operands with `depth: 1000`, `getters: true` and
`maxArrayLength: Infinity`. A React-rendered DOM element carries
`__reactFiber$*` / `__reactProps$*` as own enumerable properties, and that
fiber graph re-expands once per traversal path, so inspecting a single
rendered element allocates without bound. Measured on the Asset Library
heading: depth 6 = 1.3MB, 8 = 7.8MB, 10 = 36MB, 12 = 135MB -- at Node's
depth 1000 the worker dies before any `AssertionError` exists.

The damage is not the crash, it is the disguise. Equality assertions only
inspect their operands on failure, so these sites stayed invisible while
green and detonated exactly when the behaviour they guard regressed --
reporting as `worker exited unexpectedly` with a truncated count
(`8 passed (13)`) and no failing test named. Breaking the delete-path focus
restoration in `asset-library.tsx` reproduced it: 29.5GB anon-rss and the
system OOM killer, or a V8 heap abort in 3s under a 512MB cap. The same
regression now fails in 1.15s with `expect(element).toHaveFocus()` naming
both the expected heading and the `<body>` that took focus instead.

`expect` is not affected -- vitest prints and diffs DOM nodes through
pretty-format's DOM plugin, which reads tag/attributes/children and never
touches the fiber -- so every unsafe site converts to a matcher:
`toHaveFocus()` for the three focus comparisons, `not.toBeInTheDocument()`
for the sixteen `assert.equal(queryBy..., null)` absence checks, which are
equally lethal (proved separately: element-vs-null inspects the element).

Three layers so this cannot come back:
- the 20 live sites in asset-library/asset-picker now use matchers;
- `test-assertion-boundary/no-element-operand-equality` fails `pnpm lint`
  when a DOM-element expression reaches `node:assert` equality, resolving
  local bindings and exempting the forms that cannot fail with an element
  in hand (`assert.notEqual(el, null)`, `el.textContent`);
- a 2048MB worker old-space ceiling in `vitest.config.ts` bounds any future
  runaway to a legible `Reached heap limit` abort in seconds instead of an
  OOM-killed machine (heaviest suite peaks near 1.3GB RSS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:03:33 +09:00
DongHyeonkaandClaude Opus 5 2483c4032f test: regenerate the five editor goldens the Asset search control moved
Stale because of the search UI this task added to the CASE editor's asset
panel, so they belong to this work. Each diff was inspected before
regenerating; every difference is attributable to the added control.

Four render `.../edit` for a CASE (2999 -> 3066px tall, +67px):
  document-edit-1440, case-editor-1440, conflict-editor-1440,
  dirty-leave-dialog-1440 -- the comparator flags one 401x95 region at
  (131,2795), the "Asset 검색" label, input and 검색 button. Above it the
  maximum per-channel delta is 1/255, i.e. visually unchanged.

  conflict-editor was not in my first estimate: document ...115 is a CASE
  too, so it renders the same panel.

document-edit-360 (3627 -> 3694px, +67px) shows more because the layout
is single-column: byte-identical above y=3032, then the added control,
then the status rail below translated down (67px, 68px past the 저장
button -- sub-pixel rounding, same content).

Its width returns to 360 here; the previous run captured it at 382,
which was the overflow fixed in d2574a3, not a golden to bake in.

`corepack pnpm test:visual`: 130 passed. `git diff --stat` for this
commit lists exactly these five PNGs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:40:49 +09:00
DongHyeonkaandClaude Opus 5 d2574a3be7 fix: stop the Asset search row from overflowing the 360px viewport
Caught by the visual suite, not predicted: at 360px the Picker's new
search row laid itself out at 366px inside a 328px column and pushed the
submit button off-screen, taking the document's scrollWidth to 382.

Both defaults involved resolve to a min-content minimum: an implicit grid
track (`auto`) and a flex container (`min-width: auto`). Neither will
shrink below the row's min-content, so the input's `flex: 1; min-width: 0`
never got the chance to give the button room. `minmax(0, 1fr)` on the
track and `min-width: 0` on the row are the same pair
`.studio-editor-layout` already needs one file over.

`.studio-asset-tools` gets the guard too -- the Library's search row has
the identical structure and no visual golden watching it. Verified in
Chromium at 360 and 1440 on both routes: scrollWidth equals the viewport
and no element extends past it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:35:25 +09:00
DongHyeonkaandClaude Opus 5 9950cb9d6b fix: clear the decorative flag when the upload dialog is handed a different file
The twin of the previous commit, with a sharper consequence. `decorative`
does not merely describe the previous image, it *exempts* it:
`validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED reads
`Asset.decorative`, so a flag inherited from a discarded divider lets a
meaningful diagram publish with no accessible name at all -- the check
passes rather than catching it. Stale alt text ships a wrong description;
stale `decorative` ships none.

Same terminal-state retry path: tick 장식용 for `divider.png`, have it
rejected, pick `sequence.png`, upload -- and `sequence.png` shipped as
decorative with `altText: undefined`.

Resetting it also re-enables the alt input, so the post-selection focus
call no longer has to ask whether it would land on a disabled control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:23:32 +09:00
DongHyeonkaandClaude Opus 5 1f2cba79e9 fix: clear alt text when the upload dialog is handed a different file
The file input's `onChange` reset `state` to `IDLE` and left `altText`
untouched. On success that is harmless -- the dialog unmounts. But
REJECTED, QUARANTINED and TRANSPORT_FAILED all leave it mounted with the
file input re-enabled, and that is precisely the retry path: upload
`db-schema.png` described as "DB 스키마", have it quarantined, pick
`sequence.png`, upload -- and `sequence.png` shipped described as
"DB 스키마", passing EVIDENCE_ALT_REQUIRED and publishing with a caption
about a different image.

Cleared on every file selection rather than only after a failure: alt
text describes one image, and "which file is this describing" has one
honest answer per selection. `submit()`'s existing ALT_REQUIRED refusal
turns the emptied field into a stop rather than a silent omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:17:33 +09:00
DongHyeonkaandClaude Opus 5 f69edb633d feat: search the server from the Asset Picker without shrinking preview's catalog
The Picker asked for `{ managementStatus: "READY", limit: 50 }` and
ignored `nextCursor`, so the 51st-oldest READY asset onward could not be
inserted at all. It sits inside the editing flow, where scrolling a long
list is the wrong interaction, so it gets search rather than a "더 보기"
control -- and it still loads a first page, because an empty panel until
you type is hostile to an author reaching for the asset they uploaded a
minute ago.

The trap this creates is the substance of the change. The editor screen's
Asset array feeds two consumers with opposite needs: the Picker's
*displayed* list, which a search must narrow, and Instant Preview's
*resolution catalog*, which a search must never narrow -- its gate
rejects any key no loaded asset backs. Handing search results straight to
the screen's `setAssets` would blank previously-inserted evidence figures
the moment the author typed a query.

They are kept apart by making the screen's callback additive by
construction rather than by convention: `mergeAssetCatalog` (domain,
beside `findResolvableAsset`) can only grow the set, and both writers --
observed pages and fresh uploads -- go through it. The prop is renamed
`onAssetsObserved` so the contract reads as "what the Picker saw", not
"what to show"; the replacing version was one `setAssets` reference away
and looked correct.

A key backed by neither the first page nor the current results is still
unresolvable. That is the known deferred limitation, not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:16:31 +09:00
DongHyeonkaandClaude Opus 5 a5825c18b5 feat: give the Asset Library server search and cursor paging
`StudioAssetGateway.listAssets` has always accepted `q`/`cursor` and
returned `nextCursor`, and this screen's own heading promises search
("업로드한 Asset을 검색하고..."). The component asked for `{ limit: 50 }`
and dropped `nextCursor`, so past the 51st asset older assets were
unmanageable with nothing on screen admitting anything was left out.

Follows `document-list.tsx`'s established shape: a `searchDraft`/`q`
pair so only a submitted query is an effect dependency (exactly one
request per deliberate search, never a trailing one), and a
`nextCursor`-driven control that appends rather than replaces.

Two things the pattern did not already cover:

- `RequestOptions.signal` is advisory, so aborting is not enough to stop
  an abandoned response from painting over a newer one. An `active` flag
  that flips synchronously on dependency change closes that race.
- "더 보기" unmounts exactly when the last page arrives, which would
  strand focus on `<body>`. Focus moves to the first appended row
  unconditionally, falling back to the page heading -- the same anchor
  the delete path already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:12:58 +09:00
DongHyeonkaandClaude Opus 5 419d9d006d ci: give test:tech-log its own gate command and junit evidence
The TechLog suite already ran in CI, but only inside `test:coverage`'s combined
`vitest run tests/runtime-schema tests/unit … tests/features/tech-log`
invocation on FE-GATE-005. A TechLog regression therefore surfaced as a
coverage-gate failure, and the only junit that carried it was coverage.xml,
which reports every other suite at the same time.

`test-tech-log` now sits on FE-GATE-007 beside `test-reference-feature`, the
sibling it mirrors — both drive a suite under `tests/features/` — and declares
`artifacts/tests/tech-log.xml`, which `test:tech-log` already wrote, as its own
command-generated junit evidence. The gate log now names
`$ corepack pnpm test:tech-log` as its own step and the failure lands on the
suite that produced it.

Adding a command and an artifact moves the exact-count authority to 84
definitions / 96 references / 130 artifacts (109 evidence references), and
changes the canonical gate shape digest because FE-GATE-007's commandIds and
evidenceArtifactIds are part of it. There is no tooling to regenerate that
digest, so it was recomputed by hand under the standing procedure: a fresh
transcription of `canonicalGateShapeSha256` first reproduced the committed
f3cc9075… from the unedited config/ci/gates.json — proving the transcription,
not just agreeing with whatever the check compares against — and only then
hashed the edited file to 98d19911….

`corepack pnpm ci:gate -- FE-GATE-007` passes end to end; the generated
workflow bytes are unchanged, since the yml dispatches gates rather than
commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:58:41 +09:00
DongHyeonkaandClaude Opus 5 74281b0277 test: probe the provider sandbox instead of failing on it
The 16 sandboxed provider tests in `tests/unit/ci-artifact-contract.test.ts`
fail wherever unprivileged user namespaces are denied. bubblewrap is installed
and answers `--version`, but `bwrap --unshare-net … -- /bin/true` exits 1 with
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`, and it fails the
same way with no netns flag at all (`setting up uid map: Permission denied`), so
this is the whole nested-userns capability and not one option. Sixteen
assertion errors on every local run buried whatever else the file had to say.

`scripts/lib/provider-sandbox-probe.ts` now runs a trivial command under the
real isolation options — `runProviderInSandbox` spreads the same
`PROVIDER_SANDBOX_ISOLATION_ARGUMENTS`, and a test fails if an isolation option
is added to the run without the probe having to clear it. Capability is
measured, never inferred from the binary existing or from a version string;
both would pass here.

An unusable sandbox means two different things in two places, so the decision
is explicit. Locally it is an environment fact: the affected tests skip and
carry the bwrap diagnostic as their skip note, visible as `↓ … [reason]`. In CI
it is a regression — a security gate that silently stopped running is exactly
what these tests exist to catch — so the same probe result fails the run
through one guard test that says "the provider sandbox is unavailable" instead
of sixteen assertion errors.

CI is detected with `CI === "true"` via a new `isCiRun`, sharing the predicate
that already gates `ciBuildEnvironmentFailures`. The workflow sets it at the
top-level `env:` block, so it holds in every job; `CI_RUN_ID` and its
`GITEA_`/`GITHUB_` fallbacks are declared only by the release-tier provider
jobs and are absent from the merge gates that run this file, so keying on them
would have left the CI branch permanently dead.

The three `it.each` groups become `it.for` because only `.for` passes the test
context, which is what carries the skip note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:55:56 +09:00
DongHyeonkaandClaude Opus 5 1a40522ff8 test: regenerate the remaining 6 goldens stale from Decision authoring
The prior commit (ef17bca) fixed the 7 of 13 test:visual failures that
were a pure dimensional consequence of 79e9aa8's evidence-figure width
rule. The other 6 were left failing pending investigation of their own
diffs rather than being folded into that theory.

Per-file inspection of each diff/expected/actual triple confirms all 6
are stale content from 0355b64 ("feat: add TechLog project decision
authoring"), also an ancestor of main's merge-base -- a "Decision"
document type was added without refreshing the affected goldens:

  tech-log-studio-documents-360        360x2883  (unchanged) -- subtitle
    copy gained ", Decision" before "을 찾고", rewrapping the sentence.
  tech-log-studio-documents-1440       1440x1493 (unchanged) -- same
    subtitle copy change, no wrap at this width.
  tech-log-studio-document-list-1440   1440x1493 (unchanged) -- same
    page/route as documents-1440 (identical diff), same copy change.
  tech-log-studio-document-new-360     360x1000 -> 360x1130 -- a new
    "Decision" radio option/row added to the document-type picker;
    narrow viewport can't absorb the extra row without growing.
  tech-log-studio-document-new-1440    1440x1000 (unchanged) -- same
    new "Decision" radio row; existing bottom whitespace absorbs it at
    this width.
  tech-log-studio-new-document-1440    1440x1000 (unchanged) -- same
    page/route as document-new-1440 (identical diff), same new row.

None of the 6 diffs show anything resembling a rendering defect (no
overlap, no clipped/broken layout) -- each is legitimate new copy or a
legitimate new control from the shipped feature.

test:visual: 124 passed/6 failed -> 130 passed/0 failed. lint and
check:types unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:41:25 +09:00
DongHyeonkaandClaude Opus 5 ef17bca445 test: regenerate the 7 goldens stale from the evidence-figure width fix
79e9aa8 ("fix: align TechLog article content widths") narrowed
.evidence-figure/.code-block/.data-table-wrap from
min(61rem, calc(100% + 15rem)) to min(var(--body-copy), 100%), shrinking
every page whose article body contains an evidence-figure diagram. The
goldens were never refreshed after that commit landed, so test:visual
carried 13 failures inherited from main.

Per-failure diff inspection (not just the aggregate count) showed the 13
split into two unrelated causes:

- 7 are a pure dimensional consequence of the width rule -- full-page
  diffs starting at the evidence-figure diagram, page height down by a
  fixed ~93px offset in matched pairs. Regenerated here:
    tech-log-case-1440                              6506 -> 6412
    tech-log-public-fixture-cases-collection-...     6506 -> 6412
    tech-log-studio-document-preview-1440            1904 -> 1811
    tech-log-studio-current-preview-1440             1904 -> 1811
    tech-log-studio-publication-preview-1440         1687 -> 1594
    tech-log-studio-publication-snapshot-1440        1687 -> 1594
    tech-log-studio-immediate-preview-1440           1733 -> 1640

- 6 are unrelated content staleness from a later commit
  (0355b64, "feat: add TechLog project decision authoring") that added a
  "Decision" document type/option without refreshing its goldens. These
  are left failing intentionally -- they are not a dimensional
  consequence of 79e9aa8 and regenerating them here would silently bake
  an unreviewed content change into the baseline:
    tech-log-studio-documents-360/1440,
    tech-log-studio-document-new-360/1440,
    tech-log-studio-document-list-1440, tech-log-studio-new-document-1440

test:visual: 13 failed/117 passed -> 124 passed/6 failed (the 6 above).
lint and check:types unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:35:22 +09:00
DongHyeonkaandClaude Opus 5 813f9e16cd test: regenerate only the five goldens this branch actually changed
Attribution was measured, not inferred: the visual suite was run at the
merge-base `9e5fbd1` in a detached checkout (13 failed / 117 passed) and the
`-actual.png` each run produced was compared by SHA-256 against HEAD's. For
13 of the 18 failures the rendered bytes at HEAD and at the merge-base are
identical, so those failures belong to `main`, not here.

Regenerated (all Case-editor surfaces, all grown by the Task 10 Asset panel;
the +293px band was inspected in the new golden and is the "EVIDENCE / 본문에
Asset 삽입" panel):

  tech-log-studio-document-edit-360    360x3313  -> 360x3627
  tech-log-studio-document-edit-1440   1440x2706 -> 1440x2999
  tech-log-studio-case-editor-1440     1440x2706 -> 1440x2999
  tech-log-studio-conflict-editor-1440 1440x2706 -> 1440x2999
  tech-log-studio-dirty-leave-dialog-1440 1440x2706 -> 1440x2999

`--update-snapshots` was restricted to those five tests with `--grep`; a
blanket update would have absorbed the 13 inherited failures and destroyed
the distinction. `test:visual` now reports 13 failed / 117 passed, exactly
the merge-base's set.

Two earlier records are corrected in the spec: the five "pixel-only, not
investigated" failures are all inherited, and `TECH_LOG_STUDIO_DOCUMENT_NEW`
360px was wrongly attributed to the Asset Picker -- it fails at the
merge-base with the identical 28,413 differing pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:29:46 +09:00
DongHyeonkaandClaude Opus 5 a889cb5c00 fix: scope the TechLog CSRF invalidation to Studio operations
`contractOperations.execute` is the single executor every installed feature
dispatches through, and it called `invalidateTechLogCsrfOnOutcome` for every
operation. A 403 on an unrelated reference-feature request therefore threw
away a perfectly good TechLog CSRF token, forcing an avoidable
`getStudioSession` round trip on the next Studio operation -- and, when the
session endpoint is itself unhealthy, turning someone else's authorization
failure into a Studio outage.

`invalidateTechLogCsrfOnOutcome` now takes the operation's auth profile and
acts only on the two TechLog Studio profiles. Required, not optional, so the
scoping cannot be dropped again by omission, and the predicate lives in the
feature file: `bootstrap/runtime-adapters.ts` is template-synced and its
change is the one added argument.

The composition test now installs both contributions the way
`installed-contract-contributions.ts` does, and asserts a reference-feature
403 leaves the cached token alone. Reverting the scope check fails exactly
that test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:14:42 +09:00
DongHyeonkaandClaude Opus 5 65f8528ccc test: cross-product Instant Preview's evidence-key pair with the adapter's
`evidence-key-agreement.test.ts` cross-products 144 adversarial combinations
across the mock adapter's gate and descriptor resolver, `validateWorkingCopy`
and the shared pixel resolver -- but omitted `instant-preview.tsx`'s own gate
and descriptor resolver. That pair is byte-parallel to the adapter's (it
lives in `presentation/`, which may not import `adapters/`, so it carries its
own copy of the legacy-key predicate) and it is exactly the pair three earlier
fix rounds regressed.

`instant-preview.tsx` now exports the two expressions it already used, so the
test drives the production code rather than rebuilding it. Added assertions:
gate agreement in both array orders, descriptor equality with the adapter's,
pixel/descriptor agreement, and order independence. The one sanctioned
divergence -- the legacy static key's `assetId`, a fixed literal here versus a
registry-derived one there -- is pinned to that case rather than ignored.

Mutation-checked: breaking the legacy predicate reports 96 of 144 combinations
disagreeing, breaking the legacy descriptor path reports 64 of 144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:12:06 +09:00
DongHyeonkaandClaude Opus 5 e2a0e695dd fix: normalize uncharacterized read failures in the mock Studio gateway
`idempotent()` was fixed to wrap anything `work()` throws, with a documented
rationale: this port's contract is `StudioGatewayError` only, so nothing else
may cross it. Its `read()` sibling was left unwrapped, so an uncharacterized
internal failure on any of the seven read operations escaped as a raw `Error`
and reached UI code written to catch `StudioGatewayError`.

`read()` now applies the same `failureOf` classification. It has no
idempotency ledger, so only the problem half is used. `boundary()` stays
outside the wrap so an aborted request still surfaces as `AbortError`,
exactly as `idempotent()` arranges it -- asserted by the new test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:09:36 +09:00
DongHyeonkaandClaude Opus 5 e4f9f81a3f docs: record the passthrough output validators as a known limitation
All 18 Studio operations declare `passthrough` (`z.unknown()`) input and
output validators. The choice is deliberate and commented in
`tech-log-studio-contract-contribution.ts`, but it was recorded nowhere a
spec reader looks, and it compounds the cycle's own non-goal: with no running
backend, MSW returns whatever the test author wrote and nothing compares it
to the canonical schemas, so `CONTRACT_VIOLATION` can never fire for a
success-payload shape mismatch. Compile time is currently the only layer that
catches a shape regression.

Recorded in §Task 12 완료 상태 beside "no running-backend verification" -- the
same class of risk -- with the two options for the backend-comparison cycle.
No code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:08:46 +09:00
DongHyeonkaandClaude Opus 5 172497591f docs: point the spec at canonical-source.json instead of a stale digest
§상태 pinned `sha256:85a65004…` / revision `0ec5582` while the canonical yaml
that actually shipped is `sha256:99f54f56…` / `ce2e748` -- the value
`canonical-source.json` records, the value the contract contribution imports,
and the value the spec's own Task 12 table already quotes. The canonical
source moved during implementation and only one of the two places was updated.

The spec no longer carries the values at all: it names
`contracts/studio/canonical-source.json` as the single record, which is what
the contract contribution reads and what `check:tech-log-contract` verifies,
so the two cannot drift apart again. Task 12 row 3 also records that the drift
gate is now wired into FE-GATE-010 and `test:all`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:08:18 +09:00
DongHyeonkaandClaude Opus 5 6085af51b6 fix: collect alt text and a decorative flag when uploading a Studio Asset
The upload dialog sent `{ file, kind }` only, though `UploadAssetForm` and
`asset-upload-transport.ts` both carry `altText`/`decorative`. Every asset
uploaded through the flagship authoring flow therefore landed as
`altText: null, decorative: false`, and the directive `case-fields.tsx` and
`asset-picker.tsx` build from `asset.decorative ? "" : (asset.altText ?? "")`
could only ever be `alt=""`. The document parsed and previewed correctly and
then failed publish validation with EVIDENCE_ALT_REQUIRED, recoverable only
by hand-editing raw Markdown -- the exact thing the Picker exists to prevent.

The dialog now stages the file instead of uploading on selection, and carries
a decorative checkbox plus an alt-text field (focused as soon as a file is
chosen, submitting on Enter). A decorative asset never demands alt text; a
meaningful one is refused with a stated reason rather than a disabled button.
Insertion is unchanged: both call sites already read the Asset, so they now
insert what it actually carries.

Three new tests drive the whole loop -- upload through the real MOCK
composition, auto-insert, save, validate, preview -- and assert the result is
publishable. The pre-existing loop test no longer needs its hand-edit
workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:07:38 +09:00
DongHyeonkaandClaude Opus 5 f19be639a3 ci: run the TechLog contract drift gate in CI and test:all
`check:tech-log-contract` existed and worked but nothing ran it. No gate in
`config/ci/gates.json` referenced it and `test:all` did not chain it, so a
hand-edit of the vendored canonical yaml or of `generated.ts` passed every
gate the repository actually executes -- the exact regression the digest pin
exists to prevent.

FE-GATE-010 (architecture/contract governance) now owns the command, beside
`check-registries` and `check-ci`, and `test:all` runs it in front of
`test:tech-log`. The canonical authority baseline moves to 83 command
definitions / 95 references and the gate-shape SHA-256 is recomputed with
`canonicalGateShapeSha256`; the recomputation was first verified by
reproducing the previous constant from the previous gates.json.

`tests/features/tech-log/contract-generation.test.ts` now fails if either
wiring is removed again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:03:14 +09:00
DongHyeonka 5c997f3a7e docs: correct the test:visual root-cause attribution in the alignment record
Fix round 1 on Task 12's evidence. The design doc and task-12-report.md
attributed all 18 test:visual failures to Task 9 and described them as
uniformly taller. Independently re-derived from git log/git diff instead of
re-asserting the review's numbers on trust:

- 7 of 18 (both Public tests + 5 Studio preview/snapshot tests) render
  shorter, by 93-94px, caused by 79e9aa8 ("fix: align TechLog article
  content widths") — already on main, an ancestor of this branch's
  merge-base 9e5fbd1. case-body-renderer.tsx/evidence-figure.tsx are
  byte-identical across 9e5fbd1..HEAD; the golden PNGs were last written at
  3a7c5de, before 79e9aa8. Pre-existing at the merge-base, not caused by
  this branch; refresh belongs against 79e9aa8 on main.
- 6 of 18 (Studio editor surfaces) render taller, from this branch's
  sanctioned Asset Picker/upload UI (Task 10) — the only failures this
  branch actually produced.
- Condition-1's citation (check:architecture/check:registries) didn't
  support a Public-render claim; replaced with the actual evidence (empty
  renderer diff) plus the main-inherited visual failure.
- Softened test:coverage's "no new code is under-covered" — risk-coverage
  has zero tech-log entries, so its silence isn't evidence either way;
  stated the global thresholds that are actually cleared instead.

No code changed; no gates re-run.
2026-08-18 08:44:35 +09:00
DongHyeonka 83d47e7185 docs: record TechLog backend alignment completion state
Task 12 of the backend-alignment plan: document the TECH_LOG_STUDIO_SOURCE
switch and the generate/check:tech-log-contract scripts in README.md, and
record the spec's completion status (12/12 completion conditions met, the
two explicitly-out-of-scope items restated, and the gate findings from the
full verification pass) in the design doc's status section. No product code
changed.
2026-08-18 08:25:49 +09:00
DongHyeonkaandClaude Opus 5 3ab04a236d fix: restore focus to a stable anchor, not a removed row, after asset delete
Fix round 1 for the Task 11 asset library review. I1 (Important): a
successful delete removed the row from state, so remove()'s reuse of
closeDetail() queued .focus() on a now-detached button -- a silent
no-op that stranded focus on <body>. Splits closeDetail() (cancel/close,
row still on screen, restores focus to the trigger) from a new
closeDetailAfterRemoval() (post-delete, focuses the page heading, the
one anchor guaranteed to survive any list change) so the two paths
stop sharing a helper that only one of them can safely use.

Also closes four Minors from the same review round:
- route-contract.test.ts's "27-route inventory" test title corrected
  to 28.
- canHardDelete's usageCount clause gets its own isolating assertion
  (every prior case used usageCount: 0, so that clause was never
  independently falsified).
- Dropped a duplicate role="status" announcement on a listAssets
  load failure; the existing role="alert" paragraph is now the sole
  announcement.
- Added success-path tests for archive() and remove() via a new
  recordingGateway() test helper that pins the exact gateway call
  shape (expectedVersion, managementStatus: ARCHIVED, idempotency
  keys), not just the resulting UI text; the delete-success test also
  pins the I1 focus fix so a regression back to the removed trigger
  fails loudly.

See task-11-report.md's "Fix round 1" section for the RED/GREEN
evidence (the pre-fix code reliably crashes the test worker rather
than failing the assertion cleanly -- explained there) and the
correction to this task's original claim about matching
publication-list.tsx's focus pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 07:53:17 +09:00
DongHyeonkaandClaude Opus 5 b11aa94f1c feat: add the TechLog Studio asset library route and screen
Adds TECH_LOG_STUDIO_ASSETS (/studio/assets) as a route reachable but
excluded from primary Studio navigation (navigationLabel/navigationOrder
null), plus the AssetLibrary screen that lists assets, shows usage, and
lets an operator archive or hard-delete one. canHardDelete() is a pure
gate mirroring the server's ASSET_IN_USE rule so the screen never offers
an action the server would refuse.

Adding a 28th route also required updating the route-scoped CI
accessibility-evidence gate (FE-GATE-009 in config/ci/gates.json, plus
its authority-baseline counts and shape digest in
scripts/contracts/ci-gates.ts) and the Vite route-to-chunk map that
scripts/generate-build-manifest.ts depends on, or test:unit and the
production build both fail. See task-11-report.md for the full
breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 07:26:05 +09:00
DongHyeonkaandClaude Opus 5 073fda87eb fix: collapse the evidence-key gate and resolver into one decision
Three fix rounds each rebuilt the gate as a separate expression that merely
agreed with the resolver on the inputs that round's tests used. Different
expressions cannot agree in general, so the defect class stayed open while
each reported instance closed.

`findResolvableAsset(assets, key)` is now the single place that decides which
Asset an evidence key resolves to. Every gate is
`Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared
composition, and every resolver returns what it returns:

- validate-working-copy: the key gate and the decorative lookup (a last-wins
  Map against the resolver's first-wins find, so alt could be judged against a
  different Asset than the one rendered)
- adapters/mock/project-public-render-model: gate and resolver
- instant-preview: gate and descriptor resolver
- createAssetCatalogResolver: the pixels, a fourth expression nobody had
  listed -- one Asset's caption could sit over another Asset's image

Duplicate assetKeys are a contract violation but reachable through a paged
list, so the choice is total and order-independent: newest updatedAt wins,
tie-broken by id.

InstantPreview's gate is no longer looser than the others. The un-loaded-asset
case it was loosened for blanks either way; all the looseness bought was
catalog-only keys rendering an empty gap with no message while validation said
EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the
consistent behaviour, and the false comment claiming a fix that did not exist
is gone.

idempotent() now maps a deterministic content failure to VALIDATION_STALE/409
instead of offering a retry that fails identically, and no longer caches
uncharacterized internal failures -- reporting one as retryable while freezing
it in the ledger meant the retry could never re-run.

Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial
(asset list, key) combinations asserting the agreement itself rather than
examples. It reported 53 disagreements against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 06:55:06 +09:00
DongHyeonkaandClaude Opus 5 783e9b2cf1 fix: rebuild gate 1 from the resolver's own predicate, not a catalog lookup
Fix round 3 (review of 7ff9728):

Round 2's gate 1 (supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets)))
still routed through evidenceCatalogEntryFor, matching on id || label ||
publicPath -- gate 2's question, asked over fewer rows, not the resolver's
actual success condition (assetKey === key && managementStatus === "READY"
&& Boolean(publicPath)). Two Asset shapes made the two predicates disagree:
a READY asset with publicPath null/"", and a READY asset whose publicPath
happens to satisfy the legacy /media/${someOtherKey}.svg convention for a
key that isn't its own assetKey. Both passed gate 1 while the resolver
could not produce real pixels, reproducing the validateDocument-VALID /
createPreview-throws-raw-Error disagreement a second time.

Rebuilt gate 1 in validate-working-copy.ts and
adapters/mock/project-public-render-model.ts directly from a new domain
function, supportsEvidenceKeyFromReadyAssets, that mirrors the resolver's
exact condition -- never through evidenceCatalogEntryFor again. Gate 2
keeps reading the merged catalog. Made the mock resolver total (returns a
placeholder instead of throwing, matching instant-preview.tsx's resolver),
removing a comment that asserted an invariant the code did not hold.
Wrapped mock-studio-gateway.ts's idempotent() so any non-StudioGatewayError
that reaches its catch is normalized before crossing the port -- closing
the class generally, not just this instance.

Reverted instant-preview.tsx's own gate 1 to the merged catalog (unlike
the mock adapters, its resolver is provably total, so a loose gate there
only ever degrades to a placeholder) -- round 2's narrowing there was a
separate regression: a CASE referencing a document-catalog-backed key
outside the editor's currently-loaded Asset list blanked the entire
preview instead of degrading one figure.

Pinned both slip-through shapes failing on both mock paths with the
thrown/rejected error's type asserted (StudioGatewayError, never a raw
Error), pinned idempotent()'s new wrapping via a validate/preview race,
pinned EVIDENCE_NOT_FOUND as reachable through validateWorkingCopy
directly, and pinned Instant Preview's graceful degradation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 06:24:46 +09:00
DongHyeonkaandClaude Opus 5 7ff9728a5c fix: un-collapse evidence gate 1 from gate 2 to close a raw-Error escape
Fix round 2 (review of 9864958):

Round 1's I2 fix made gate 1 (supportsEvidenceKeyIn) read the same
merged catalog gate 2 already checks, so gate 1 stopped asking "does a
real Asset (or the legacy key) back this key" and started asking the
identical question gate 2 asks ("is there any EVIDENCE catalog row for
it"). A real EVIDENCE catalog row that exists for something other than
media (fixtures.ts's row for a QUESTION resolution target) then passed
gate 1 with nothing backing it as evidence. validateDocument reported
VALID; createPreview's resolver -- which only ever knew the legacy key
and real Assets -- threw a raw, unwrapped Error, violating the port's
StudioGatewayError-only contract. I2's disagreement reproduced in the
opposite direction.

Rebuilt gate 1 in all three callers (validate-working-copy.ts,
adapters/mock/project-public-render-model.ts, instant-preview.tsx) as
"legacy key OR an Asset-derived catalog entry only" -- never the
merged document catalog -- while gate 2 keeps reading the merged
catalog as before. Also stopped emitting the real Asset UUID as the
synthesized CatalogEntry's id (prefixed instead), closing a path where
pasting an Asset's real id -- never something the Picker itself
produces -- would have resolved as an evidence key.

Restored content-format.test.ts's domain tests to exercise the same
gate-1 composition production callers now use instead of a bare
hand-injected predicate, and added coverage for: a real non-asset
EVIDENCE row still being rejected, an Asset-backed key being accepted
with no document-catalog row at all, the fixture-UUID case failing
both mock paths with no raw Error crossing the gateway port, and the
EVIDENCE_ALT_REQUIRED decorative-Asset pairing that had no test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:54:21 +09:00
DongHyeonkaandClaude Opus 5 98649585e6 fix: generate fresh upload idempotency keys and reconcile mock preview/validation on evidence keys
Fix round 1 (review of 54d9bf9):

I1: AssetUploadDialog reused one idempotency key across every upload
attempt in a session, generated once when the dialog opened. Every
terminal state re-enables the file input, so retrying with a different
file after a failure sent two distinct payloads under the same key.
The key is now generated fresh inside submit() on each call, matching
every other mutation call site in the repo, and dropped from the
dialog's public props entirely (it was never in the task's own
"Produces" interface).

I2: the mock's validateDocument gate only recognized the one legacy
hardcoded evidence key, completely disconnected from the Asset system
Instant Preview now consults -- so a directive the Picker or upload
dialog inserted always previewed live and then failed validation with
EVIDENCE_UNSUPPORTED for every other key. Extracted the Asset-to-
CatalogEntry mapping (evidenceCatalogEntriesFromAssets) and the
domain's one EVIDENCE-catalog matching rule (evidenceCatalogEntryFor)
into shared domain modules that both the preview path
(instant-preview.tsx) and the validation path (validate-working-copy.ts,
the mock's own createPreview) now call. Reconciled the underlying MOCK
studioSource gap that caused this: built a mock asset gateway
(mock-studio-asset-gateway.ts) sharing one in-memory Asset store with
the mock document gateway, wired per composition-root instance in
create-tech-log-feature-input.ts, so an Asset the editor actually
loaded is visible to validation too, while a key backed by nothing
still fails both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:31:01 +09:00
DongHyeonkaandClaude Opus 5 54d9bf9120 feat: insert evidence directives from the TechLog asset picker
Adds an Asset Picker and upload dialog to the CASE editor so authors can
insert `:::evidence` directives that reference backend assets, and opens
projectWorkingCopy's two evidence gates so Instant Preview accepts a key
backed by a freshly loaded READY asset instead of only the one hardcoded
legacy key. The editor screen now owns the loaded Asset list so the
Picker, the upload dialog, and Instant Preview all read the same array,
and a freshly uploaded asset appears in the preview without a refetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:32:56 +09:00
DongHyeonkaandClaude Opus 5 e2c1d076f4 fix: overlay legacy evidence labels instead of shadowing the whole descriptor
Review finding I1: the static-key-first resolution order protected the
entire legacy fixture (image and labels), when only the hand-authored labels
ever needed protecting -- ResolvedAsset carries no label fields, so nothing
else could recover them, but a real descriptor's publicPath/width/height were
never actually at risk of mismatching. Narrowed resolveWith so a resolved
descriptor's data fields always win; the legacy registry only overlays
triggerLabel/dialogLabel for keys it recognizes, and only supplies the full
descriptor when nothing else resolves the key at all. A future backend asset
colliding with the legacy key now degrades to a wrong caption, never a wrong
image.

Review finding I2: renamed and rewrote a test whose title claimed a
READY-vs-QUARANTINED same-key guarantee its body never constructed. It now
puts both a QUARANTINED and a READY entry under one assetKey and asserts the
READY one wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:03:43 +09:00
DongHyeonkaandClaude Opus 5 9d91001a31 feat: resolve evidence figures from backend asset descriptors
Public Preview and Publication Snapshot now resolve evidence figures from the
ResolvedAsset descriptor the server already attaches to each EVIDENCE_FIGURE
block, instead of a local literal that only knew one hardcoded key and threw
on anything else. Instant Preview gains an `assets` prop (defaults to `[]`,
unwired until the Asset Picker task) and stops throwing when a key has no
catalog match yet.

Builds on Task 1's existing seam (resolveCaseEvidenceAssets /
ResolveEvidenceAsset) via a new asset-resolvers.ts rather than a parallel
path. Kept out of adapters/ (presentation may not import it) by carrying a
small local copy of the one legacy fixture entry, checked before any
descriptor match so the pre-existing key keeps rendering byte-identically
across surfaces during the migration window. Non-READY assets never resolve;
unresolvable keys return a placeholder instead of throwing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:54:30 +09:00
DongHyeonka 0c071aaabc fix: judge evidence alt text with asset metadata, not syntax
The parser has no asset catalogue, so it cannot know whether an
evidence figure is decorative. Move the empty-alt rejection from
parse-time (a syntax error) to publish validation, where the mock
treats static evidence assets as decorative: false since the static
registry predates the Asset capability. The real rule is that the
server judges alt against Asset.decorative.
2026-08-18 03:33:19 +09:00
DongHyeonkaandClaude Opus 5 35cc5c868a fix: invalidate the TechLog CSRF token on 403 and harden the bootstrap profile wiring
Item 1 (real bug): contractOperations.execute only invalidated the cached
CSRF token on UNAUTHENTICATED (401). A CSRF-specific rejection normally
arrives as FORBIDDEN (403) -- the platform classifies any 403 response as
FORBIDDEN unconditionally -- so a token rejected during an ordinary document
save left the stale token cached and every subsequent Studio mutation kept
failing until reload. Extracted invalidateTechLogCsrfOnOutcome() so
production and the composition test call the identical function; it now
invalidates on both UNAUTHENTICATED and FORBIDDEN.

Item 2: the upload transport's uncontracted-status fallback hardcoded status
503, so an uncontracted 401/403 body never reached the gateway's
error.status === 401 || 403 invalidation check. Passes the real
response.status through.

Item 3: safeOperation()'s auth-profile parameter is now typed as a union of
the two valid profile constants instead of a bare string, and
assertExactlyOneTechLogStudioBootstrapOperation() fails composition closed
if getStudioSession stops being the sole caller of the credential-free
bootstrap profile.

Item 4: corrected two stale operation counts in the adapter review doc.

Both new tests for items 1 and 2 were run and shown failing before their
fix, per this task's TDD standard for error-path changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:25:29 +09:00
DongHyeonkaandClaude Opus 5 2cab4974b7 fix: break the TechLog CSRF bootstrap cycle and close the review's fix-round-1 items
C1 (Critical): getStudioSession was stamped with the same
TECH_LOG_STUDIO_SESSION auth profile as every other Studio operation, and
that profile requires the CSRF header it is getStudioSession's own job to
issue -- an unconditional cycle that recursed without bound in HTTP mode.
Fixed with a credential-free TECH_LOG_STUDIO_BOOTSTRAP auth profile for
getStudioSession alone, a synchronous re-entrancy guard in
createCsrfTokenProvider as defense in depth, and a throwing stub in place of
the prior `let x!: T` assertion. Added a composition-level regression test
that wires the real executor, CSRF provider, and credential-attach function
together and proves getStudioSession dispatches exactly once while its token
reaches both a JSON operation and the multipart upload.

Also: invalidate the cached CSRF token on a 401/403 from the upload path
(I2), a throwing useStudioAssetGateway() accessor so Task 11 cannot silently
compile a null-gateway UI (I3), and the M1-M5 minors from the review (guard
a malformed success body, cover the untested error fallbacks, align aborted
uploads with the JSON path's non-retryable CANCELLED mapping, derive the
credential header name from one source instead of two, and correct the
adapter review doc's operation count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:07:02 +09:00
DongHyeonkaandClaude Opus 5 c9c832c365 feat: add the TechLog asset multipart upload transport
Wires the whole Asset capability into the running application: the
multipart upload transport (the contract runtime can only express JSON
bodies), a single composition-root-owned CSRF provider shared between
the platform's credential collaborator (18 JSON operations) and the
upload transport (1 multipart operation), and Studio/StudioShell
exposure of the Asset gateway alongside the existing document gateway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:39:51 +09:00
DongHyeonka d9c2d8bc5e feat: add the TechLog Studio asset gateway port and JSON adapter 2026-08-18 02:16:51 +09:00
DongHyeonka 7724a8720c fix: account for TECH_LOG_STUDIO_SESSION in the auth profile registry pin
Task 3's contract contribution legitimately registered a TECH_LOG_STUDIO_SESSION
auth profile, growing INSTALLED_REST_AUTH_PROFILES from 2 to 3 entries. The
exact-key-set regression test at contract-registry-immutability.test.ts:91
correctly caught the drift; test:unit was left off Task 3's verification list,
so it went unnoticed until Task 5 ran the full suite. Updates the expected key
list rather than weakening the assertion, so it keeps forcing a reviewer to
confirm any future registry change was intended.
2026-08-18 02:05:29 +09:00
DongHyeonka 3b641906b8 feat: select the TechLog Studio adapter from runtime configuration
Adds TECH_LOG_STUDIO_SOURCE (MOCK | HTTP, default MOCK) to the V2 runtime
config schema so a build can switch createTechLogFeatureInstalledInput
between the mock and HTTP Studio gateways without a rebuild. V1 documents
predate the key and always normalize to MOCK. The HTTP gateway is
constructed with only { operations } per Task 4's actual signature -
no CSRF provider is wired here; that lands with attachCredentials at a
later composition-root task.

Updates every existing Studio test call site to the new required
createTechLogFeatureInstalledInput(context) signature via a shared
tests/helpers/studio-install-context.ts MOCK fixture, so the whole
existing Studio suite keeps exercising the mock adapter unchanged.
2026-08-18 01:57:15 +09:00
DongHyeonkaandClaude Opus 5 7424ed4594 fix: forward every documented Studio query filter and pin canonicalInputIdentity
Two review Minor findings against the brief itself, both closed:

- tests/mocks/handlers/tech-log-studio.ts silently dropped documented
  query filters instead of mirroring the mock gateway it wraps:
  listStudioDocuments forwarded only q/limit (dropping kind,
  publicationStatus, nextAction, projectId, sort, cursor),
  listStudioCatalog forwarded only type (dropping q/cursor/limit), and
  listStudioPublications ignored all four of its parameters outright.
  Added a shared queryParams() helper and forward every field each
  operation's projectRequest actually emits, plus a regression test
  that narrows the fixture set by kind through the real HTTP gateway
  (confirmed it fails without the fix).

- canonicalInputIdentity (on the idempotency-safety path, reused by
  Task 6) had no test. Added three tests against mutationIntent(): a
  large Korean payload stays within the byte bound, the same input
  retried twice yields an identical identity, and a mid-codepoint
  truncation cut leaves no replacement character (confirmed the third
  fails without the strip). The known collision limitation of any
  bounded-length identity scheme is documented in the test file rather
  than solved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:43:51 +09:00
DongHyeonkaandClaude Opus 5 1d01a522de feat: implement the TechLog Studio HTTP gateway
Adds createHttpStudioGateway, the adapter the Studio UI calls in
production. It drives every mutation through mutationIntent()
(defineMutationIntent/defineIdempotencyKey, not
createBrowserMutationIntentFactory, so the caller-supplied idempotency
key is preserved rather than regenerated) and leaves header
construction to the executor/credential collaborator entirely - the
gateway never sees or sets Idempotency-Key or x-csrf-token itself.

Also moves stable-stringify.ts out of adapters/mock/ so the mock and
http adapters share one pure function without production code
depending on the mock directory, and fixes the resulting import in
cursor.ts, mock-studio-gateway.ts, and mock-studio-gateway.test.ts.

Adds MSW handlers (tests/mocks/handlers/tech-log-studio.ts) that wrap
the reference mock-studio-gateway implementation, and a contract test
suite that drives the gateway through a thin fetch-based executor
built from the contract's own projectRequest, proving canonical
path/body/header construction without assembling the full platform
transport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:30:24 +09:00
DongHyeonkaandClaude Opus 5 9313018ef5 fix: restore contract-set regression coverage and mirror canonical problem bounds
release-manifest.test.ts's fixture derived manifest.contractSet.packages
from EXPECTED_CONTRACT_SET_PACKAGES itself, so the equality it checked was
satisfied by construction and CONTRACT_SET_PACKAGE_MISSING became
unreachable from any test in the repo. Adds two independent checks: a
literal (not derived) assertion that EXPECTED_CONTRACT_SET_PACKAGES really
contains @tech-log/studio-contract@2.0.0, and a negative test with a
manifest that omits a package the real expected set requires, asserting
CONTRACT_SET_PACKAGE_MISSING. Confirmed the negative test has teeth by
temporarily disabling the missing-package branch in
verifyContractSet (src/contracts/contract-set.ts) and observing the test
fail before reverting.

Also narrows tech-log-studio-contract-contribution.ts's problemSchema to
match canonical ProblemDetails exactly: title max 200 (was 240) and type
unbounded (was max 512; canonical only constrains it as
format: uri-reference). Both prior values were over-permissive, so no
previously-accepted document is now rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:21:14 +09:00
DongHyeonkaandClaude Opus 5 66c047cec8 feat: register the TechLog Studio contract contribution
Registers the TECH_LOG_STUDIO_SESSION SAME_ORIGIN_COOKIE auth profile and
declares the tech-log-studio-http-v1 contribution covering all 18 JSON
Studio operations (everything except the multipart uploadStudioAsset),
built from two shared builders (safeOperation/keyedOperation) so every
KEYED command gets IDEMPOTENCY_REPLAY recovery and a zero retry budget,
and every SAFE read gets a plain retry budget, without repeating the
declaration shape 18 times.

Rescopes the canonical package identity from "tech-log-studio-contract"
to "@tech-log/studio-contract" (generator, canonical-source.json,
contract-generation.test.ts) because the platform's contribution
composer requires an npm-scoped packageId; the unscoped form failed
composition. Updates the release-manifest test fixture, which hardcoded
an empty expected contract set, to derive its expected packages from the
real installed set now that TechLog is always installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:10:04 +09:00
DongHyeonkaandClaude Opus 5 a6fc536d8a docs: scope the TechLog contract packageId to satisfy PACKAGE_ID
The platform models contract contributions as published npm packages and
rejects an unscoped packageId at composition time. Use
@tech-log/studio-contract, the name a real publish would carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:05:16 +09:00
DongHyeonka 6c40c291d9 test: cover Studio error mapping's remaining outcome branches and CSRF rejection recovery 2026-08-18 00:51:50 +09:00
DongHyeonka 7381be1477 feat: add TechLog Studio error mapping and CSRF token provider 2026-08-18 00:44:37 +09:00
DongHyeonkaandClaude Opus 5 a0e0be6522 test: cover dashboard needsValidation and detail() nextAction regression risk
Round 3 of the TechLog contract task added two pieces of genuinely new
logic with no assertion on their output: the totals.needsValidation
count in getDashboard(), and the detail() rewrite that avoids a
self-reference when computing WorkingCopyDetail.nextAction. Both would
have passed every existing test if they regressed.

- needsValidation is checked against an independent count derived from
  listDocuments()'s per-document nextAction, with sanity bounds so a
  filter that always returns 0 or the full count can't pass silently.
- detail()'s nextAction is asserted on two fixtures whose expected
  value is justified by the asserted preconditions alongside it
  (published-at-current-version -> NONE; INVALID-but-current-validation
  -> FIX_VALIDATION). Verified locally that hardcoding nextAction to a
  constant in detail() makes the second assertion fail, and that
  zeroing needsValidation's count makes the dashboard assertion fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 00:38:03 +09:00
DongHyeonkaandClaude Opus 5 639e1a49c9 build: generate the TechLog Studio contract from canonical source
Vendors the canonical studio-v1.yaml, generates types via an isolated
`pnpm dlx` toolchain (openapi-typescript needs TypeScript 5's classic
compiler API; this repo pins TypeScript 7.0.2 per VD-01, whose root
export has none), and adds an offline drift gate that checks the
vendored yaml/generated types/canonical-source.json against each
other without touching the sibling design-package repo or the network.

Regenerating from canonical surfaces real, new required fields on
existing schemas (WorkingCopyDetail.nextAction, PreviewDetail/PublicPreview
.dependencyRevision, StudioDashboard.totals.needsValidation,
PublicationSnapshot.contentFormatVersion/rendererContractVersion) and a
new required EvidenceFigureBlock.asset. The mock gateway and fixtures
are updated to satisfy the former; the latter exposes a real authoring-
vs-rendering conflation in the content-format parser (it declared its
output as the server's fully-resolved PublicRenderModel type, which it
has no asset catalog to satisfy). Split that boundary: the parser now
produces an authoring block type omitting the resolved asset, and each
of its three consumers (the mock gateway, the Studio instant preview,
and the static Case demo page) attaches the resolved descriptor from
its own asset source through a shared, pure domain-level resolver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 00:27:59 +09:00
DongHyeonkaandClaude Opus 5 ac555a85e8 docs: generate the TechLog contract outside the repo toolchain
openapi-typescript needs the TypeScript 5 classic compiler API; this repo
pins typescript@7.0.2 (VD-01), whose root export has no compiler API.
Run the generator in an isolated pnpm dlx environment instead, so the
lockfile and peer contract are untouched.

Also make check:tech-log-contract work without the canonical repo or the
generator — it read an absolute path to a sibling repo that CI never has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 00:03:29 +09:00
DongHyeonkaandClaude Opus 5 9f0599a389 docs: correct the alignment plan against platform transport constraints
Pre-flight scan found five defects that would have failed at composition:

- authProfileId TECH_LOG_STUDIO_SESSION was never registered
- deleteStudioAsset declared responseByteLimit 0 (platform requires >= 1)
- gateways passed CSRF/idempotency through operation input, but contract
  projection has no header channel; both must use the platform seams
- the browser mutation intent factory generates its own key, discarding the
  caller-supplied one the port contract depends on
- instant preview was never wired to the loaded asset list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:52:47 +09:00
DongHyeonkaandClaude Opus 5 78766accdf docs: plan the TechLog backend alignment implementation
12개 Task로 분해한다. 계약 생성·digest 고정(1), 오류/CSRF(2), 계약 기여(3),
StudioGateway HTTP(4), 런타임 스위치(5), Asset 포트(6), multipart 전송과
배선(7), alt 규칙 이동(8), evidence resolver(9), Picker(10), Library(11),
전체 게이트(12).

자체 검토에서 세 결함을 고쳤다.
- Asset gateway가 feature input에 배선되지 않아 UI가 도달할 수 없었다.
- Backend assetKey를 해석할 resolver가 없어 삽입한 directive가 즉시
  미리보기를 깨뜨렸다. 렌더러의 기존 주입점을 쓰는 Task를 추가했다.
- dialog/library 단계에 코드가 없었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:44:25 +09:00
DongHyeonkaandClaude Opus 5 12329ec9c0 docs: define TechLog backend alignment
Studio 계약을 tech-log-design-package의 canonical studio-v1.yaml 단일
출처로 정합시키고, Asset capability를 편집 흐름에 연결하는 설계를 확정한다.

- 계약 drift는 EXTERNAL_PACKAGE provenance의 digest 고정으로 막는다.
- Studio 전송은 플랫폼 계약 런타임을 통과한다. multipart 업로드 1개만
  전용 seam으로 분리한다 — 런타임이 JSON 본문만 표현할 수 있기 때문이다.
- CSRF는 전송 관심사로 어댑터 내부에 둔다. Studio 인증 UI는 추가하지 않는다.
- Public 조회의 HTTP 전환은 별도 사이클로 분리한다. 동기 포트를 async로
  바꾸는 작업이 19개 파일 31개 호출 지점과 이식 parity 기준선을 흔든다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:24:29 +09:00
DongHyeonkaandClaude Opus 5 9e5fbd1384 refactor: derive TechLog navigation from the route contract
Both headers carried their own literal list of {label, path}. That made the
route contract and the header two sources for the same three facts — which
routes are navigable, what they are called, and in what order — with nothing
keeping them in step: a renamed route or a reordered menu could be right in one
place and stale in the other.

techLogNavigation(layoutGroup) derives the menu from TECH_LOG_ROUTE_REGISTRY,
where navigationOrder is what makes a route navigable. Output is byte-identical
to the previous literal lists, pinned by a new test.

Derived from TechLog's own route contract rather than from the composed
registries: .dependency-cruiser.json freezes an exact allowlist of files that
may read src/features/installed-*, explicitly so that coupling cannot spread,
and a feature header is not on it.

The studio header keeps its active-state rules — "작업본" stays highlighted
across /studio/documents/* except on the new-document screen — because that is
presentation behaviour the contract has no opinion about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:34:36 +09:00
DongHyeonkaandClaude Opus 5 93ce86eef4 test: decouple the feature-switch gates from the template's demo screens
The template's demonstration screens exist to explain the template. A product
replaces them with its domain, so deleting them is the expected end state — but
two gates were coupled to them, and the merge accommodated that coupling instead
of fixing it.

product-features.test.ts derives its own scope now: a registry must gate on the
manifest when it imports a module belonging to a manifest-declared feature. The
previous fix put installed-feature-runtimes.tsx in the exempt list, which
silenced the guard for that file permanently. Verified by removing the manifest
reference from installed-feature-adapters.ts and watching the guard fail; a
counter asserts the sweep still watches at least one file.

product-feature-switch.test.tsx exercises the kill switch end to end again. The
mechanism is the ownership lookup plus isFeatureActive, which has nothing to do
with which screens ship, so the ownership map is the fixture: one real
registered route attributed to a real installed feature, with the product's own
router, components and codecs.

That restoration exposed a real gap. The navigation-withdrawal half is not
implemented here: it lives in the template's PrimaryNavigation and this product
does not render the template's AppShell at all — the public header is a
hand-written list of paths. A disabled feature's route is refused by the router
but its link would still be advertised. Harmless only while no feature-owned
route is navigable, which is now asserted so the gap cannot ship silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:08:42 +09:00
DongHyeonkaandClaude Opus 5 cd1ef5cda2 chore: ignore in-repository git worktrees
.worktrees/ showed as untracked in main's status, so a worktree checkout could
be committed into the repository by an ordinary 'git add -A'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:38:27 +09:00
DongHyeonkaandClaude Opus 5 7d1ccccbcd Merge branch 'main' into feature/techlog-ui-migration
Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI
migration. Merged in this direction so every conflict is resolved and proved in
the worktree; main is only fast-forwarded afterwards and never holds a state
that was not verified here.

15 conflicts. The rule throughout: keep the template's mechanism, keep the
product's content, and never invent a third state neither branch would accept.

The template's product manifest and its runtime feature kill switch are adopted.
The route registry is deliberately not composed from contract.routes: the
reference feature still declares screens this product deleted, and reducing over
them would register paths with no component behind them. ROUTE_FEATURE_OWNER is
narrowed to registered routes for the same reason. The first resolution did
compose from contract.routes and was rejected by product-features.test.ts.

Three files pinned counts and a digest describing the gate contract. Neither
side's numbers describe the merged config/ci/gates.json, so they were recomputed
from it rather than chosen: 27 gates, 82 commands, 94 command references, 107
evidence references, 128 artifacts, shape sha256 5063586d.

README.md and docs/accessibility/manual-checklist.md now enumerate this
product's 27 routes, which the template's own verify:documentation requires.

product-feature-switch.test.tsx was rewritten around the invariant that still
applies here — no registered route without a component — rather than deleted
with the screens it used to exercise.

docs/operations/template-merge-2026-08-17.md records every decision, the gate
results, and the three follow-ups this merge deliberately did not decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:38:08 +09:00
DongHyeonka 0355b644a0 feat: add TechLog project decision authoring 2026-08-17 17:23:42 +09:00
DongHyeonka 79e9aa8328 fix: align TechLog article content widths 2026-08-17 16:49:20 +09:00
DongHyeonka 72669949bc docs: define TechLog content width alignment 2026-08-17 16:34:45 +09:00
DongHyeonka 47cbf4ddca docs: record TechLog migration evidence 2026-08-16 05:07:58 +09:00
DongHyeonka 3a7c5deca0 fix: complete TechLog migration evidence 2026-08-16 04:55:52 +09:00
DongHyeonka 6c2780b7a7 test: prove TechLog UI migration parity 2026-08-16 02:48:26 +09:00
DongHyeonka c5c8b9423c feat: complete TechLog Studio publication flow 2026-08-16 00:35:11 +09:00
DongHyeonka 9c6906fc6f feat: port TechLog Studio validation workflow 2026-08-15 23:59:08 +09:00
DongHyeonka 5933265975 feat: port TechLog Studio editors 2026-08-15 23:41:34 +09:00
DongHyeonka 887f5e6eb1 feat: port TechLog Studio shell and indexes 2026-08-15 23:29:32 +09:00
DongHyeonka 2b6fa42620 feat: complete TechLog public screens 2026-08-15 23:19:04 +09:00
DongHyeonka 4283e40bb2 feat: port TechLog document screens 2026-08-15 23:06:57 +09:00
DongHyeonka 512aa4a1e9 fix: synchronize TechLog focus and not-found runtime 2026-08-15 22:52:42 +09:00
DongHyeonka ef1d5cc548 feat: port TechLog discovery screens 2026-08-15 22:29:57 +09:00
DongHyeonka c9164c1a03 test: assert TechLog search focus style 2026-08-15 21:54:09 +09:00
DongHyeonka 3bc74e0195 test: strengthen TechLog shell contracts 2026-08-15 21:42:49 +09:00
DongHyeonkaandClaude Opus 5 bdee07a93b chore: sync the frontend template from a0fbafb to 5434760
Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.

Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.

What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.

Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:19 +09:00
DongHyeonka 627df884dd feat: port TechLog shells and styles 2026-08-15 21:20:47 +09:00
DongHyeonka 01316763e3 fix: inject grouped route codecs 2026-08-15 20:51:22 +09:00
DongHyeonka adb8613cb9 feat: add grouped TechLog route contracts 2026-08-15 20:30:32 +09:00
DongHyeonka 27dda3e0e3 feat: compose TechLog static and mock adapters 2026-08-15 19:36:46 +09:00
DongHyeonka 708680b28e fix: reject TechLog protocol-relative links 2026-08-15 19:10:05 +09:00
DongHyeonka 8342fb14dc fix: reject unsafe TechLog network paths 2026-08-15 19:00:10 +09:00
DongHyeonka 16753f53af feat: port TechLog content format and renderer 2026-08-15 18:44:09 +09:00
DongHyeonka 82f94423e5 test: enforce exact TechLog contract keys 2026-08-15 18:16:21 +09:00
DongHyeonka 01ed1e9300 feat: add TechLog feature contracts 2026-08-15 18:07:12 +09:00
DongHyeonka 5479101c8c fix: exercise production evidence asset lookup 2026-08-15 17:55:37 +09:00
DongHyeonka 034b702e8e chore: establish TechLog migration baseline 2026-08-15 17:49:40 +09:00
DongHyeonka 05e3d50ba0 chore: prepare isolated migration worktree 2026-08-15 17:35:21 +09:00
DongHyeonka 954ca8a1fd docs: plan TechLog UI migration 2026-08-15 17:09:58 +09:00
DongHyeonka 325a2a0843 docs: define GitFlow delivery for UI migration 2026-08-15 16:46:36 +09:00
DongHyeonka c2d03165b3 docs: define TechLog UI migration design 2026-08-15 16:43:45 +09:00
610 changed files with 97078 additions and 4164 deletions
+35
View File
@@ -156,6 +156,41 @@
"path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)" "path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)"
} }
}, },
{
"name": "contracts-do-not-know-application",
"comment": "§4. `src/contracts` is the lower of the two packages: application reads contracts, never the other way round. Before this rule the shared Result carrier and the compatibility predicate lived in application and were imported back down by contracts, so neither package owned the shared vocabulary and the coupling was invisible to every gate.",
"severity": "error",
"from": {
"path": "^src/contracts"
},
"to": {
"path": "^src/(application|features)"
}
},
{
"name": "generic-presentation-does-not-compose-the-product",
"comment": "§4 / §9. Which features are installed is a product decision that belongs to bootstrap. Generic presentation reads the installed registries directly today; the paths below are the exact set that does so, frozen so the coupling cannot spread while the assembly is lifted into bootstrap.",
"severity": "error",
"from": {
"path": "^src/presentation/",
"pathNot": "^src/presentation/(layouts/app-shell\\.tsx|pages/(not-found-page|home-page)\\.tsx|routes/(route-contract|route-codecs|app-router|navigation-policy)\\.(ts|tsx)|i18n/catalog\\.ts|examples/platform-overview-page\\.tsx)$"
},
"to": {
"path": "^src/features/installed-"
}
},
{
"name": "adapters-do-not-know-other-concrete-adapters",
"comment": "docs/architecture/layers.md §4: a concrete adapter never depends on another concrete adapter. Only the adapter kernel is shared — `src/adapters/platform` (clock, abort primitive, capacity guard) and the browser-data result helpers. `query-cache` still reads two collaborator types from `cross-context-invalidation`; that edge is named here rather than left silent, and closes when those types are lifted to a port.",
"severity": "error",
"from": {
"path": "^src/adapters/([^/]+)/"
},
"to": {
"path": "^src/adapters/([^/]+)/",
"pathNot": "^src/adapters/($1/|platform/|browser-file-storage/result\\.ts$|cross-context-invalidation/index\\.ts$)"
}
},
{ {
"name": "no-circular-dependencies", "name": "no-circular-dependencies",
"severity": "error", "severity": "error",
+25
View File
@@ -0,0 +1,25 @@
# Build-time inputs (§6.1). These are compiled into the bundle by Vite, so
# everything here is public by definition. Never put a secret in this file or in
# any `.env*` file: a frontend has no confidential storage, and a value that
# reaches the browser has been published.
#
# Runtime configuration — API endpoints, auth mode, telemetry, capability
# switches — is NOT here. It lives in `config/runtime/<profile>.json` and is
# materialized into `dist/config.json` at build time, so it can be changed
# without rebuilding. See docs/architecture/layers.md.
#
# Copy to `.env.local` (git-ignored) to override locally.
# Identifies the build in release manifests and the runtime document.
# CI supplies the real value; a developer build falls back to "local-build".
VITE_BUILD_ID=local-build
# Source revision the bundle was produced from.
VITE_COMMIT_SHA=local
# Sub-path the app is served under. Must start and end with "/".
# Feeds the router, the Service Worker scope and Vite's asset base together.
VITE_ROUTER_BASE_PATH=/
# Where the browser fetches the runtime document from at boot.
VITE_RUNTIME_CONFIG_URL=/config.json
+4
View File
@@ -126,6 +126,9 @@ jobs:
outputs: outputs:
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }} dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }} archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
env:
APP_PROFILE: "${{ vars.APP_PROFILE }}"
RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}"
steps: steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
@@ -187,6 +190,7 @@ jobs:
scripts/lib/secret-scan.ts \ scripts/lib/secret-scan.ts \
scripts/lib/supply-chain.ts \ scripts/lib/supply-chain.ts \
scripts/lib/validated-json-artifact.ts \ scripts/lib/validated-json-artifact.ts \
scripts/lib/vite-route-chunks.ts \
src/contracts/release-artifacts.ts \ src/contracts/release-artifacts.ts \
src/features/installed-contract-contributions.ts \ src/features/installed-contract-contributions.ts \
src/features/installed-feature-contracts.ts \ src/features/installed-feature-contracts.ts \
+12
View File
@@ -6,6 +6,7 @@ dist/
playwright-report/ playwright-report/
test-results/ test-results/
coverage/ coverage/
.worktrees/
!tests/fixtures/coverage/ !tests/fixtures/coverage/
!tests/fixtures/coverage/below-threshold.json !tests/fixtures/coverage/below-threshold.json
artifacts/**/*.json artifacts/**/*.json
@@ -18,3 +19,14 @@ artifacts/storybook/
artifacts/tests/storybook/ artifacts/tests/storybook/
artifacts/tests/visual/ artifacts/tests/visual/
!artifacts/**/.gitkeep !artifacts/**/.gitkeep
# Local environment overrides. `.env.example` is the tracked template; every
# other `.env*` file is a developer's own machine and never enters the repo.
.env
.env.*
!.env.example
# Git worktrees created inside the repository. A worktree is a checkout, not
# source: committing one would nest a second working copy inside this one.
.worktrees/
.playwright-mcp/
+15
View File
@@ -10,6 +10,11 @@ import { ApplicationProvider } from "../src/presentation/providers/application-p
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx"; import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx"; import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
import "../src/presentation/styles/theme.css"; import "../src/presentation/styles/theme.css";
import { resolveProductFeatures } from "../src/contracts/product-features.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../src/features/installed-product-manifest.ts";
const preferences = new Map<string, unknown>(); const preferences = new Map<string, unknown>();
const application = createApplication({ const application = createApplication({
@@ -45,6 +50,16 @@ const application = createApplication({
routeChunks: {}, routeChunks: {},
}), }),
}, },
// Storybook renders components, not a product: every declared feature is
// shown as active so a story is never blank because of a deployment switch.
productFeatures: {
getSnapshot: () =>
resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
),
isActive: () => true,
},
runtimeCapabilities: { runtimeCapabilities: {
getSnapshot: () => getSnapshot: () =>
Object.freeze( Object.freeze(
@@ -0,0 +1,30 @@
# Task 10 report
## Mapping
- Source Studio provider/runtime shell and header → application-input-created, provider-scoped gateway; React Router navigation; native Public link; persisted `pageshow` generation reset.
- Source dashboard → exact workspace heading, totals, workflow sections, row labels, links, loading and error copy.
- Source document list → exact search/filter/list/empty surfaces plus cursor pagination, retry, and abort of obsolete requests.
- Source new-document form → exact type cards/copy, session gateway creation, announcement, and editor redirect.
- Source Studio not-found → in-shell 404 surface; Studio routes remain public with no auth UI.
## TDD evidence
- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-shell-smoke.test.tsx tests/features/tech-log/studio-screens-smoke.test.tsx` failed both suites at missing Studio presentation imports (exit 1).
- GREEN: the same command passed 2 files / 8 tests.
- Focused regression: both Studio suites plus `tests/features/tech-log/runtime-composition.test.ts` passed 3 files / 10 tests.
## Files
- Added the 12 Task 10 Studio provider/runtime/shell/component/page files under `src/features/tech-log/presentation/studio/`.
- Added `studio-shell-smoke.test.tsx` and `studio-screens-smoke.test.tsx`.
## SHA
- Base: `2b6fa42620136c3edb1506f907ce79c2251d1316`.
- Implementation: the commit containing this report, titled `feat: port TechLog Studio shell and indexes` (final SHA recorded in the Task 10 handoff).
## Deferred
- Task 11 editor screens and Task 12 dirty-leave/save/validation dialogs remain intentionally deferred.
- Broad architecture, type, lint, build, security, and visual gates remain deferred to Task 14 by user direction.
@@ -0,0 +1,33 @@
# Task 11 report
## Mapping
- Source common, Case, Reference, and Question fields → exact target labels, controls, order, loaded values, conditional resolution fields, and CSS classes.
- Source ordered text/rule/option/relation editors → presentation-owned add, remove, reorder, local IDs, limits, and accessibility names.
- Source document editor/status rail → source tabs, keyboard focus, working-copy status, dirty indicator, version/kind rail, and deferred workflow controls.
- Source instant preview → Content Format v1 `projectWorkingCopy` plus the shared `PublicRecordRenderer`; no gateway preview mutation or parser/renderer duplication.
- Source edit page → registered route input adaptation for the document ID.
- Task 10 provider seam → smallest generic provider-owned editor session (`saved`, `draft`, `status`) retained across editor tabs.
## TDD evidence
- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-editor-smoke.test.tsx` failed at the exact missing `document-editor-screen.tsx` import before test collection.
- First GREEN: the same focused command passed 1 file / 5 tests.
- Focused regression: editor smoke plus `content-format.test.ts` and `public-render.test.tsx` passed 3 files / 41 tests.
- Scope check: `git diff --check` passed.
## Files
- Added the 12 Task 11 component/page files under `src/features/tech-log/presentation/studio/`.
- Extended `studio-provider.tsx` and `use-studio.ts` only with presentation-owned editor session state.
- Added `tests/features/tech-log/studio-editor-smoke.test.tsx`.
## SHA
- Base: `887f5e6eb1a5ccdf4feab0b27fae1c5823233190`.
- Implementation: the commit containing this report, titled `feat: port TechLog Studio editors` (final SHA recorded in the Task 11 handoff).
## Deferred
- Save/conflict resolution, guarded navigation, validation, server preview, publish, and unpublish workflows remain deferred to Tasks 12/13. The source Save control is present but disabled until Task 12 supplies the workflow.
- Broad app/test types, lint, build, architecture, security, visual, and full-suite gates remain deferred to integrated Task 14 by user direction.
@@ -0,0 +1,33 @@
# Task 12 report
## Mapping
- Source editor save workflow → pending/success announcements, fresh per-command idempotency keys, retry after request failure, and revision-conflict state without replacing the local draft.
- Source guarded Studio links/provider/dialog → all internal Studio anchors use the guarded `<a>` DOM; dirty navigation offers stay, discard, and save-then-navigate with modal focus/trigger restoration and native `beforeunload` protection.
- Source validation report/screen → saved-version validation gates, current/stale freshness copy, error-before-warning issue order, exact JSON-pointer editor anchors, retry/not-found surfaces, and abortable route reads.
- Source Public Preview screen → missing/current/stale/expired states, exact next-action labels, idempotent preview creation, retry/not-found surfaces, and the shared typed `PublicRecordRenderer`.
- Route pages → existing route-input codecs supply the validation/preview document ID; no publish/history/snapshot behavior was pulled forward.
- Task 10/11 seam → provider gained only dirty-navigation/time state, editor gained the save callback, and existing Studio links were switched to the newly available source guarded-link component.
## TDD evidence
- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-save-navigation.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx` exited 1 before collection at the intentionally missing `guarded-studio-link.tsx` and `public-preview-screen.tsx` imports (2 failed files, 0 tests).
- GREEN: the two workflow suites plus `tests/features/tech-log/mock-studio-gateway.test.ts` passed 3 files / 22 tests.
- Focused seam regression: those three files plus the existing Studio shell, screen, and editor suites passed 6 files / 35 tests.
- Scope check: `git diff --check` passed.
## Files
- Added guarded link, unsaved dialog, beforeunload hook, validation report/screen, Public Preview screen, and validation/preview route pages under `src/features/tech-log/presentation/studio/`.
- Extended the Task 10 provider/context and Task 11 editor/status rail; updated existing Studio internal link consumers to use the source guard.
- Added `studio-save-navigation.test.tsx` and `studio-validation-preview.test.tsx`.
## SHA
- Base: `59332659752ee17c095471d06e7f0fc8b00c89b4`.
- Implementation: the commit containing this report, titled `feat: port TechLog Studio validation workflow` (final SHA recorded in the Task 12 handoff).
## Deferred
- Publish, republish, unpublish, publication history, warning acknowledgement, and immutable publication snapshots remain deferred to Task 13.
- Broad browser-security, app/test types, lint, build, architecture, visual, and full-suite gates remain deferred to integrated Task 14 by user direction.
@@ -0,0 +1,47 @@
# Task 13 report: publication flow and atomic install
## Status
`DONE_WITH_CONCERNS`
Base SHA: `9c6906fc6f76115346360d050dd8c211fee1b5a9`
Delivery commit: the commit containing this report, with subject `feat: complete TechLog Studio publication flow`.
## Publication mapping
- Added the source-faithful publish screen, warning acknowledgements, publication history/filter, unpublish dialog, immutable event snapshot preview, and their three route pages.
- Publish blocks invalid or stale validation, requires every warning acknowledgement, creates a fresh idempotency key per command/retry, preserves gateway command ordering, and exposes pending/error/retry states.
- Unpublish preserves the reason/confirmation contract. Historical preview reads the event-owned immutable snapshot and renders it through the shared `PublicRecordRenderer`; a missing/unknown event stays inside Studio.
- Added `tests/features/tech-log/studio-publication-flow.test.tsx` first. The initial red was the exact missing `publication-list.tsx` module/screen; the implemented suite is green at 7 tests.
## Atomic install and removals
- Installed exactly the 27 governed TechLog route definitions, codecs, runtime imports, module identities, message catalogs, schemas, and release-manifest chunk IDs. `PublicShell` and `StudioShell` are the grouped layout elements.
- Added governed Vite chunk naming for the 27 route module identities and changed the performance probe to `TECH_LOG_HOME`.
- Kept the reference contract/adapters and API/schema/invalidation/platform fixture tests, while removing its presentation runtime/pages and page-level tests.
- Removed the four sample presentation pages, starter home/not-found pages, their page-level component/E2E screens, and all four `/examples/*` E2E specs authorized by the brief.
- Added `tests/e2e/tech-log-studio-workflow.spec.ts`; it was deliberately not run and is deferred to Task 14.
- Updated the removal fixture to retain TechLog after reference removal and to exclude tests whose only contract is the removed reference runtime or the canonical (non-reduced) CI authority.
- Split `DocumentEditorController` into a type-only module to remove the editor/status-rail cycle exposed by the installed route graph.
## Verification evidence
- Publication + validation-preview + mock gateway: PASS, 3 files / 23 tests.
- Router + runtime application + retained reference contract: PASS, 3 files / 16 tests.
- Final route contract + navigation policy: PASS, 2 files / 10 tests.
- Registry structure: PASS, 11 registries.
- Release manifest inventory: PASS, exactly 27 derived chunk IDs.
- `git diff --check`: PASS.
- `test:sample-removal` was run once. Its isolated home smoke passed 9/9 and registry/CI reduced-contract checks passed, but its internally broad type/architecture/unit/coverage/build loop failed. Task-owned findings were fixed afterward: ES-target-incompatible `toSorted`, stale `APP_HOME`, direct adapter import, editor/status-rail cycle, reference-dependent fixture residue, canonical-CI-only tests in a reduced fixture, and missing governed build chunk names. Per fast-mode direction, that several-minute broad loop was not rerun; Task 14 must confirm the fixes through its integrated gates.
## Files
- Publication/UI/runtime: `src/features/tech-log/presentation/**`, including the five publication components, three pages, route runtime, and controller boundary.
- Contracts/install: `src/contracts/{routes,route-runtime-contract}.ts`, `src/features/installed-feature-*.{ts,tsx}`, TechLog route contract, platform codecs/runtime, router, layout reference, registry governance, Vite config, release manifest, performance/removal scripts.
- Tests: new publication flow and Studio workflow specs; updated route/router/runtime/reference/navigation expectations; authorized sample/reference presentation test deletions.
## Task 14 deferred concerns
- Run the complete integrated review/gates, including the sample-removal loop with the post-fix code, production build/manifest verification, types, lint, architecture, security, and Playwright workflow.
- Confirm the governed Vite chunk names in the generated production manifest and assess any unrelated environment/timing failures from the broad isolated fixture.
@@ -0,0 +1,310 @@
# Task 14 report: integrated TechLog parity and release verification
## Status
`DONE_WITH_CONCERNS`. The production serving correction, 130-case
source-to-target comparison, full recursive product-tree evidence, direct HTTP
contract, target visuals, focused browser suites, and static gates pass. The
automated Chromium accessibility suite passes 29/29, but the 27 signed human
keyboard/focus/screen-reader records remain `PENDING`; `FE-GATE-009` is not
claimed as passing. The other concern is the repository's pre-existing
restricted-runner `test:all` baseline: 19 provider-environment cases remain
red. The isolation and exact counts below prove that no TechLog test is among
those failures.
The work remains on `feature/techlog-ui-migration`. It was not merged, pushed,
finished with GitFlow, or deleted. The immutable code candidate is
`3a7c5deca06679fb9b8710da2cce87bbca07ce8a`
(`fix: complete TechLog migration evidence`). This report and the durable
parity JSON are deliberately recorded afterward in
`docs: record TechLog migration evidence`, so the evidence can name the exact
candidate it verifies.
## Evidence files
Added or replaced product evidence:
- `tests/visual/tech-log.visual.spec.ts` and 129 target-only PNG snapshots. The
suite has 130 cases because canonical and state coverage for the 1440-pixel
`/studio/publications` screen deliberately share the same reviewed image.
- `scripts/lib/tech-log-production-server.ts`, the generated self-contained
`dist/server.mjs` build artifact, its serving contract/generator, and 39-case
direct HTTP regression coverage.
- `tests/e2e/tech-log-public-discovery.spec.ts`,
`tests/e2e/tech-log-accessibility.spec.ts`, and
`tests/e2e/tech-log-responsive.spec.ts`.
- `tests/support/browser/tech-log-fixtures.ts`, the checked Node 24 parity
runner, and durable
`docs/operations/evidence/tech-log-source-parity.json` evidence.
- Focused regressions in `tests/unit/vite-route-chunks.test.ts`,
`tests/unit/design-system-source.test.ts`, the bounded-body reader tests, the
router component suite, and TechLog feature suites.
- Governed registry, dependency, release, CI, and operations evidence in
`config/contracts`, `config/security`, `config/ci`, the generated Gitea
workflow, `README.md`, and
`docs/operations/techlog-ui-migration-baseline.md`.
Removed starter-only evidence:
- `tests/e2e/compact-smoke.spec.ts`,
`tests/e2e/design-system-interactions.spec.ts`, `tests/e2e/i18n.spec.ts`, and
`tests/e2e/theme.spec.ts`.
- `tests/visual/platform.visual.spec.ts` and all five platform visual PNGs.
The retained `app-shell`, registry-wide accessibility, and responsive suites
were rewritten around TechLog. No stale starter browser or visual snapshot is
left referenced.
## Source-to-target visual method and result
The supplied source at `/home/donghyeon/workspace/techlog-studio-frontend` was
never written. It was copied to `/tmp/techlog-source-parity.I0CBK7`; build and
Vinext runtime caches were created only in that temporary copy. The source
production server on `4375` and target `dist/server.mjs` production artifact on
`4174` were opened by one Playwright Chromium instance with two fresh contexts
and the following identical controls:
- device scale factor 1, light color scheme, `ko-KR`, `Asia/Seoul`, reduced
motion, service workers blocked, 1000-pixel viewport height, and full-page
screenshots;
- fixed clock `2026-08-14T01:00:00.000Z`, deterministic in-memory data,
`document.fonts.ready`, matching Pretendard/IBM Plex Mono font-face state,
and zero-duration animation, transition, and caret styles;
- no masks and no tolerance: exact RGBA pixel comparison, normalized recursive
product-subtree tags, ordered child nodes, complete classes, attributes,
text and ARIA relationships, layout diagnostics, response metadata, boot
lifecycle, and console/page/request failure collection.
The only attributes normalized by name are diagnostics-confirmed framework
outputs: React Router `data-discover`; Next Image `data-nimg`, `decoding`, and
`srcset`; and Next SSR `selected` for a controlled select. Generated React IDs
and CSS-module hashes are normalized by value; there is no broad attribute
omission.
The final external comparison command was:
```bash
TZ=Asia/Seoul corepack pnpm verify:tech-log-source-parity
```
Result: **130/130 passed**, 0 failed, `totalDifferentPixels=0`, every recursive
DOM/class/attribute/text/ARIA tree equal, all HTTP metadata equal, and 0
unexplained source/target errors. Source
screenshots were temporary comparison inputs; none was copied into target
snapshots. The no-update target visual run also passed 130/130 with
`maxDiffPixels=0` and `maxDiffPixelRatio=0`.
The durable evidence identifies source-tree digest
`6724c2f898eefc62d2fc0ee695bccc3ae61a69c5153ed43c69f2cf99ee45bca5`,
candidate `3a7c5deca06679fb9b8710da2cce87bbca07ce8a`, build-manifest digest
`3579650faa482f566553c00d8b4a05a05b4f7a1ab93b83e48676289bbcf02984`,
Vite-manifest digest
`cfd583ed7b6c27dce7f47389447608636b6b9df3e28df00c6416674da2c7c46d`,
case-inventory digest
`5689bcdb5d5637205cdeb92b7c57f72d99f0b039818b8f90afdde526bbafe0ac`,
and evidence-payload digest
`f046047beded19ce468be607dcf99c6b74d4e07323457ec7145c56d2f67d2c79`.
The comparison found and corrected actual integration defects rather than
accepting drift: the TechLog Tailwind bootstrap is loaded exactly once in the
same cascade order as source; the starter theme import is removed; CSS-module
class mapping, shell navigation/focus, publication labels and states, router
404 handling, and generated Vite route-chunk lookup now follow source. All five
source/target CSS pairs pass `cmp -s`; their hashes are recorded in the
operations baseline.
Source production returns missing dynamic slugs and an unmatched Public path as
HTTP 404, `text/plain;charset=UTF-8`, with the exact nine-byte body `Not Found`.
The target production boundary now returns that exact shell-free response;
known Public paths and all known Studio paths remain SPA-served, while an
unknown Studio path preserves the source's HTML Studio shell with HTTP 404.
This is an observed source-production contract and satisfies the planned
prohibition on a generic runtime error; it is not a redesign.
## Route, viewport, and state inventory
The 27 canonical contract routes were each compared at 360 and 1440 pixels:
- Public: `/`, `/explore`, `/explore/:kind`, `/cases/:slug`,
`/references/:slug`, `/questions/:slug`, `/topics/:slug`, `/projects`,
`/projects/:slug`, the `records`, `decisions`, and `activity` project views,
`/releases`, `/releases/:version`, `/profile`, `/search`, and `*`.
- Studio: `/studio`, `/studio/documents`, `/studio/documents/new`, the `edit`,
`validation`, `preview`, and `publish` document views,
`/studio/publications`, publication-event preview, and `/studio/*`.
All known Public fixtures were exercised: two cases, two references, two open
questions, three topics, both projects and all three nested views, and release
`0.1.0`. Ten unknown Public dynamic shapes were also compared at both widths.
The 130-case matrix is 54 canonical-route captures, 18 additional known Public
fixture captures, 12 home breakpoints (1180, 1179, 1050, 1024, 980, 900, 820,
768, 767, 420, 390, and 375), 19 Studio states, 20 unknown-Public captures, and
7 interactions. Studio states cover the
dashboard, list/new, Case/Reference/Question/conflict editors, valid/invalid
validation, current/missing/expired previews, ready/blocked publish,
publications/snapshot, missing document/publication, and unknown Studio route.
Interactions cover Public search, Studio mobile menu, immediate preview,
dirty-leave dialog, newly created current preview, unpublish confirmation, and
warning acknowledgement through publish-ready state.
## Browser, responsive, and accessibility outcomes
- Required four-spec Chromium command: **48/48 passed**. It covers Public
discovery, the full Studio workflow, responsive behavior, and accessibility.
- Responsive plus accessibility focused command: **26/26 passed**.
- Direct production HTTP contract: **39/39 passed**, including exact raw Public
404s, the in-shell Studio 404, known SPA routes, and boot documents.
- Exact target visual command: **130/130 passed** in 2.5 minutes with no masks
and zero pixel tolerance.
- Automated Chromium `@a11y`: **29/29 passed**. The 27 human review records are
intentionally pending, so `corepack pnpm review:a11y-manual` exits 1 and
lists missing status, candidate release ID, reviewer/signature/attestation,
reviewed time, M1-M7, and screen-reader evidence for every route.
- Keyboard/focus checks cover Public search dismissal/restoration, Studio mobile
navigation, dirty-leave and unpublish dialogs, labels, heading/landmark order,
and focus-visible behavior. Axe reports no violations in the required route
and state inventory. Overflow assertions pass at the compact and transition
widths, and no unexpected console, page, or request error remains.
Commands:
```bash
corepack pnpm exec playwright test tests/e2e/tech-log-public-discovery.spec.ts tests/e2e/tech-log-studio-workflow.spec.ts tests/e2e/tech-log-responsive.spec.ts tests/e2e/tech-log-accessibility.spec.ts --project=chromium
corepack pnpm exec playwright test tests/e2e/tech-log-responsive.spec.ts tests/e2e/tech-log-accessibility.spec.ts --project=chromium
corepack pnpm exec playwright test tests/e2e/tech-log-http-contract.spec.ts --project=chromium
corepack pnpm exec playwright test tests/e2e/accessibility.spec.ts --project=chromium --grep @a11y
corepack pnpm exec playwright test tests/visual/tech-log.visual.spec.ts --config=playwright.visual.config.ts --project=chromium
corepack pnpm test:visual
```
## Registry and supply-chain governance
The initial no-baseline registry artifact reported 23 migration-owned breaking
IDs. Each now has owner `tech-log-frontend`, a TechLog contract-version reason,
atomic route/runtime/manifest installation, same-release compatibility, and
rollback to `05e3d50ba01f01c27f257d2e9040c2bc413ea053`:
- Contract: `$contract:{allowedValues,breakingFields,fieldTypes,requiredFields}`.
- Removed route rows: `APP_HOME`, `EXAMPLES_AUTH`, `EXAMPLES_PLATFORM`,
`EXAMPLES_STATES`, `EXAMPLES_UI`, `REFERENCE_RESOURCE_DETAIL`,
`REFERENCE_RESOURCE_FORM`, `REFERENCE_RESOURCE_LIST`, and
`REFERENCE_RESOURCE_STATUS`.
- Runtime removals: the same nine route IDs.
- Runtime change: `NOT_FOUND:moduleId:field-changed`.
The governed update used exactly:
```bash
REGISTRY_BASELINE_OWNER=tech-log-frontend REGISTRY_BASELINE_REASON="Install approved TechLog Public and Studio route contract" node scripts/update-registry-baseline.ts artifacts/quality/registries.json
corepack pnpm check:registries
```
Final result: 11 registries pass, compatibility `none`, no unacknowledged
change. Approved snapshot digest:
`428479ac5845374a82dd7d02a0c59a713106405f031cdc153789174f14c1405b`.
Six direct dependency additions have evidence owner `tech-log-frontend`,
reviewer `frontend-platform-security`, product-specific reason, and atomic
rollback: `@fontsource/ibm-plex-mono@5.3.0`, `pretendard@1.3.9`,
`remark-directive@4.0.0`, `remark-gfm@4.0.1`, `remark-parse@11.0.0`, and
`unified@11.0.5`. The dependency policy recognizes the font packages' OFL-1.1
license. Supply-chain generation covered 641 packages. The pre-existing
dependency baseline was not promoted; the denied promotion was unnecessary for
the regular verification path, which passes with the committed evidence.
## Sample removal and fresh verification
The final staged-candidate command passed:
```bash
corepack pnpm test:sample-removal
```
Result: **PASS (13 checks, no fixture IDs)**. Its internal evidence included
types; reduced architecture (382 modules/1,170 dependencies, 12 graph checks,
9 forbidden fixtures); 11 registry checks; runtime schema 3 files/40 tests;
unit 118/1,250; component 18/124; integration 8/74; recipes 2/17; coverage at
77.40% statements, 73.26% branches, 83.88% functions, and 80.02% lines; risk
coverage 381/381 with 76 thresholds; source evidence 202 files/129 baselines;
artifact/CI checks; router smoke 9/9; and production build.
Fresh completion commands and results:
| Command | Result |
| --- | --- |
| `corepack pnpm exec vitest run tests/features/tech-log` | 21 files, 170 tests passed |
| required four-spec Chromium command above | 48 tests passed |
| `corepack pnpm exec playwright test tests/e2e/tech-log-http-contract.spec.ts --project=chromium` | 39 tests passed |
| Chromium `@a11y` command above | 29 tests passed |
| `corepack pnpm test:visual` | 130 tests passed |
| `corepack pnpm check:types` | app/node/test/recipes/web-worker/service-worker passed |
| `corepack pnpm lint` | passed with 0 warnings |
| `corepack pnpm check:architecture` | 391 modules, 1,212 dependencies, 12 graph checks, 9 forbidden fixtures passed |
| `corepack pnpm check:design-system` | 48 tokens and vendor boundaries passed |
| `corepack pnpm check:i18n` | 194 keys across 4 locales passed |
| `corepack pnpm check:registries` | 11 registries passed; compatibility `none` |
| `corepack pnpm check:browser-security` | injection rejected; Public source maps absent |
| `corepack pnpm build` | 2,351 modules transformed; build and manifest completed |
| `git diff --check` | passed |
The fresh staged-candidate `corepack pnpm test:all` passed runtime schema 3/40,
then its unit phase passed 122 files/1,773 tests and failed 19 tests in only
`ci-artifact-contract`. The failures are the documented provider/cgroup,
RLIMIT/EMFILE, restrictive-umask, `/tmp`, timing, and identity environment
cases; no TechLog test failed. A pre-staging run had also exposed 39
release-inventory `APP_HOME` failures because the new serving files were not
yet visible to `git ls-files`; staging the complete candidate corrected that
test precondition, and all 39 disappeared. Its one aggregate guardian timeout
passed 1/1 in isolation and 21/21 in the fresh staged aggregate.
The earlier exact baseline-isolation command:
```bash
corepack pnpm exec vitest run tests/unit/ci-artifact-contract.test.ts tests/unit/ci-workflow-generation.test.ts tests/unit/http-scenario-evidence.test.ts
```
ran 3 files/529 tests: 510 passed; all 407 CI-workflow and all 14 HTTP-scenario
tests passed, leaving the same 19 environment-only CI-artifact cases. Direct
runs of the aggregate's remaining phases passed: component 18/124, integration
11/82 under the required child-process scope, reference feature 4/13, and
recipes 2/17. This environment-only baseline is also recorded in the
operations baseline.
## Fixes, branch audit, and handoff
Root-cause-driven fixes added regressions for generated Vite manifest chunk
resolution, source-compatible raw 404 responses, palette-source detection, and
abort rejection. Presentation integration corrections preserve source DOM,
ARIA, copy, assets, CSS, workflow state, and focus behavior; no design was
introduced. The branch-wide audit of
`05e3d50ba01f01c27f257d2e9040c2bc413ea053..HEAD` found no migrated
presentation import of adapters, Next.js, Vinext, or Cloudflare. The 27 route
chunks are present in the release manifest and derive from actual Vite output,
not hard-coded generated filenames.
The immutable code candidate
`3a7c5deca06679fb9b8710da2cce87bbca07ce8a` contains the production 404
boundary, parity runner, tests, snapshots, and 27 pending human-review records.
Only after that commit existed was the target rebuilt cleanly and the final
130-case parity, visual, HTTP, accessibility, and static verification rerun.
This report and its JSON are committed separately as
`docs: record TechLog migration evidence`; later human accessibility evidence
must cite the candidate SHA, not the evidence-only commit.
The independent `final-review.md` remains the immutable review input with its
historical `CHANGES_REQUESTED` verdict. This candidate addresses its production
404 issue with a real build artifact and 39 direct HTTP tests; expands the
source matrix from 112 to 130 and makes recursive tree equality part of pass;
and replaces the missing `tsx` invocation with a checked Node runner and
durable provenance. Its accessibility inventory issue is structurally fixed
and automated Chromium coverage is green, but the reviewer-dependent 27 human
records deliberately remain pending. No review verdict was rewritten or
self-approved.
Manual completion requires a human to check out candidate
`3a7c5deca06679fb9b8710da2cce87bbca07ce8a`, review every route according to
`docs/accessibility/manual-checklist.md`, fill each record's exact candidate
Release ID, reviewer, signature, attestation, reviewed time, M1-M7, and screen
reader result, commit that evidence separately, then rerun
`corepack pnpm review:a11y-manual`. Until then `FE-GATE-009` remains pending.
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
#
# The frontend deployment artifact. The repository had none — `dist/server.mjs`
# is a preview server that applies neither the security headers nor the cache
# policy `config/hosting/` declares — so a deployment had nothing to run.
#
# Two stages: the build produces `dist/` and, from the serving contract, the
# nginx configuration that matches it; the runtime is nginx with both.
# ---------------------------------------------------------------------------
# build
# ---------------------------------------------------------------------------
# Pinned by digest: the release-provenance gate requires an immutable runner
# identity, and a floating tag cannot give one.
FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
WORKDIR /src
# The profile is baked at build time (scripts/generate-runtime-config.ts), so it
# has to be chosen here rather than at `docker run`. `dist/config.json` stays a
# separate file in the image, which is what makes build-once/promote possible:
# a deployment can replace just that file without rebuilding the bundle.
ARG APP_PROFILE=production
ENV APP_PROFILE=${APP_PROFILE}
# The bundle and the nginx locations must agree on the prefix the deployment
# serves this under: "/" at a domain root, "/dev/" behind a path prefix.
ARG VITE_ROUTER_BASE_PATH=/
ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH}
# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts),
# which refuses to build without an identity for the artifact. That is the point:
# a deployed bundle that cannot say which commit it came from is not traceable,
# and the checklist asks exactly that. Supplied as build args so the caller —
# a pipeline or the deploy script — owns the values.
ENV CI=true
ARG VITE_BUILD_ID
ARG VITE_COMMIT_SHA
ARG RELEASE_ID
ARG CI_RUNNER_IMAGE
ARG SOURCE_DATE_EPOCH
ENV VITE_BUILD_ID=${VITE_BUILD_ID}
ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA}
ENV RELEASE_ID=${RELEASE_ID}
ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE}
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
# The two values a deployment is allowed to supply (scripts/generate-runtime-
# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL
# has to be absolute — the runtime canonicalises it with `new URL(value)` — so
# even a same-origin deployment names its own origin here. The committed
# production profile ships a placeholder (https://api.example.com/), which is
# what a deployment that forgets this would silently serve.
ARG RUNTIME_API_BASE_URL
ARG RUNTIME_TELEMETRY_ENDPOINT
ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL}
ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT}
RUN corepack enable
# Dependencies first so a source-only change does not re-resolve them.
COPY package.json pnpm-lock.yaml ./
RUN corepack pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN corepack pnpm build \
&& node scripts/generate-nginx-config.ts
# ---------------------------------------------------------------------------
# runtime
# ---------------------------------------------------------------------------
FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime
# Replaces the packaged default server block; the generated file is the whole
# server definition, including the BFF proxy locations.
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf
COPY --from=build /src/dist/ /usr/share/nginx/html/
# The generated config is served from /usr/share/nginx/html as root, so the two
# copies above would also publish nginx.conf itself. It is not secret, but it is
# not a page either.
RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \
&& rm -rf /usr/share/nginx/html/.vite \
# The build writes config.json 0600, which nginx (running as `nginx`) cannot
# read — the container came up healthy and answered 403 for the one file the
# SPA needs before it can boot. Normalise what is served to world-readable.
&& chmod -R a+rX /usr/share/nginx/html
EXPOSE 80
# No `nginx -t` here: proxy_pass names are resolved when the config loads, and
# `backend`/`keycloak` only exist on the compose network. The container's own
# startup is the check, and it fails loudly.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1
+125 -26
View File
@@ -4,9 +4,9 @@ Initialized from `clean-architecture-frontend-template` revision
`4dc033cf33a5b6173bbf960d5eb464a406dc4c92`. The exact source identity is `4dc033cf33a5b6173bbf960d5eb464a406dc4c92`. The exact source identity is
recorded in `template.lock.json`. recorded in `template.lock.json`.
A React/Vite reference implementation where architecture boundaries, A React/Vite TechLog application where architecture boundaries, integration
integration behavior, release coherence, accessibility, performance, and behavior, release coherence, accessibility, performance, and operations are
operations are executable contracts rather than conventions. executable contracts rather than conventions.
## Start locally ## Start locally
@@ -28,26 +28,107 @@ corepack pnpm dev
Runtime-public settings live in `public/config.json` and are validated before Runtime-public settings live in `public/config.json` and are validated before
the product tree mounts. Client secrets are forbidden. the product tree mounts. Client secrets are forbidden.
## Included starter experience ## TechLog experience
The default build mounts a domain-neutral application shell with a header, The default build mounts the source-faithful TechLog Public and Studio
responsive sidebar, route focus management, session integration status, and a experience. Public routes provide discovery, search, documents, topics,
persistent `system` / `light` / `dark` theme selector. projects, releases, and profile content. `/studio` provides the session-scoped
mock authoring workflow: create, edit, validate, preview, publish, unpublish,
and immutable publication history.
| Route | Purpose | The 27 canonical route definitions are divided into `PUBLIC` and `STUDIO`
| --- | --- | nested layouts. Studio authentication remains deliberately deferred; its mock
| `/` | implementation readiness and starter links | gateway state lasts for one Studio shell session and resets on a full document
| `/examples/ui` | buttons, fields, cards, alerts, badges, modal, and tokens | load. The exact route, dependency, stylesheet, asset, and parity inventory is
| `/examples/states` | loading, refresh, empty, error, auth, forbidden, and not-found states | recorded in
| `/examples/auth` | reactive external-auth integration seam | [`docs/operations/techlog-ui-migration-baseline.md`](docs/operations/techlog-ui-migration-baseline.md).
| `/examples/reference-resources` | removable, session-required reference feature |
`AUTH_MODE=demo` is credential-free and accepted only in local/development `corepack pnpm build` emits a self-contained `dist/server.mjs` production
environments. Deployments use `AUTH_MODE=external` and provide the opaque auth boundary. `corepack pnpm preview --host 127.0.0.1 --port 4174` serves known
owner described in Public and Studio routes as SPA documents, preserves the in-shell Studio 404,
[`docs/architecture/starter-experience.md`](docs/architecture/starter-experience.md). and returns source-exact raw `404 text/plain` responses for missing Public
The client route policy is user experience only; server authorization remains content. With the read-only source temp-copy server on `4375`, run:
authoritative.
```bash
TECH_LOG_SOURCE_URL=http://127.0.0.1:4375 \
TECH_LOG_TARGET_URL=http://127.0.0.1:4174 \
corepack pnpm verify:tech-log-source-parity
```
### Studio backend source
`TECH_LOG_STUDIO_SOURCE` (`MOCK` | `HTTP`) selects which `StudioGateway`
adapter the composition root wires up. It defaults to `MOCK` — the
session-scoped in-memory Studio described above — so the existing Studio
workflow and its test suites are unaffected unless the switch is deliberately
turned on. Setting it to `HTTP` wires the HTTP `StudioGateway` instead, which
calls the canonical `@tech-log/studio-contract` operations against
`API_BASE_URL`. With no backend reachable at that URL, Studio still boots and
its shell renders; the specific panels that need the backend show an inline
"failed to load" state rather than a blank screen or an unhandled exception.
The switch is a field on the versioned runtime config document
(`RuntimeConfigV2`), not a build-time flag:
- `config/runtime/{local,development,staging,production}.json` are the
deployment profiles `corepack pnpm build` (via
`scripts/generate-runtime-config.ts`) materializes into `dist/config.json`
for a real build.
- `corepack pnpm dev` does not run that step. Plain `vite` serves
`public/config.json` (and `public/release-manifest.json`) verbatim as dev
fixtures — editing `config/runtime/local.json` alone has no effect on
`pnpm dev`. To exercise `HTTP` mode under `pnpm dev`, set
`TECH_LOG_STUDIO_SOURCE` in `public/config.json` directly.
### The dev release manifest must declare the compiled contract set
Contract-set verification runs unconditionally at boot, before any adapter is
selected. It is not an `HTTP`-mode caveat: if `public/release-manifest.json`'s
`contractSet` does not match the set the build compiled, `corepack pnpm dev`
does not start the app at all — it renders the fail-closed boot screen
(`CONTRACT_SET_MISMATCH` / `CONTRACT_SET_PACKAGE_MISSING`) in the **default
`MOCK` mode** too. A developer who runs `pnpm dev` and gets a boot error has a
broken dev server, however tidy the screen looks; treat it as a defect in the
fixture, never as expected behaviour.
The expectation comes from `EXPECTED_CONTRACT_SET_PACKAGES`
(`src/features/installed-contract-contributions.ts`), and a real build writes it
into `dist/release-manifest.json` from `scripts/generate-contract-set.ts`, so
production builds are always self-consistent. Only the hand-maintained dev
fixture can drift, and it drifts whenever either half moves — a regenerated
contract (new package digest or version) or a contribution added to or removed
from `installed-contract-contributions.ts`. Two things keep it honest:
- `corepack pnpm generate:tech-log-contract` refreshes the fixture's
`contractSet` block as its last step, so regenerating the contract can never
leave the two out of step. `corepack pnpm generate:dev-release-manifest`
refreshes the same block on its own, for the contribution-list case that does
not go through contract generation.
- `corepack pnpm check:dev-release-manifest` is the gate. It compares the
fixture's `setAlgorithm`, `setDigest` and package set against the compiled
set and fails on any difference. It runs in CI as part of FE-GATE-010, which
is what catches the changes the generation step cannot see.
### TechLog contract generation
The Studio HTTP contract is vendored from a canonical OpenAPI source, not
hand-written:
- `corepack pnpm generate:tech-log-contract` regenerates
`src/features/tech-log/contracts/studio/studio-api.openapi.yaml`,
`generated.ts`, and `canonical-source.json` from the canonical
`tech-log-design-package` repository (path from `TECH_LOG_DESIGN_PACKAGE`,
default `/home/donghyeon/workspace/tech-log-design-package`). It needs that
repository checked out locally and network access, because type generation
runs in an isolated `pnpm dlx` sandbox (this repo pins TypeScript 7, which
has no classic compiler API for `openapi-typescript` to use). Run it after
the canonical contract changes, then commit the regenerated files.
- `corepack pnpm check:tech-log-contract` is the drift gate: it hashes the
vendored yaml against the recorded digest and confirms every recorded
`operationId` is present in both the yaml and the generated types. It needs
neither the canonical repository nor the network, so it runs in CI and in
this sandbox. Run it any time to confirm the vendored contract has not
drifted from what was last generated.
## Architecture ## Architecture
@@ -61,11 +142,13 @@ contracts own cross-cutting registries
``` ```
See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
`docs/architecture/starter-experience.md`. The removable vertical slice is `docs/architecture/starter-experience.md`. TechLog is one feature boundary
under `src/features/reference-feature`; its domain, application input, HTTP under `src/features/tech-log`. Its immutable Public catalog and session-scoped
adapter, contracts, route runtime, and presentation are installed through the Studio mock gateway are injected through the application feature input;
feature contribution files in `src/features`. The generic starter routes Public and Studio presentation code shares the typed content renderer without
continue to typecheck, test, and build after that contribution is removed. importing concrete adapters. The retained reference feature remains a
non-product platform contract fixture and can be removed without changing the
TechLog route set.
### Platform capability review ### Platform capability review
@@ -117,6 +200,9 @@ corepack pnpm verify:release
corepack pnpm check:registries corepack pnpm check:registries
corepack pnpm drill:runbooks corepack pnpm drill:runbooks
corepack pnpm check:ci corepack pnpm check:ci
corepack pnpm exec vitest run tests/features/tech-log
corepack pnpm exec playwright test tests/e2e/tech-log-public-discovery.spec.ts tests/e2e/tech-log-studio-workflow.spec.ts tests/e2e/tech-log-responsive.spec.ts tests/e2e/tech-log-accessibility.spec.ts --project=chromium
corepack pnpm test:visual
``` ```
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript `check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
@@ -144,7 +230,20 @@ corepack pnpm exec playwright install --with-deps chromium firefox webkit
Two gates intentionally need external evidence: Two gates intentionally need external evidence:
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review - `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
for all six registered routes. for all 27 registered routes:
`NOT_FOUND`, `TECH_LOG_CASE`, `TECH_LOG_EXPLORE`, `TECH_LOG_EXPLORE_KIND`,
`TECH_LOG_HOME`, `TECH_LOG_PROFILE`, `TECH_LOG_PROJECT`,
`TECH_LOG_PROJECTS`, `TECH_LOG_PROJECT_ACTIVITY`,
`TECH_LOG_PROJECT_DECISIONS`, `TECH_LOG_PROJECT_RECORDS`,
`TECH_LOG_QUESTION`, `TECH_LOG_REFERENCE`, `TECH_LOG_RELEASE`,
`TECH_LOG_RELEASES`, `TECH_LOG_SEARCH`, `TECH_LOG_STUDIO_DOCUMENTS`,
`TECH_LOG_STUDIO_DOCUMENT_EDIT`, `TECH_LOG_STUDIO_DOCUMENT_NEW`,
`TECH_LOG_STUDIO_DOCUMENT_PREVIEW`, `TECH_LOG_STUDIO_DOCUMENT_PUBLISH`,
`TECH_LOG_STUDIO_DOCUMENT_VALIDATION`, `TECH_LOG_STUDIO_HOME`,
`TECH_LOG_STUDIO_NOT_FOUND`, `TECH_LOG_STUDIO_PUBLICATIONS`,
`TECH_LOG_STUDIO_PUBLICATION_PREVIEW`, `TECH_LOG_TOPIC`.
`verify:documentation` derives that list from the route registry and fails if
this paragraph falls behind it.
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum - `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
eligible-sample threshold and 28 days of production data exist. eligible-sample threshold and 28 days of production data exist.
-18
View File
@@ -1,18 +0,0 @@
# APP_HOME accessibility review
Status: pending-manual-review
Route ID: APP_HOME
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Automated axe, keyboard-focus, and reduced-motion evidence is available; human review pending.
@@ -1,18 +0,0 @@
# EXAMPLES_AUTH accessibility review
Status: pending-manual-review
Route ID: EXAMPLES_AUTH
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Review session state announcements and unavailable external-integration behavior.
@@ -1,18 +0,0 @@
# EXAMPLES_PLATFORM accessibility review
Status: pending-manual-review
Route ID: EXAMPLES_PLATFORM
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Review the horizontally scrollable registry tables for keyboard reachability of the scroll container, table caption and header association announced per row, capability status badges carrying their meaning in text rather than colour alone, and the release identity region announcing its update through aria-live without interrupting a reader mid row.
@@ -1,18 +0,0 @@
# EXAMPLES_STATES accessibility review
Status: pending-manual-review
Route ID: EXAMPLES_STATES
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Review loading, refresh, empty, error, authentication, forbidden, and not-found announcements.
+3 -3
View File
@@ -10,9 +10,9 @@ Attestation: pending
M1 Keyboard: pending M1 Keyboard: pending
M2 Visible focus: pending M2 Visible focus: pending
M3 Route focus: pending M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route) M4 Modal focus: pending
M5 Error association: not-applicable (no form error on this route) M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Human review pending. Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -1,18 +0,0 @@
# REFERENCE_RESOURCE_STATUS accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_STATUS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -1,7 +1,7 @@
# REFERENCE_RESOURCE_FORM accessibility review # TECH_LOG_CASE accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_FORM Route ID: TECH_LOG_CASE
Release ID: Release ID:
Reviewer: Reviewer:
Reviewed at: Reviewed at:
@@ -15,4 +15,4 @@ M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Human review pending. Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -1,7 +1,7 @@
# EXAMPLES_UI accessibility review # TECH_LOG_EXPLORE accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: EXAMPLES_UI Route ID: TECH_LOG_EXPLORE
Release ID: Release ID:
Reviewer: Reviewer:
Reviewed at: Reviewed at:
@@ -15,4 +15,4 @@ M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Review form primitives, Menu/Tabs keyboard behavior, Toast announcements, Tooltip supplemental copy, text-field error association and modal focus containment/restoration. Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_EXPLORE_KIND accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_EXPLORE_KIND
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -1,7 +1,7 @@
# REFERENCE_RESOURCE_LIST accessibility review # TECH_LOG_HOME accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_LIST Route ID: TECH_LOG_HOME
Release ID: Release ID:
Reviewer: Reviewer:
Reviewed at: Reviewed at:
@@ -10,9 +10,9 @@ Attestation: pending
M1 Keyboard: pending M1 Keyboard: pending
M2 Visible focus: pending M2 Visible focus: pending
M3 Route focus: pending M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route) M4 Modal focus: pending
M5 Error association: not-applicable (no form error on this route) M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Human review pending. Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -1,7 +1,7 @@
# REFERENCE_RESOURCE_DETAIL accessibility review # TECH_LOG_PROFILE accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_DETAIL Route ID: TECH_LOG_PROFILE
Release ID: Release ID:
Reviewer: Reviewer:
Reviewed at: Reviewed at:
@@ -10,9 +10,9 @@ Attestation: pending
M1 Keyboard: pending M1 Keyboard: pending
M2 Visible focus: pending M2 Visible focus: pending
M3 Route focus: pending M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route) M4 Modal focus: pending
M5 Error association: not-applicable (no form error on this route) M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Human review pending. Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_PROJECT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_PROJECT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_PROJECTS accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_PROJECTS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_PROJECT_ACTIVITY accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_PROJECT_ACTIVITY
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_PROJECT_DECISIONS accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_PROJECT_DECISIONS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_PROJECT_RECORDS accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_PROJECT_RECORDS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_QUESTION accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_QUESTION
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_REFERENCE accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_REFERENCE
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_RELEASE accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_RELEASE
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_RELEASES accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_RELEASES
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_SEARCH accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_SEARCH
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_ASSETS accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_ASSETS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_DOCUMENTS accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_DOCUMENTS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_DOCUMENT_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_DOCUMENT_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_DOCUMENT_NEW accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_DOCUMENT_NEW
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_DOCUMENT_PREVIEW accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_DOCUMENT_PREVIEW
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_DOCUMENT_PUBLISH accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_DOCUMENT_PUBLISH
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_DOCUMENT_VALIDATION accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_DOCUMENT_VALIDATION
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_HOME accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_HOME
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_NOT_FOUND accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_NOT_FOUND
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_PUBLICATIONS accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_PUBLICATIONS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_PUBLICATION_PREVIEW accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_PUBLICATION_PREVIEW
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASES accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASES
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_TAXONOMY accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_TAXONOMY
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_TOPIC accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_TOPIC
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+284 -42
View File
@@ -174,6 +174,11 @@
"script": "test:reference-feature", "script": "test:reference-feature",
"expect": "pass" "expect": "pass"
}, },
{
"id": "test-tech-log",
"script": "test:tech-log",
"expect": "pass"
},
{ {
"id": "test-recipes", "id": "test-recipes",
"script": "test:recipes", "script": "test:recipes",
@@ -323,6 +328,16 @@
"expectedExitCode": 1, "expectedExitCode": 1,
"expectedDiagnosticId": "duplicates routeId=DUPLICATE" "expectedDiagnosticId": "duplicates routeId=DUPLICATE"
}, },
{
"id": "check-tech-log-contract",
"script": "check:tech-log-contract",
"expect": "pass"
},
{
"id": "check-dev-release-manifest",
"script": "check:dev-release-manifest",
"expect": "pass"
},
{ {
"id": "build", "id": "build",
"script": "build", "script": "build",
@@ -472,6 +487,11 @@
"id": "check-ci", "id": "check-ci",
"script": "check:ci", "script": "check:ci",
"expect": "pass" "expect": "pass"
},
{
"id": "check-release-admission",
"script": "check:release-admission",
"expect": "pass"
} }
], ],
"artifactSchemas": [ "artifactSchemas": [
@@ -732,6 +752,12 @@
"id": "sarif-secret-scan", "id": "sarif-secret-scan",
"kind": "sarif", "kind": "sarif",
"maxBytes": 67108864 "maxBytes": 67108864
},
{
"id": "json-deployment-admission",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "deployment-admission"
} }
], ],
"artifacts": [ "artifacts": [
@@ -885,6 +911,15 @@
"test-reference-feature" "test-reference-feature"
] ]
}, },
{
"id": "artifact-artifacts-tests-tech-log-xml",
"path": "artifacts/tests/tech-log.xml",
"schemaId": "junit",
"production": "command-generated",
"producerCommandIds": [
"test-tech-log"
]
},
{ {
"id": "artifact-artifacts-tests-optional-recipes-xml", "id": "artifact-artifacts-tests-optional-recipes-xml",
"path": "artifacts/tests/optional-recipes.xml", "path": "artifacts/tests/optional-recipes.xml",
@@ -1006,58 +1041,196 @@
] ]
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-APP-HOME-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-HOME-md",
"path": "artifacts/tests/a11y-manual/APP_HOME.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_HOME.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "command-generated", "production": "source-controlled"
"producerCommandIds": [
"review-a11y-manual"
]
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-UI-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-EXPLORE-md",
"path": "artifacts/tests/a11y-manual/EXAMPLES_UI.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_EXPLORE.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "command-generated", "production": "source-controlled"
"producerCommandIds": [
"review-a11y-manual"
]
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-STATES-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-EXPLORE-KIND-md",
"path": "artifacts/tests/a11y-manual/EXAMPLES_STATES.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_EXPLORE_KIND.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "command-generated", "production": "source-controlled"
"producerCommandIds": [
"review-a11y-manual"
]
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-AUTH-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-CASE-md",
"path": "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_CASE.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "command-generated", "production": "source-controlled"
"producerCommandIds": [
"review-a11y-manual"
]
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-REFERENCE-RESOURCE-LIST-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-REFERENCE-md",
"path": "artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_REFERENCE.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "command-generated", "production": "source-controlled"
"producerCommandIds": [ },
"review-a11y-manual" {
] "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-QUESTION-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_QUESTION.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-TOPIC-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_TOPIC.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECTS-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_PROJECTS.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_PROJECT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-RECORDS-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_PROJECT_RECORDS.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-DECISIONS-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_PROJECT_DECISIONS.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-ACTIVITY-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_PROJECT_ACTIVITY.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-RELEASES-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_RELEASES.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-RELEASE-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_RELEASE.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-PROFILE-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_PROFILE.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-SEARCH-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_SEARCH.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-HOME-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_HOME.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENTS-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_DOCUMENTS.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-NEW-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_DOCUMENT_NEW.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_DOCUMENT_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-VALIDATION-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_DOCUMENT_VALIDATION.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-PREVIEW-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_DOCUMENT_PREVIEW.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-PUBLISH-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_DOCUMENT_PUBLISH.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PUBLICATIONS.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PUBLICATION_PREVIEW.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_ASSETS.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_TAXONOMY.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
"schemaId": "markdown",
"production": "source-controlled"
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "id": "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
"path": "artifacts/tests/a11y-manual/NOT_FOUND.md", "path": "artifacts/tests/a11y-manual/NOT_FOUND.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "command-generated", "production": "source-controlled"
"producerCommandIds": [
"review-a11y-manual"
]
}, },
{ {
"id": "artifact-artifacts-tests-a11y-manual-report-json", "id": "artifact-artifacts-tests-a11y-manual-report-json",
@@ -1602,6 +1775,21 @@
"producerCommandIds": [ "producerCommandIds": [
"check-ci" "check-ci"
] ]
},
{
"id": "artifact-artifacts-release-deployment-admission-json",
"path": "artifacts/release/deployment-admission.json",
"schemaId": "json-deployment-admission",
"production": "command-generated",
"producerCommandIds": [
"check-release-admission"
]
},
{
"id": "artifact-artifacts-quality-gates-FE-GATE-027-txt",
"path": "artifacts/quality/gates/FE-GATE-027.txt",
"schemaId": "text",
"production": "runner-generated"
} }
], ],
"gates": [ "gates": [
@@ -1707,6 +1895,7 @@
"test-integration", "test-integration",
"test-http-scenario-evidence", "test-http-scenario-evidence",
"test-reference-feature", "test-reference-feature",
"test-tech-log",
"test-recipes" "test-recipes"
], ],
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-007-txt", "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-007-txt",
@@ -1716,6 +1905,7 @@
"artifact-artifacts-quality-http-scenario-evidence-json", "artifact-artifacts-quality-http-scenario-evidence-json",
"artifact-artifacts-quality-http-scenario-evidence-fixture-json", "artifact-artifacts-quality-http-scenario-evidence-fixture-json",
"artifact-artifacts-tests-reference-feature-xml", "artifact-artifacts-tests-reference-feature-xml",
"artifact-artifacts-tests-tech-log-xml",
"artifact-artifacts-tests-optional-recipes-xml" "artifact-artifacts-tests-optional-recipes-xml"
], ],
"retentionClassId": "merge-cycle" "retentionClassId": "merge-cycle"
@@ -1757,11 +1947,37 @@
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-009-txt", "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-009-txt",
"evidenceArtifactIds": [ "evidenceArtifactIds": [
"artifact-artifacts-tests-a11y-json", "artifact-artifacts-tests-a11y-json",
"artifact-artifacts-tests-a11y-manual-APP-HOME-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-HOME-md",
"artifact-artifacts-tests-a11y-manual-EXAMPLES-UI-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-EXPLORE-md",
"artifact-artifacts-tests-a11y-manual-EXAMPLES-STATES-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-EXPLORE-KIND-md",
"artifact-artifacts-tests-a11y-manual-EXAMPLES-AUTH-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-CASE-md",
"artifact-artifacts-tests-a11y-manual-REFERENCE-RESOURCE-LIST-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-REFERENCE-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-QUESTION-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-TOPIC-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECTS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-RECORDS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-DECISIONS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-PROJECT-ACTIVITY-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-RELEASES-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-RELEASE-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-PROFILE-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-SEARCH-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-HOME-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENTS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-NEW-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-VALIDATION-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-PREVIEW-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-PUBLISH-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-report-json" "artifact-artifacts-tests-a11y-manual-report-json"
], ],
@@ -1788,6 +2004,8 @@
"check-registries-baseline-fixture", "check-registries-baseline-fixture",
"check-registries-fixture", "check-registries-fixture",
"check-routes-fixture", "check-routes-fixture",
"check-tech-log-contract",
"check-dev-release-manifest",
"check-ci" "check-ci"
], ],
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-010-txt", "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-010-txt",
@@ -2049,6 +2267,18 @@
"artifact-artifacts-performance-lab-json" "artifact-artifacts-performance-lab-json"
], ],
"retentionClassId": "release-coherence" "retentionClassId": "release-coherence"
},
{
"id": "FE-GATE-027",
"name": "release-admission",
"commandIds": [
"check-release-admission"
],
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-027-txt",
"evidenceArtifactIds": [
"artifact-artifacts-release-deployment-admission-json"
],
"retentionClassId": "release-coherence"
} }
], ],
"stages": [ "stages": [
@@ -2083,7 +2313,8 @@
"FE-GATE-014", "FE-GATE-014",
"FE-GATE-015", "FE-GATE-015",
"FE-GATE-019", "FE-GATE-019",
"FE-GATE-026" "FE-GATE-026",
"FE-GATE-027"
] ]
}, },
{ {
@@ -2236,10 +2467,20 @@
"condition": "release", "condition": "release",
"timeoutMinutes": 45, "timeoutMinutes": 45,
"gateIds": [ "gateIds": [
"FE-GATE-015" "FE-GATE-015",
"FE-GATE-027"
], ],
"browserGateIds": [], "browserGateIds": [],
"environment": [], "environment": [
{
"name": "APP_PROFILE",
"value": "${{ vars.APP_PROFILE }}"
},
{
"name": "RELEASE_TARGET",
"value": "${{ vars.RELEASE_TARGET }}"
}
],
"steps": [ "steps": [
{ {
"kind": "checkout" "kind": "checkout"
@@ -2301,6 +2542,7 @@
"scripts/lib/secret-scan.ts", "scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts", "scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts", "scripts/lib/validated-json-artifact.ts",
"scripts/lib/vite-route-chunks.ts",
"src/contracts/release-artifacts.ts", "src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts", "src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts", "src/features/installed-feature-contracts.ts",
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"snapshotDigest": "96b95ef1d50cce36e9fca8a98776a9e2e9e3a5dca24a6288ad83bf29c94aebd8", "snapshotDigest": "428479ac5845374a82dd7d02a0c59a713106405f031cdc153789174f14c1405b",
"owner": "frontend-platform", "owner": "tech-log-frontend",
"reason": "Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement", "reason": "Install approved TechLog Public and Studio route contract",
"approvedAt": "2026-08-01T15:15:45.537Z" "approvedAt": "2026-08-15T16:32:32.042Z"
} }
+633 -153
View File
@@ -5,11 +5,12 @@
"registryId": "FE-REG-ROUTE", "registryId": "FE-REG-ROUTE",
"owner": "feature-frontend-routing-release-recovery-runtime", "owner": "feature-frontend-routing-release-recovery-runtime",
"source": "src/features/installed-feature-contracts.ts", "source": "src/features/installed-feature-contracts.ts",
"rowCount": 10, "rowCount": 27,
"contract": { "contract": {
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"path", "path",
"layoutGroup",
"paramsSchema", "paramsSchema",
"searchSchema", "searchSchema",
"access", "access",
@@ -23,6 +24,7 @@
"fieldTypes": { "fieldTypes": {
"routeId": "string", "routeId": "string",
"path": "string", "path": "string",
"layoutGroup": "string",
"paramsSchema": "string|null", "paramsSchema": "string|null",
"searchSchema": "string|null", "searchSchema": "string|null",
"access": "string", "access": "string",
@@ -43,14 +45,29 @@
"public", "public",
"session-required" "session-required"
], ],
"layoutGroup": [
"PUBLIC",
"STUDIO"
],
"paramsSchema": [ "paramsSchema": [
null, null,
"NotFoundSplat", "NotFoundSplat",
"ReferenceResourceParams" "ReferenceResourceParams",
"TechLogExploreKindParams",
"TechLogSlugParams",
"TechLogVersionParams",
"TechLogDocumentIdParams",
"TechLogPublicationEventIdParams",
"TechLogStudioSplat"
], ],
"searchSchema": [ "searchSchema": [
null, null,
"ReferenceResourceListQuery" "ReferenceResourceListQuery",
"TechLogHomeSearch",
"TechLogExploreSearch",
"TechLogExploreKindSearch",
"TechLogSearchQuery",
"TechLogCaseStateSearch"
], ],
"loadingSurface": [ "loadingSurface": [
"app-shell", "app-shell",
@@ -88,6 +105,7 @@
"breakingFields": [ "breakingFields": [
"routeId", "routeId",
"path", "path",
"layoutGroup",
"paramsSchema", "paramsSchema",
"searchSchema", "searchSchema",
"access", "access",
@@ -95,75 +113,11 @@
] ]
}, },
"rows": { "rows": {
"APP_HOME": {
"access": "public",
"chunkId": "route-home",
"errorSurface": "route-boundary",
"loadingSurface": "app-shell",
"navigationLabel": "시작",
"navigationOrder": 10,
"paramsSchema": null,
"path": "/",
"routeId": "APP_HOME",
"searchSchema": null,
"title": "시작"
},
"EXAMPLES_AUTH": {
"access": "public",
"chunkId": "route-examples-auth",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "인증 연동",
"navigationOrder": 40,
"paramsSchema": null,
"path": "/examples/auth",
"routeId": "EXAMPLES_AUTH",
"searchSchema": null,
"title": "인증 연동"
},
"EXAMPLES_PLATFORM": {
"access": "public",
"chunkId": "route-examples-platform",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "플랫폼 구성",
"navigationOrder": 15,
"paramsSchema": null,
"path": "/examples/platform",
"routeId": "EXAMPLES_PLATFORM",
"searchSchema": null,
"title": "플랫폼 구성"
},
"EXAMPLES_STATES": {
"access": "public",
"chunkId": "route-examples-states",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "화면 상태",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/examples/states",
"routeId": "EXAMPLES_STATES",
"searchSchema": null,
"title": "화면 상태"
},
"EXAMPLES_UI": {
"access": "public",
"chunkId": "route-examples-ui",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "UI 구성요소",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/examples/ui",
"routeId": "EXAMPLES_UI",
"searchSchema": null,
"title": "UI 구성요소"
},
"NOT_FOUND": { "NOT_FOUND": {
"access": "public", "access": "public",
"chunkId": "route-not-found", "chunkId": "route-not-found",
"errorSurface": "not-found", "errorSurface": "not-found",
"layoutGroup": "PUBLIC",
"loadingSurface": "none", "loadingSurface": "none",
"navigationLabel": null, "navigationLabel": null,
"navigationOrder": null, "navigationOrder": null,
@@ -171,59 +125,371 @@
"path": "*", "path": "*",
"routeId": "NOT_FOUND", "routeId": "NOT_FOUND",
"searchSchema": null, "searchSchema": null,
"title": "페이지를 찾을 수 없" "title": "페이지를 찾을 수 없습니다."
}, },
"REFERENCE_RESOURCE_DETAIL": { "TECH_LOG_CASE": {
"access": "session-required", "access": "public",
"chunkId": "route-reference-resource-detail", "chunkId": "route-tech-log-case",
"errorSurface": "feature-boundary", "errorSurface": "feature-boundary",
"loadingSurface": "reference-resource-detail", "layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null, "navigationLabel": null,
"navigationOrder": null, "navigationOrder": null,
"paramsSchema": "ReferenceResourceParams", "paramsSchema": "TechLogSlugParams",
"path": "/examples/reference-resources/:resourceId", "path": "/cases/:slug",
"routeId": "REFERENCE_RESOURCE_DETAIL", "routeId": "TECH_LOG_CASE",
"searchSchema": null, "searchSchema": "TechLogCaseStateSearch",
"title": "Reference detail" "title": "Case"
}, },
"REFERENCE_RESOURCE_FORM": { "TECH_LOG_EXPLORE": {
"access": "session-required", "access": "public",
"chunkId": "route-reference-resource-form", "chunkId": "route-tech-log-explore",
"errorSurface": "feature-boundary", "errorSurface": "feature-boundary",
"loadingSurface": "reference-resource-form", "layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": "탐색",
"navigationOrder": 10,
"paramsSchema": null,
"path": "/explore",
"routeId": "TECH_LOG_EXPLORE",
"searchSchema": "TechLogExploreSearch",
"title": "탐색"
},
"TECH_LOG_EXPLORE_KIND": {
"access": "public",
"chunkId": "route-tech-log-explore-kind",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogExploreKindParams",
"path": "/explore/:kind",
"routeId": "TECH_LOG_EXPLORE_KIND",
"searchSchema": "TechLogExploreKindSearch",
"title": "유형별 탐색"
},
"TECH_LOG_HOME": {
"access": "public",
"chunkId": "route-tech-log-home",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null, "navigationLabel": null,
"navigationOrder": null, "navigationOrder": null,
"paramsSchema": null, "paramsSchema": null,
"path": "/examples/reference-resources/new", "path": "/",
"routeId": "REFERENCE_RESOURCE_FORM", "routeId": "TECH_LOG_HOME",
"searchSchema": null, "searchSchema": "TechLogHomeSearch",
"title": "Reference form" "title": "TechLog"
}, },
"REFERENCE_RESOURCE_LIST": { "TECH_LOG_PROFILE": {
"access": "session-required", "access": "public",
"chunkId": "route-reference-resources", "chunkId": "route-tech-log-profile",
"errorSurface": "feature-boundary", "errorSurface": "feature-boundary",
"loadingSurface": "reference-resource-list", "layoutGroup": "PUBLIC",
"navigationLabel": "Reference feature", "loadingSurface": "app-shell",
"navigationOrder": 50, "navigationLabel": "프로필",
"navigationOrder": 40,
"paramsSchema": null, "paramsSchema": null,
"path": "/examples/reference-resources", "path": "/profile",
"routeId": "REFERENCE_RESOURCE_LIST", "routeId": "TECH_LOG_PROFILE",
"searchSchema": "ReferenceResourceListQuery", "searchSchema": null,
"title": "Reference feature" "title": "프로필"
}, },
"REFERENCE_RESOURCE_STATUS": { "TECH_LOG_PROJECT": {
"access": "session-required", "access": "public",
"chunkId": "route-reference-resource-status", "chunkId": "route-tech-log-project",
"errorSurface": "feature-boundary", "errorSurface": "feature-boundary",
"loadingSurface": "reference-resource-status", "layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/projects/:slug",
"routeId": "TECH_LOG_PROJECT",
"searchSchema": null,
"title": "프로젝트"
},
"TECH_LOG_PROJECT_ACTIVITY": {
"access": "public",
"chunkId": "route-tech-log-project-activity",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/projects/:slug/activity",
"routeId": "TECH_LOG_PROJECT_ACTIVITY",
"searchSchema": null,
"title": "프로젝트 활동"
},
"TECH_LOG_PROJECT_DECISIONS": {
"access": "public",
"chunkId": "route-tech-log-project-decisions",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/projects/:slug/decisions",
"routeId": "TECH_LOG_PROJECT_DECISIONS",
"searchSchema": null,
"title": "프로젝트 결정"
},
"TECH_LOG_PROJECT_RECORDS": {
"access": "public",
"chunkId": "route-tech-log-project-records",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/projects/:slug/records",
"routeId": "TECH_LOG_PROJECT_RECORDS",
"searchSchema": null,
"title": "프로젝트 기록"
},
"TECH_LOG_PROJECTS": {
"access": "public",
"chunkId": "route-tech-log-projects",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": "프로젝트",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/projects",
"routeId": "TECH_LOG_PROJECTS",
"searchSchema": null,
"title": "프로젝트"
},
"TECH_LOG_QUESTION": {
"access": "public",
"chunkId": "route-tech-log-question",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/questions/:slug",
"routeId": "TECH_LOG_QUESTION",
"searchSchema": null,
"title": "Open Question"
},
"TECH_LOG_REFERENCE": {
"access": "public",
"chunkId": "route-tech-log-reference",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/references/:slug",
"routeId": "TECH_LOG_REFERENCE",
"searchSchema": null,
"title": "Reference"
},
"TECH_LOG_RELEASE": {
"access": "public",
"chunkId": "route-tech-log-release",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogVersionParams",
"path": "/releases/:version",
"routeId": "TECH_LOG_RELEASE",
"searchSchema": null,
"title": "변경 기록"
},
"TECH_LOG_RELEASES": {
"access": "public",
"chunkId": "route-tech-log-releases",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": "변경 기록",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/releases",
"routeId": "TECH_LOG_RELEASES",
"searchSchema": null,
"title": "변경 기록"
},
"TECH_LOG_SEARCH": {
"access": "public",
"chunkId": "route-tech-log-search",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null, "navigationLabel": null,
"navigationOrder": null, "navigationOrder": null,
"paramsSchema": null, "paramsSchema": null,
"path": "/examples/reference-resources/status", "path": "/search",
"routeId": "REFERENCE_RESOURCE_STATUS", "routeId": "TECH_LOG_SEARCH",
"searchSchema": "TechLogSearchQuery",
"title": "검색"
},
"TECH_LOG_STUDIO_DOCUMENT_EDIT": {
"access": "public",
"chunkId": "route-tech-log-studio-document-edit",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogDocumentIdParams",
"path": "/studio/documents/:id/edit",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_EDIT",
"searchSchema": null, "searchSchema": null,
"title": "Reference status" "title": "문서 편집"
},
"TECH_LOG_STUDIO_DOCUMENT_NEW": {
"access": "public",
"chunkId": "route-tech-log-studio-document-new",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": "새 문서",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/studio/documents/new",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_NEW",
"searchSchema": null,
"title": "새 문서"
},
"TECH_LOG_STUDIO_DOCUMENT_PREVIEW": {
"access": "public",
"chunkId": "route-tech-log-studio-document-preview",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogDocumentIdParams",
"path": "/studio/documents/:id/preview",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_PREVIEW",
"searchSchema": null,
"title": "Public Preview"
},
"TECH_LOG_STUDIO_DOCUMENT_PUBLISH": {
"access": "public",
"chunkId": "route-tech-log-studio-document-publish",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogDocumentIdParams",
"path": "/studio/documents/:id/publish",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_PUBLISH",
"searchSchema": null,
"title": "게시"
},
"TECH_LOG_STUDIO_DOCUMENT_VALIDATION": {
"access": "public",
"chunkId": "route-tech-log-studio-document-validation",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogDocumentIdParams",
"path": "/studio/documents/:id/validation",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_VALIDATION",
"searchSchema": null,
"title": "문서 검증"
},
"TECH_LOG_STUDIO_DOCUMENTS": {
"access": "public",
"chunkId": "route-tech-log-studio-documents",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": "작업본",
"navigationOrder": 10,
"paramsSchema": null,
"path": "/studio/documents",
"routeId": "TECH_LOG_STUDIO_DOCUMENTS",
"searchSchema": null,
"title": "작업본"
},
"TECH_LOG_STUDIO_HOME": {
"access": "public",
"chunkId": "route-tech-log-studio-home",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": null,
"path": "/studio",
"routeId": "TECH_LOG_STUDIO_HOME",
"searchSchema": null,
"title": "TechLog Studio"
},
"TECH_LOG_STUDIO_NOT_FOUND": {
"access": "public",
"chunkId": "route-tech-log-studio-not-found",
"errorSurface": "not-found",
"layoutGroup": "STUDIO",
"loadingSurface": "none",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogStudioSplat",
"path": "/studio/*",
"routeId": "TECH_LOG_STUDIO_NOT_FOUND",
"searchSchema": null,
"title": "Studio 화면을 찾을 수 없습니다"
},
"TECH_LOG_STUDIO_PUBLICATION_PREVIEW": {
"access": "public",
"chunkId": "route-tech-log-studio-publication-preview",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogPublicationEventIdParams",
"path": "/studio/publications/:publicationEventId/preview",
"routeId": "TECH_LOG_STUDIO_PUBLICATION_PREVIEW",
"searchSchema": null,
"title": "게시 Snapshot"
},
"TECH_LOG_STUDIO_PUBLICATIONS": {
"access": "public",
"chunkId": "route-tech-log-studio-publications",
"errorSurface": "feature-boundary",
"layoutGroup": "STUDIO",
"loadingSurface": "app-shell",
"navigationLabel": "게시 기록",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/studio/publications",
"routeId": "TECH_LOG_STUDIO_PUBLICATIONS",
"searchSchema": null,
"title": "게시 기록"
},
"TECH_LOG_TOPIC": {
"access": "public",
"chunkId": "route-tech-log-topic",
"errorSurface": "feature-boundary",
"layoutGroup": "PUBLIC",
"loadingSurface": "app-shell",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "TechLogSlugParams",
"path": "/topics/:slug",
"routeId": "TECH_LOG_TOPIC",
"searchSchema": null,
"title": "Topic"
} }
} }
}, },
@@ -231,7 +497,7 @@
"registryId": "FE-REG-ROUTE-RUNTIME", "registryId": "FE-REG-ROUTE-RUNTIME",
"owner": "feature-frontend-routing-release-recovery-runtime", "owner": "feature-frontend-routing-release-recovery-runtime",
"source": "src/features/installed-feature-contracts.ts", "source": "src/features/installed-feature-contracts.ts",
"rowCount": 10, "rowCount": 27,
"contract": { "contract": {
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
@@ -276,64 +542,166 @@
] ]
}, },
"rows": { "rows": {
"APP_HOME": {
"moduleId": "home-page",
"paramsCodec": "none",
"routeId": "APP_HOME",
"searchCodec": "none"
},
"EXAMPLES_AUTH": {
"moduleId": "auth-example-page",
"paramsCodec": "none",
"routeId": "EXAMPLES_AUTH",
"searchCodec": "none"
},
"EXAMPLES_PLATFORM": {
"moduleId": "platform-overview-page",
"paramsCodec": "none",
"routeId": "EXAMPLES_PLATFORM",
"searchCodec": "none"
},
"EXAMPLES_STATES": {
"moduleId": "state-gallery-page",
"paramsCodec": "none",
"routeId": "EXAMPLES_STATES",
"searchCodec": "none"
},
"EXAMPLES_UI": {
"moduleId": "ui-gallery-page",
"paramsCodec": "none",
"routeId": "EXAMPLES_UI",
"searchCodec": "none"
},
"NOT_FOUND": { "NOT_FOUND": {
"moduleId": "not-found-page", "moduleId": "route-not-found",
"paramsCodec": "NotFoundSplat", "paramsCodec": "NotFoundSplat",
"routeId": "NOT_FOUND", "routeId": "NOT_FOUND",
"searchCodec": "none" "searchCodec": "none"
}, },
"REFERENCE_RESOURCE_DETAIL": { "TECH_LOG_CASE": {
"moduleId": "reference-resource-detail-page", "moduleId": "route-tech-log-case",
"paramsCodec": "ReferenceResourceParams", "paramsCodec": "TechLogSlugParams",
"routeId": "REFERENCE_RESOURCE_DETAIL", "routeId": "TECH_LOG_CASE",
"searchCodec": "TechLogCaseStateSearch"
},
"TECH_LOG_EXPLORE": {
"moduleId": "route-tech-log-explore",
"paramsCodec": "none",
"routeId": "TECH_LOG_EXPLORE",
"searchCodec": "TechLogExploreSearch"
},
"TECH_LOG_EXPLORE_KIND": {
"moduleId": "route-tech-log-explore-kind",
"paramsCodec": "TechLogExploreKindParams",
"routeId": "TECH_LOG_EXPLORE_KIND",
"searchCodec": "TechLogExploreKindSearch"
},
"TECH_LOG_HOME": {
"moduleId": "route-tech-log-home",
"paramsCodec": "none",
"routeId": "TECH_LOG_HOME",
"searchCodec": "TechLogHomeSearch"
},
"TECH_LOG_PROFILE": {
"moduleId": "route-tech-log-profile",
"paramsCodec": "none",
"routeId": "TECH_LOG_PROFILE",
"searchCodec": "none" "searchCodec": "none"
}, },
"REFERENCE_RESOURCE_FORM": { "TECH_LOG_PROJECT": {
"moduleId": "reference-resource-form-page", "moduleId": "route-tech-log-project",
"paramsCodec": "none", "paramsCodec": "TechLogSlugParams",
"routeId": "REFERENCE_RESOURCE_FORM", "routeId": "TECH_LOG_PROJECT",
"searchCodec": "none" "searchCodec": "none"
}, },
"REFERENCE_RESOURCE_LIST": { "TECH_LOG_PROJECT_ACTIVITY": {
"moduleId": "reference-resource-page", "moduleId": "route-tech-log-project-activity",
"paramsCodec": "none", "paramsCodec": "TechLogSlugParams",
"routeId": "REFERENCE_RESOURCE_LIST", "routeId": "TECH_LOG_PROJECT_ACTIVITY",
"searchCodec": "ReferenceResourceListQuery" "searchCodec": "none"
}, },
"REFERENCE_RESOURCE_STATUS": { "TECH_LOG_PROJECT_DECISIONS": {
"moduleId": "reference-resource-status-page", "moduleId": "route-tech-log-project-decisions",
"paramsCodec": "TechLogSlugParams",
"routeId": "TECH_LOG_PROJECT_DECISIONS",
"searchCodec": "none"
},
"TECH_LOG_PROJECT_RECORDS": {
"moduleId": "route-tech-log-project-records",
"paramsCodec": "TechLogSlugParams",
"routeId": "TECH_LOG_PROJECT_RECORDS",
"searchCodec": "none"
},
"TECH_LOG_PROJECTS": {
"moduleId": "route-tech-log-projects",
"paramsCodec": "none", "paramsCodec": "none",
"routeId": "REFERENCE_RESOURCE_STATUS", "routeId": "TECH_LOG_PROJECTS",
"searchCodec": "none"
},
"TECH_LOG_QUESTION": {
"moduleId": "route-tech-log-question",
"paramsCodec": "TechLogSlugParams",
"routeId": "TECH_LOG_QUESTION",
"searchCodec": "none"
},
"TECH_LOG_REFERENCE": {
"moduleId": "route-tech-log-reference",
"paramsCodec": "TechLogSlugParams",
"routeId": "TECH_LOG_REFERENCE",
"searchCodec": "none"
},
"TECH_LOG_RELEASE": {
"moduleId": "route-tech-log-release",
"paramsCodec": "TechLogVersionParams",
"routeId": "TECH_LOG_RELEASE",
"searchCodec": "none"
},
"TECH_LOG_RELEASES": {
"moduleId": "route-tech-log-releases",
"paramsCodec": "none",
"routeId": "TECH_LOG_RELEASES",
"searchCodec": "none"
},
"TECH_LOG_SEARCH": {
"moduleId": "route-tech-log-search",
"paramsCodec": "none",
"routeId": "TECH_LOG_SEARCH",
"searchCodec": "TechLogSearchQuery"
},
"TECH_LOG_STUDIO_DOCUMENT_EDIT": {
"moduleId": "route-tech-log-studio-document-edit",
"paramsCodec": "TechLogDocumentIdParams",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_EDIT",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_DOCUMENT_NEW": {
"moduleId": "route-tech-log-studio-document-new",
"paramsCodec": "none",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_NEW",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_DOCUMENT_PREVIEW": {
"moduleId": "route-tech-log-studio-document-preview",
"paramsCodec": "TechLogDocumentIdParams",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_PREVIEW",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_DOCUMENT_PUBLISH": {
"moduleId": "route-tech-log-studio-document-publish",
"paramsCodec": "TechLogDocumentIdParams",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_PUBLISH",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_DOCUMENT_VALIDATION": {
"moduleId": "route-tech-log-studio-document-validation",
"paramsCodec": "TechLogDocumentIdParams",
"routeId": "TECH_LOG_STUDIO_DOCUMENT_VALIDATION",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_DOCUMENTS": {
"moduleId": "route-tech-log-studio-documents",
"paramsCodec": "none",
"routeId": "TECH_LOG_STUDIO_DOCUMENTS",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_HOME": {
"moduleId": "route-tech-log-studio-home",
"paramsCodec": "none",
"routeId": "TECH_LOG_STUDIO_HOME",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_NOT_FOUND": {
"moduleId": "route-tech-log-studio-not-found",
"paramsCodec": "TechLogStudioSplat",
"routeId": "TECH_LOG_STUDIO_NOT_FOUND",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_PUBLICATION_PREVIEW": {
"moduleId": "route-tech-log-studio-publication-preview",
"paramsCodec": "TechLogPublicationEventIdParams",
"routeId": "TECH_LOG_STUDIO_PUBLICATION_PREVIEW",
"searchCodec": "none"
},
"TECH_LOG_STUDIO_PUBLICATIONS": {
"moduleId": "route-tech-log-studio-publications",
"paramsCodec": "none",
"routeId": "TECH_LOG_STUDIO_PUBLICATIONS",
"searchCodec": "none"
},
"TECH_LOG_TOPIC": {
"moduleId": "route-tech-log-topic",
"paramsCodec": "TechLogSlugParams",
"routeId": "TECH_LOG_TOPIC",
"searchCodec": "none" "searchCodec": "none"
} }
} }
@@ -530,7 +898,7 @@
"registryId": "FE-REG-SCHEMA", "registryId": "FE-REG-SCHEMA",
"owner": "feature-frontend-contract-schema-registry", "owner": "feature-frontend-contract-schema-registry",
"source": "src/features/installed-feature-contracts.ts", "source": "src/features/installed-feature-contracts.ts",
"rowCount": 8, "rowCount": 19,
"contract": { "contract": {
"requiredFields": [ "requiredFields": [
"schemaId", "schemaId",
@@ -633,6 +1001,105 @@
"schemaId": "ReferenceResourcePayload", "schemaId": "ReferenceResourcePayload",
"schemaVersion": 1, "schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN" "unknownFieldPolicy": "STRIP_UNKNOWN"
},
"TechLogCaseStateSearch": {
"boundary": "route-search",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogCaseStateSearch",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
},
"TechLogDocumentIdParams": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogDocumentIdParams",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"TechLogExploreKindParams": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogExploreKindParams",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"TechLogExploreKindSearch": {
"boundary": "route-search",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogExploreKindSearch",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
},
"TechLogExploreSearch": {
"boundary": "route-search",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogExploreSearch",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
},
"TechLogHomeSearch": {
"boundary": "route-search",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogHomeSearch",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
},
"TechLogPublicationEventIdParams": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogPublicationEventIdParams",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"TechLogSearchQuery": {
"boundary": "route-search",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogSearchQuery",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
},
"TechLogSlugParams": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogSlugParams",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"TechLogStudioSplat": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogStudioSplat",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"TechLogVersionParams": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-tech-log",
"runtime": "zod",
"schemaId": "TechLogVersionParams",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
} }
} }
}, },
@@ -766,7 +1233,7 @@
"registryId": "FE-REG-STORAGE", "registryId": "FE-REG-STORAGE",
"owner": "feature-frontend-storage-registry-contract", "owner": "feature-frontend-storage-registry-contract",
"source": "src/contracts/storage-keys.ts", "source": "src/contracts/storage-keys.ts",
"rowCount": 4, "rowCount": 5,
"contract": { "contract": {
"requiredFields": [ "requiredFields": [
"logicalName", "logicalName",
@@ -847,6 +1314,19 @@
"ttl": null, "ttl": null,
"valueCodec": "none" "valueCodec": "none"
}, },
"CACHE_INVALIDATION_PULSE": {
"backend": "localStorage",
"classification": "opaque-cache",
"logicalName": "CACHE_INVALIDATION_PULSE",
"migration": "discard",
"name": "pulse",
"physicalKey": "ca-frontend:cache-invalidation:v1:pulse",
"quotaFallback": "no-persist",
"schemaVersion": 1,
"scope": "cache-invalidation",
"ttl": null,
"valueCodec": "opaque-string-v1"
},
"CHUNK_RELOAD_GUARD": { "CHUNK_RELOAD_GUARD": {
"backend": "sessionStorage", "backend": "sessionStorage",
"classification": "opaque-cache", "classification": "opaque-cache",
@@ -1,6 +1,190 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"changes": [ "changes": [
{
"changeId": "FE-REG-ROUTE:$contract:allowedValues:contract-field-changed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:$contract:breakingFields:contract-field-changed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:$contract:fieldTypes:contract-field-changed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:$contract:requiredFields:contract-field-changed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:APP_HOME:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:EXAMPLES_AUTH:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:EXAMPLES_PLATFORM:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:EXAMPLES_STATES:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:EXAMPLES_UI:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:REFERENCE_RESOURCE_DETAIL:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:REFERENCE_RESOURCE_FORM:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:REFERENCE_RESOURCE_LIST:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:REFERENCE_RESOURCE_STATUS:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:APP_HOME:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:EXAMPLES_AUTH:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:EXAMPLES_PLATFORM:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:EXAMPLES_STATES:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:EXAMPLES_UI:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:NOT_FOUND:moduleId:field-changed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:REFERENCE_RESOURCE_DETAIL:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:REFERENCE_RESOURCE_FORM:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:REFERENCE_RESOURCE_LIST:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE-RUNTIME:REFERENCE_RESOURCE_STATUS:*:removed",
"versionBump": "TechLog route contract v1 replaces the starter and reference-resource route set.",
"migration": "Install the TechLog route definition, route runtime, schema registry, and release manifest atomically in the same release.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot and runtime chunks; no mixed route contract is supported.",
"rollback": "Roll back the atomic release to the pre-migration feature base 05e3d50ba01f01c27f257d2e9040c2bc413ea053.",
"owner": "tech-log-frontend"
},
{ {
"changeId": "FE-REG-ENV:API_CONTRACT_VERSION:*:removed", "changeId": "FE-REG-ENV:API_CONTRACT_VERSION:*:removed",
"versionBump": "Runtime Config V2 (CONFIG_SCHEMA_VERSION 2.0) removes the scalar API contract version.", "versionBump": "Runtime Config V2 (CONFIG_SCHEMA_VERSION 2.0) removes the scalar API contract version.",
@@ -96,6 +280,86 @@
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.", "compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
"rollback": "Remove the required codec field and runtime codec dispatch together.", "rollback": "Remove the required codec field and runtime codec dispatch together.",
"owner": "frontend-platform" "owner": "frontend-platform"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_HOME:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENTS:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_NEW:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_EDIT:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_VALIDATION:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PREVIEW:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PUBLISH:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATIONS:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATION_PREVIEW:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_NOT_FOUND:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
} }
] ]
} }
+25 -3
View File
@@ -15,6 +15,7 @@
"requiredFields": [ "requiredFields": [
"routeId", "routeId",
"path", "path",
"layoutGroup",
"paramsSchema", "paramsSchema",
"searchSchema", "searchSchema",
"access", "access",
@@ -28,6 +29,7 @@
"fieldTypes": { "fieldTypes": {
"routeId": "string", "routeId": "string",
"path": "string", "path": "string",
"layoutGroup": "string",
"paramsSchema": "string|null", "paramsSchema": "string|null",
"searchSchema": "string|null", "searchSchema": "string|null",
"access": "string", "access": "string",
@@ -41,8 +43,27 @@
"uniqueFields": ["routeId", "path", "chunkId"], "uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": { "allowedValues": {
"access": ["public", "session-required"], "access": ["public", "session-required"],
"paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"], "layoutGroup": ["PUBLIC", "STUDIO"],
"searchSchema": [null, "ReferenceResourceListQuery"], "paramsSchema": [
null,
"NotFoundSplat",
"ReferenceResourceParams",
"TechLogExploreKindParams",
"TechLogSlugParams",
"TechLogVersionParams",
"TechLogDocumentIdParams",
"TechLogPublicationEventIdParams",
"TechLogStudioSplat"
],
"searchSchema": [
null,
"ReferenceResourceListQuery",
"TechLogHomeSearch",
"TechLogExploreSearch",
"TechLogExploreKindSearch",
"TechLogSearchQuery",
"TechLogCaseStateSearch"
],
"loadingSurface": [ "loadingSurface": [
"app-shell", "app-shell",
"example-page", "example-page",
@@ -84,6 +105,7 @@
"breakingFields": [ "breakingFields": [
"routeId", "routeId",
"path", "path",
"layoutGroup",
"paramsSchema", "paramsSchema",
"searchSchema", "searchSchema",
"access", "access",
@@ -234,7 +256,7 @@
"consumerIdentityField": "schemaId", "consumerIdentityField": "schemaId",
"consumerDirectories": [ "consumerDirectories": [
"src/presentation/routes", "src/presentation/routes",
"src/features/reference-feature/presentation", "src/features/tech-log/presentation",
"src/features/reference-feature/contracts" "src/features/reference-feature/contracts"
], ],
"breakingFields": ["schemaId", "boundary", "runtime"] "breakingFields": ["schemaId", "boundary", "runtime"]
+21
View File
@@ -0,0 +1,21 @@
{
"APP_ENV": "development",
"API_BASE_URL": "https://api.dev.example.com/",
"REQUEST_TIMEOUT_MS": 15000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false,
"AUTH_MODE": "external",
"CONFIG_SCHEMA_VERSION": "2.0",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
"CAPABILITY_OVERRIDES": {
"REALTIME": "DEFAULT",
"WEB_WORKER": "DEFAULT",
"SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "HTTP",
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"APP_ENV": "local",
"API_BASE_URL": "http://localhost:8080/",
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false,
"AUTH_MODE": "demo",
"CONFIG_SCHEMA_VERSION": "2.0",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
"CAPABILITY_OVERRIDES": {
"REALTIME": "DEFAULT",
"WEB_WORKER": "DEFAULT",
"SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "MOCK",
"TECH_LOG_PUBLIC_SOURCE": "MOCK",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"APP_ENV": "production",
"API_BASE_URL": "https://api.example.com/",
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": true,
"TELEMETRY_ENDPOINT": "https://telemetry.example.com/v1/events",
"AUTH_MODE": "external",
"CONFIG_SCHEMA_VERSION": "2.0",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
"CAPABILITY_OVERRIDES": {
"REALTIME": "DEFAULT",
"WEB_WORKER": "DEFAULT",
"SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "HTTP",
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"APP_ENV": "staging",
"API_BASE_URL": "https://api.staging.example.com/",
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": true,
"TELEMETRY_ENDPOINT": "https://telemetry.staging.example.com/v1/events",
"AUTH_MODE": "external",
"CONFIG_SCHEMA_VERSION": "2.0",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
"CAPABILITY_OVERRIDES": {
"REALTIME": "DEFAULT",
"WEB_WORKER": "DEFAULT",
"SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "HTTP",
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
}
@@ -1,4 +1,47 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"changes": [] "changes": [
{
"changeId": "add:@fontsource/ibm-plex-mono@5.3.0",
"owner": "tech-log-frontend",
"reviewer": "frontend-platform-security",
"reason": "Preserve the source application's IBM Plex Mono typography and bundled font assets without a runtime font request.",
"rollback": "Revert the TechLog UI migration dependency installation and restore the pre-migration presentation entry point."
},
{
"changeId": "add:pretendard@1.3.9",
"owner": "tech-log-frontend",
"reviewer": "frontend-platform-security",
"reason": "Preserve the source application's Pretendard Variable typography using its pinned bundled font asset.",
"rollback": "Revert the TechLog UI migration dependency installation and restore the pre-migration presentation entry point."
},
{
"changeId": "add:remark-directive@4.0.0",
"owner": "tech-log-frontend",
"reviewer": "frontend-platform-security",
"reason": "Parse the source TechLog document directive syntax through the migrated deterministic content pipeline.",
"rollback": "Revert the TechLog UI migration content parser and dependency installation together."
},
{
"changeId": "add:remark-gfm@4.0.1",
"owner": "tech-log-frontend",
"reviewer": "frontend-platform-security",
"reason": "Preserve source TechLog GitHub-flavored Markdown tables, task lists, and autolink parsing.",
"rollback": "Revert the TechLog UI migration content parser and dependency installation together."
},
{
"changeId": "add:remark-parse@11.0.0",
"owner": "tech-log-frontend",
"reviewer": "frontend-platform-security",
"reason": "Parse the source TechLog Markdown records into the migrated typed public-render model.",
"rollback": "Revert the TechLog UI migration content parser and dependency installation together."
},
{
"changeId": "add:unified@11.0.5",
"owner": "tech-log-frontend",
"reviewer": "frontend-platform-security",
"reason": "Compose the source-equivalent Markdown and directive parsing stages without framework coupling.",
"rollback": "Revert the TechLog UI migration content parser and dependency installation together."
}
]
} }
+2 -1
View File
@@ -12,7 +12,8 @@
"ISC", "ISC",
"MIT", "MIT",
"MIT-0", "MIT-0",
"MPL-2.0" "MPL-2.0",
"OFL-1.1"
], ],
"deniedLicensePatterns": [ "deniedLicensePatterns": [
"(^|\\s)AGPL", "(^|\\s)AGPL",
+24
View File
@@ -0,0 +1,24 @@
# Keycloak realm
`tech-log-realm.json` is imported by the `keycloak` service at start
(`--import-realm`). It exists because the realm was previously created by hand,
which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수
있다" — had no answer: nothing in either repository described the realm.
What it declares, and why each part is load-bearing:
- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this
name to `studio:read` and `studio:write`. The name is configurable through
`APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too.
- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to
the backend, not the browser — the SPA never holds a token. `redirectUris` is
relative so the same realm works on any origin the deployment is served from.
- **`realm-roles` protocol mapper.** Without it the roles never reach the token,
the registry resolves zero permissions, and every Studio call answers 403.
## Values that must be replaced
`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the
deploy script substitutes them from the environment before import. They are left
visible rather than pre-filled so a realm file committed with a real secret is an
obvious mistake rather than a quiet one.
+57
View File
@@ -0,0 +1,57 @@
{
"realm": "tech-log",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginTheme": "keycloak",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"roles": {
"realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
]
},
"clients": [
{
"clientId": "tech-log-bff",
"name": "Tech Log BFF",
"description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.",
"enabled": true,
"publicClient": false,
"secret": "CHANGE_ME_BFF_SECRET",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"redirectUris": ["/login/oauth2/code/*"],
"webOrigins": ["+"],
"protocolMappers": [
{
"name": "realm-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"multivalued": "true",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "studio",
"enabled": true,
"emailVerified": true,
"email": "studio@tech-log.local",
"firstName": "Studio",
"lastName": "Author",
"credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }],
"realmRoles": ["default-roles-tech-log", "studio-author"]
}
]
}
+180
View File
@@ -0,0 +1,180 @@
# The Tech Log dev stack: one origin, five services.
#
# nginx is the only published port. Everything the browser touches — the SPA,
# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same
# origin, which is what lets the session be a plain first-party httpOnly cookie
# instead of a cross-site one needing SameSite=None.
#
# browser ──> frontend(nginx) ──┬─> / SPA bundle
# ├─> /api backend
# ├─> /oauth2 /login /logout backend (BFF)
# └─> /auth keycloak
#
# Secrets here are development values and are meant to be replaced by the
# deployment; they are named in .env so nothing is baked into an image.
name: tech-log
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-tech_log}
POSTGRES_USER: ${POSTGRES_USER:-tech_log}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
TZ: UTC
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
networks: [tech-log]
redis:
# Holds the Studio session. Losing it signs everyone out; it holds nothing
# else, so it is not backed by a volume on purpose.
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
networks: [tech-log]
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command: ["start-dev", "--import-realm", "--http-relative-path=/auth"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
KC_HTTP_ENABLED: "true"
# Behind nginx: Keycloak must build its URLs from the forwarded host, or
# the redirect back from the login page points at the container.
KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth
KC_HOSTNAME_STRICT: "false"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
volumes:
- ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
interval: 15s
timeout: 5s
retries: 20
start_period: 40s
restart: unless-stopped
networks: [tech-log]
backend:
image: ${BACKEND_IMAGE:-tech-log-backend:local}
volumes:
- tls-public:/tls-public:ro
# The image entrypoint is `java -jar /app/app.jar`; this wraps it so the
# frontend's certificate lands in the JVM truststore first. Without it the
# OIDC metadata fetch fails PKIX validation and the process crash-loops.
entrypoint:
- /bin/sh
- -c
- |
until [ -f /tls-public/server.crt ]; do sleep 1; done
# The image runs as a non-root user, so the JVM's own cacerts is not
# writable — importing there silently did nothing and the metadata fetch
# kept failing PKIX. Copy it somewhere writable, add the edge
# certificate, and point the JVM at that.
cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks
keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \
-file /tls-public/server.crt \
-keystore /tmp/truststore.jks -storepass changeit
exec java \
-Djavax.net.ssl.trustStore=/tmp/truststore.jks \
-Djavax.net.ssl.trustStorePassword=changeit \
-jar /app/app.jar
environment:
SPRING_PROFILES_ACTIVE: local
# Persistence
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
SPRING_FLYWAY_ENABLED: "true"
SPRING_JPA_HIBERNATE_DDL_AUTO: none
CA_SKELETON_PERSISTENCE_VENDOR: postgresql
# BFF session
CA_SKELETON_SECURITY_AUTH_MODE: redis-session
CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION
APP_REDIS_ENABLED: "true"
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true"
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: "6379"
# OIDC. The issuer is the browser-facing URL because the tokens carry it
# and the browser is redirected there; the container reaches the same
# Keycloak through nginx on the compose network.
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak"
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log}
APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author}
APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio"
# Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs
# and client IPs are the browser's, not the container's.
APP_SERVER_FORWARD_HEADERS_STRATEGY: framework
TZ: UTC
# The issuer in a token is the browser-facing URL, and the backend has to
# both validate that exact string and fetch the realm's metadata from it.
# Inside the container that host does not resolve, so discovery failed and
# the process crash-looped. Mapping the public host to the docker gateway
# makes one URL work from both sides — the browser reaches nginx directly,
# the backend reaches the same nginx through the published port.
extra_hosts:
- "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway"
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
keycloak: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"]
interval: 15s
timeout: 5s
retries: 10
start_period: 45s
restart: unless-stopped
networks: [tech-log]
frontend:
image: ${FRONTEND_IMAGE:-tech-log-frontend:local}
# The backend fetches the realm metadata from the same HTTPS origin the
# browser uses, so it has to trust this certificate. Publishing it to a
# shared volume keeps one certificate for both sides; a deployment that
# mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor
# the backend's import step.
volumes:
- tls-public:/tls-public
command:
- /bin/sh
- -c
- "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'"
ports:
- "${PUBLIC_HTTP_PORT:-8088}:80"
- "${PUBLIC_PORT:-8443}:443"
depends_on:
backend: { condition: service_started }
keycloak: { condition: service_started }
restart: unless-stopped
networks: [tech-log]
networks:
tech-log:
driver: bridge
volumes:
postgres-data:
tls-public:
+26 -10
View File
@@ -1,12 +1,27 @@
# Manual accessibility review checklist # Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must Automated axe checks do not establish WCAG conformance. A human reviewer must
review all six route records in `artifacts/tests/a11y-manual/` against one review all 27 route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. The required scope is derived from the route release candidate and sign them. The required TechLog Public and Studio scope is
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, derived from the installed route registry, so the gate rejects stale, missing,
`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the or additional route records as well as blank identity/timestamp/signature
gate rejects blank identity/timestamp/signature fields, pending verdicts, fields, pending verdicts, and mismatched release IDs. The scope is:
mismatched release IDs, or missing routes. `NOT_FOUND`, `TECH_LOG_CASE`, `TECH_LOG_EXPLORE`, `TECH_LOG_EXPLORE_KIND`,
`TECH_LOG_HOME`, `TECH_LOG_PROFILE`, `TECH_LOG_PROJECT`,
`TECH_LOG_PROJECTS`, `TECH_LOG_PROJECT_ACTIVITY`,
`TECH_LOG_PROJECT_DECISIONS`, `TECH_LOG_PROJECT_RECORDS`,
`TECH_LOG_QUESTION`, `TECH_LOG_REFERENCE`, `TECH_LOG_RELEASE`,
`TECH_LOG_RELEASES`, `TECH_LOG_SEARCH`, `TECH_LOG_STUDIO_DOCUMENTS`,
`TECH_LOG_STUDIO_DOCUMENT_EDIT`, `TECH_LOG_STUDIO_DOCUMENT_NEW`,
`TECH_LOG_STUDIO_DOCUMENT_PREVIEW`, `TECH_LOG_STUDIO_DOCUMENT_PUBLISH`,
`TECH_LOG_STUDIO_DOCUMENT_VALIDATION`, `TECH_LOG_STUDIO_HOME`,
`TECH_LOG_STUDIO_NOT_FOUND`, `TECH_LOG_STUDIO_PUBLICATIONS`,
`TECH_LOG_STUDIO_PUBLICATION_PREVIEW`, `TECH_LOG_TOPIC`.
This list is not maintained by hand: `verify:documentation` compares it against
the installed route registry and fails when a registered route is absent. The
template carried the same rule for its own example screens; the route set is
this product's, the rule is the template's.
Allowed item verdicts: Allowed item verdicts:
@@ -17,7 +32,7 @@ Required record:
```text ```text
Status: reviewed Status: reviewed
Route ID: APP_HOME Route ID: <exact installed route ID>
Release ID: <immutable release ID> Release ID: <immutable release ID>
Reviewer: <human reviewer identity> Reviewer: <human reviewer identity>
Reviewed at: <RFC 3339 timestamp> Reviewed at: <RFC 3339 timestamp>
@@ -45,7 +60,8 @@ The reviewer must verify:
- M7: non-essential motion is suppressed with reduced-motion preference - M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once - Screen reader: headings, live regions, errors, and actions are announced once
`EXAMPLES_UI` requires real M4 modal and M5 field-error review; those items must Routes with dialogs or form errors require real M4 modal-focus or M5
not be marked not-applicable on that route. Passing automated evidence means error-association review; those items must not be marked not-applicable when the
reviewed route exposes the relevant behavior. Passing automated evidence means
only that tested pages had no critical or serious axe findings under the only that tested pages had no critical or serious axe findings under the
recorded Chromium, Firefox, and WebKit runs. recorded browser runs.
+22
View File
@@ -17,11 +17,33 @@ The following edges are forbidden:
- domain to application, presentation, adapters, bootstrap, React, or browser globals - domain to application, presentation, adapters, bootstrap, React, or browser globals
- application to presentation, concrete adapters, bootstrap, React, or browser globals - application to presentation, concrete adapters, bootstrap, React, or browser globals
- `contracts` to application or features: contracts is the lower package and
owns the shared vocabulary both of them read
- presentation to concrete adapters, raw DTO schemas, or storage implementations - presentation to concrete adapters, raw DTO schemas, or storage implementations
- generic presentation to the installed-feature registries: which features exist
is a product decision owned by `bootstrap`
- an adapter to presentation, bootstrap internals, or another concrete adapter - an adapter to presentation, bootstrap internals, or another concrete adapter
- feature domain/application to its presentation or outbound adapter, and - feature domain/application to its presentation or outbound adapter, and
feature presentation to its outbound adapter feature presentation to its outbound adapter
## The adapter kernel
"Another concrete adapter" excludes the adapter kernel, which is shared on
purpose and is the only adapter code an adapter may reach across a group for:
- `src/adapters/platform/**` — the system clock, the shared abort primitive and
the bounded-capacity guard
- `src/adapters/browser-file-storage/result.ts` — the browser-data result and
failure constructors
Each rule above is enforced by `check:architecture`, including the kernel
carve-out, so this table and the executable rules cannot drift apart. Two edges
are still open and are named explicitly in `.dependency-cruiser.json` rather
than left silent: the generic presentation modules that read the installed
registries today, and the two collaborator types `query-cache` reads from
`cross-context-invalidation`. Both lists are frozen — a new edge of either kind
fails the gate.
`bootstrap` contains composition only. Business rules and page-specific `bootstrap` contains composition only. Business rules and page-specific
orchestration belong to domain/application. orchestration belong to domain/application.
+3 -2
View File
@@ -5,7 +5,7 @@
"standard": "rules/diagram-standards.md v2", "standard": "rules/diagram-standards.md v2",
"evidenceReport": { "evidenceReport": {
"repoPath": "docs/architecture/review-evidence.md", "repoPath": "docs/architecture/review-evidence.md",
"canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md", "upstreamCanonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29" "canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
}, },
"reviews": { "reviews": {
@@ -25,5 +25,6 @@
"thresholdSatisfied": true, "thresholdSatisfied": true,
"scope": "immutable static assets and mutable /config.json delivery" "scope": "immutable static assets and mutable /config.json delivery"
} }
} },
"note": "`repoPath` is this repository's copy and must resolve. `upstreamCanonicalPath` and every `reviews[*].sourcePath` name the reviewing workspace, not this tree; they are provenance labels and are deliberately not resolvable here. `canonicalSha256` is what binds the two, and the gate checks it appears in `repoPath`."
} }
@@ -462,6 +462,103 @@ The full `tests/unit` + `tests/integration` run is **1,845 passed / 1,864**,
cgroup, RLIMIT and `/tmp` permission behaviour already recorded above — the same cgroup, RLIMIT and `/tmp` permission behaviour already recorded above — the same
file failed identically before this work. No adapter test fails. file failed identically before this work. No adapter test fails.
## Operational contract review (2026-08-15)
A fourth review looked past the adapter layer at the operational contract:
feature on/off, environment separation, folder boundaries, and which gates were
actually green. It found five red gates and three structural gaps. Every row
below names the defect, not the symptom.
| id | area | disposition | what was actually wrong |
| --- | --- | --- | --- |
| `OPS-01` | release | `FIXED` | `public/` is copied verbatim into `dist/`, so every build — production included — shipped the local runtime document. Runtime config now comes from `config/runtime/<profile>.json`. |
| `OPS-02` | release | `FIXED` | Release coherence proved the artifacts agreed with each other, never that they belonged in production. `FE-GATE-027` refuses an artifact whose `APP_ENV`, auth mode, endpoints or build identity do not match a declared `RELEASE_TARGET`, and refuses an undeclared target outright. |
| `OPS-03` | runtime | `FIXED` | `REQUEST_TIMEOUT_MS` was validated and then never passed to the V3 executor; every operation ran on its contract's own deadline. It is now a ceiling that may tighten a contract, never loosen one. |
| `OPS-04` | build | `FIXED` | `VITE_ROUTER_BASE_PATH` drove the router and the Service Worker scope but not Vite's asset `base`, so a sub-path deployment emitted root-absolute assets. One value now feeds all three. |
| `OPS-05` | provider | `FIXED` | bubblewrap 0.9.0 drops whatever follows the option stream inside an `--args` file, so the sandboxed command was never executed: bwrap printed usage and exited 1. Options stay hidden; the command travels on real argv. |
| `OPS-06` | provider | `FIXED` | The scope wrapper read its liveness pipe through `fs`, a blocking `read(2)` on a pipe the supervisor never closes. `process.exit` deadlocked joining that thread, so a completed provider was reported as a timeout kill. |
| `OPS-07` | release | `FIXED` | `mkdir`/`open` modes were left to the ambient umask, so a hardened runner produced directories it could not enter and handed `tar` a file it could not re-open. |
| `OPS-08` | release | `FIXED` | Promotion cleanup deleted this promotion's exact five through a pinned descriptor and only then noticed the leaf had been substituted, leaving a half-emptied directory a retry could not distinguish from a completed one. |
| `OPS-09` | removability | `FIXED` | The removal fixture was not a repository, had no `.gitignore`, and each removal script kept its own copy-target list that had drifted. Supply-chain generation therefore failed inside every fixture and took the whole provider suite down with it. |
| `OPS-10` | removability | `FIXED` | A platform integration file asserted the reference feature's route ids, so removing the feature left it importing a deleted module. The assertion moved to the feature's own test tree. |
| `OPS-11` | removability | `FIXED` | A removal fixture runs against a deliberately reduced CI contract; the canonical exact-count tests re-imposed the full authority on it and failed the fixture for the reduction it exists to prove. |
| `OPS-12` | browser | `FIXED` | Four browser-capability specs answered capability requests without the `protocol` field the hardened envelope requires, so every capability was refused and the download and part-upload paths asserted against an empty transcript. |
| `OPS-13` | browser | `FIXED` | A refused capability document answered `recovery: NONE`, contradicting both the design record and the vault, which already answers `REISSUE_CAPABILITY`. |
| `OPS-14` | performance | `FIXED` | Playwright matches accessible names by substring, so the navigation entry matched the home page's call to action too; the run died on a strict-mode violation before the first measurement and produced no evidence at all. |
| `OPS-15` | visual | `FIXED` | The platform overview baseline predated the reference routes moving from `integration-defined` to `session-required`, so the only visual gate covering that page failed for its own staleness. |
| `OPS-16` | architecture | `FIXED` | `src/contracts` imported `src/application` for the shared `Result` and the compatibility predicate; neither package owned the shared vocabulary. Both moved down to contracts. |
| `OPS-17` | architecture | `FIXED` | The documented "no adapter depends on another concrete adapter" rule had no executable form, and `diagnostics` imported a guard out of `telemetry`. The guard moved to the adapter kernel and the rule is now enforced with a same-directory backreference. |
| `OPS-18` | architecture | `PARTIAL` | Generic presentation still reads the installed-feature registries. The rule freezes the exact set of modules doing so today; a new edge fails. Lifting the assembly into `bootstrap` is not done. |
| `OPS-19` | documentation | `FIXED` | README and the manual accessibility checklist both claimed six routes while ten were registered, leaving four screens outside the declared manual review scope. The list is now derived from the route registry by `verify:documentation`. |
### Product feature selection (2026-08-15, second pass)
| id | disposition | what changed |
| --- | --- | --- |
| `OPS-20` | `FIXED` | Which features a build contains is now a declared manifest rather than five registries spreading a literal. `VITE_PRODUCT_FEATURES` narrows it at build time; a test fails if a new registry forgets to consult it. |
| `OPS-21` | `FIXED` | `FEATURE_OVERRIDES` in the runtime document takes an installed feature out of service without a rebuild. The router refuses its routes, not just the navigation, so a typed deep link cannot still mount it. |
| `OPS-22` | `FIXED` | Both inputs are subtractive by vocabulary: the override enum has no `ENABLED`, and a build-time selection naming a feature the source tree does not declare is refused rather than ignored. |
| `OPS-23` | `FIXED` | A sandbox that fails to launch now reports why. The supervisor consumed the child's output only to enforce a byte cap and discarded it, so a host restriction surfaced as an unexplained `exit=1`. Lines the sandbox tooling itself emits are kept; provider output is still discarded. |
An env var does **not** shrink the bundle, and the code says so. A static import
cannot be undone by a value, and making the import graph depend on a
configuration string is what §3.5 exists to prevent. Measured: `none` changes
the output by 58 bytes. Physical removal is FE-GATE-020's job.
### Host restriction discovered during this pass
`bwrap --unshare-net` no longer works on this machine:
```
$ printf '%s\0' --unshare-net --ro-bind /usr /usr ... | bwrap --args 3 -- /bin/true
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1
```
That reproduction contains none of this repository's code. Earlier in the same
session the identical sandbox ran to completion, so the restriction became
active partway through. While it holds, 16 of the 108 provider tests cannot run
here — they need a sandbox the kernel will not grant. They are not counted as
green and not counted as product defects; under a host that permits the
namespace the same file was 107/108.
### Still red after this pass
*applies effective aggregate cgroup limits without exposing command or
credentials* was rewritten. It used to read the live process tree with one
`ps` per pid and assert mid-run, which lost a race against a sandbox that now
completes in a few hundred milliseconds; it records the tree from `/proc` every
5ms and asserts on the recording after the run. That restructuring is also what
revealed the host restriction above — the supervisor had been failing to launch
the sandbox and the test was dying on the observation first.
Tests that spawn processes, build archives and sign evidence were given a
30s budget instead of the 10s default sized for pure-JS unit tests. The default
was not raised: that would hide a genuinely hung test.
### FE-GATE-020 after this pass
| fixture | before | after |
| --- | ---: | ---: |
| reference feature | failed before its first assertion | 1,612 pass / 1 fail |
| optional recipe | 39 failures | 1,386 pass / 2 fail |
| browser file + storage | 40 failures | 1,006 pass / 3 fail |
| realtime | not reached | 1,159 pass / 1 fail |
Every remaining failure is one of the three environment-limited tests above.
Lab performance now produces evidence, and that evidence shows the
named-interaction budget missed on this machine (367724ms against 200ms). The
metric measures a full lazy-route navigation while the budget is an
INP-shaped 200ms, so the two do not describe the same thing. No budget was
changed to make this green.
WebKit remains unavailable in this environment (`libevent-2.1-7t64`,
`libavif16` are not installed), so 14 browser-capability specs and the WebKit
E2E project are unverified here. Chromium and Firefox are 28/28 and visual is
5/5.
## Rules for updating this ledger ## Rules for updating this ledger
- A row moves out of `NOT_STARTED` only with a linked red test, its green run, and the commit id. - A row moves out of `NOT_STARTED` only with a linked red test, its green run, and the commit id.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
# TechLog UI migration baseline
## Source provenance
- Source path: `/home/donghyeon/workspace/techlog-studio-frontend`
- Inspected: 2026-08-15 (Asia/Seoul)
- Git revision/status: unavailable. The supplied source directory is an exported
checkout with no `.git` metadata; both `git rev-parse HEAD` and
`git status --short` report that it is not a Git repository. The source path
and the byte checksums below are the reproducible provenance available for
this baseline.
## Approved copied assets
| Source and target-relative path | SHA-256 |
| --- | --- |
| `public/favicon.svg` | `e6d2e59b7b5bbb0342e0fb496dfc262decbfe4426bbb7b047aec8d467d1dc6f7` |
| `public/media/fetch-strategy-boundary.svg` | `b07926823ed77fc200f962a1f64a440e130cefa6bed31144b611612af9b02606` |
Only these two SVGs are approved for this baseline. They must remain exact
byte-for-byte copies of the source assets. The evidence key
`fetch-strategy-boundary` resolves to `/media/fetch-strategy-boundary.svg`
with dimensions `1080 × 420`, trigger label `Fetch Join과 Batch Fetch 비교
다이어그램 크게 보기`, and dialog label `Fetch Join과 Batch Fetch의 페이징 경계
확대`.
## Required dependency pins
| Package | Exact version |
| --- | --- |
| `pretendard` | `1.3.9` |
| `@fontsource/ibm-plex-mono` | `5.3.0` |
| `unified` | `11.0.5` |
| `remark-parse` | `11.0.0` |
| `remark-gfm` | `4.0.1` |
| `remark-directive` | `4.0.0` |
No Next.js, Vinext, or Cloudflare package is part of the migration baseline.
## Expected route inventory
| Layout | Route ID | Path |
| --- | --- | --- |
| PUBLIC | `TECH_LOG_HOME` | `/` |
| PUBLIC | `TECH_LOG_EXPLORE` | `/explore` |
| PUBLIC | `TECH_LOG_EXPLORE_KIND` | `/explore/:kind` |
| PUBLIC | `TECH_LOG_CASE` | `/cases/:slug` |
| PUBLIC | `TECH_LOG_REFERENCE` | `/references/:slug` |
| PUBLIC | `TECH_LOG_QUESTION` | `/questions/:slug` |
| PUBLIC | `TECH_LOG_TOPIC` | `/topics/:slug` |
| PUBLIC | `TECH_LOG_PROJECTS` | `/projects` |
| PUBLIC | `TECH_LOG_PROJECT` | `/projects/:slug` |
| PUBLIC | `TECH_LOG_PROJECT_RECORDS` | `/projects/:slug/records` |
| PUBLIC | `TECH_LOG_PROJECT_DECISIONS` | `/projects/:slug/decisions` |
| PUBLIC | `TECH_LOG_PROJECT_ACTIVITY` | `/projects/:slug/activity` |
| PUBLIC | `TECH_LOG_RELEASES` | `/releases` |
| PUBLIC | `TECH_LOG_RELEASE` | `/releases/:version` |
| PUBLIC | `TECH_LOG_PROFILE` | `/profile` |
| PUBLIC | `TECH_LOG_SEARCH` | `/search` |
| STUDIO | `TECH_LOG_STUDIO_HOME` | `/studio` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENTS` | `/studio/documents` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_NEW` | `/studio/documents/new` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_EDIT` | `/studio/documents/:id/edit` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_VALIDATION` | `/studio/documents/:id/validation` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_PREVIEW` | `/studio/documents/:id/preview` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_PUBLISH` | `/studio/documents/:id/publish` |
| STUDIO | `TECH_LOG_STUDIO_PUBLICATIONS` | `/studio/publications` |
| STUDIO | `TECH_LOG_STUDIO_PUBLICATION_PREVIEW` | `/studio/publications/:publicationEventId/preview` |
| STUDIO | `TECH_LOG_STUDIO_NOT_FOUND` | `/studio/*` |
| PUBLIC | `NOT_FOUND` | `*` |
## Source stylesheet inventory
- `app/globals.css``presentation/styles/globals.css`
`901205054ee96fe15062aaef7ff39c701985fdece15b58f7656776d0b744e607`
- `app/studio.css``presentation/styles/studio.css`
`d0d958baeb55b74796988d8c0967a7c62d211e595224e07912aa1f884e466fdd`
- `app/studio-editor.css``presentation/styles/studio-editor.css`
`ae9a473192d24465021d021cd42a5a84f9bdf4c3af0664537b4e10006ca1f889`
- `components/studio/workflow.module.css`
`presentation/styles/workflow.module.css`
`8eaa478a88ef4d16429c7f6a32bb33631acb47deef0b40838edd0a72748f124e`
- `components/studio/publication-flow.module.css`
`presentation/styles/publication-flow.module.css`
`5b1fdebeb27248b5cebc700b12d15cf11fe70471a394c9dc643a98c4569e8674`
All five source/target pairs passed `cmp -s` and have identical SHA-256
digests. Production parity testing corrected the provisional bootstrap
assumption in the design: the target loads the byte-identical TechLog
`globals.css`, including its first-byte `@import "tailwindcss";`, exactly once
and does not load the starter `theme.css`. This reproduces the source cascade;
the resulting browser comparison is pixel-identical.
## Source-to-target browser parity
The source was copied to `/tmp/techlog-source-parity.I0CBK7` before build and
runtime caches were created. The supplied source directory was used only for
read-only file comparison; it was not edited. The temp-copy Vinext production
server on `4375` and the target `dist/server.mjs` production artifact on `4174`
were captured by one Playwright Chromium instance with two fresh contexts
under identical conditions:
- `ko-KR`, `Asia/Seoul`, light color scheme, reduced motion, device scale 1,
service workers blocked, and a fixed `2026-08-14T01:00:00.000Z` clock;
- 1000-pixel viewport height, full-page screenshots, `document.fonts.ready`,
Pretendard/IBM Plex font checks, zero-duration animation/transition/caret;
- no screenshot masks; exact RGBA pixel comparison plus normalized recursive
product-subtree tags, ordered children, complete classes, relevant
attributes, text/ARIA relationships, response metadata, boot lifecycle, and
console/request failure comparison.
The only attributes omitted by name after concrete diagnostics are React
Router `data-discover`, Next Image `data-nimg`/`decoding`/`srcset`, and Next SSR
`selected`; generated React IDs and CSS-module hashes are normalized by value.
No broad attribute class is omitted.
| Evidence group | Cases |
| --- | ---: |
| 27 canonical routes at 360 and 1440 pixels | 54 |
| Every additional known Public fixture slug/version | 18 |
| Public home breakpoint transitions | 12 |
| Studio screen/state fixtures | 19 |
| Ten unknown Public dynamic shapes at 360 and 1440 pixels | 20 |
| Search/menu/preview/dialog/publication interactions | 7 |
| **Total** | **130** |
Result: 130/130 passed, zero failed, zero different pixels, all recursive
DOM/class/attribute/text/ARIA trees and HTTP response metadata equal, and zero
unexplained source or target console/request errors. The target-only visual
regression suite exercises the same 130 cases against 129 committed PNGs; the
canonical and Studio-state 1440-pixel
`/studio/publications` cases intentionally share one identical snapshot.
Source screenshots were temporary comparison inputs and were not copied into
the target snapshot directory.
The checked clean-checkout command is
`TZ=Asia/Seoul corepack pnpm verify:tech-log-source-parity`; it runs directly
under the installed Node 24 runtime and has no `tsx` dependency. Durable
evidence is committed at
`docs/operations/evidence/tech-log-source-parity.json`. It records 178 source
checksums with tree digest
`6724c2f898eefc62d2fc0ee695bccc3ae61a69c5153ed43c69f2cf99ee45bca5`,
target candidate `3a7c5deca06679fb9b8710da2cce87bbca07ce8a`, build-manifest digest
`3579650faa482f566553c00d8b4a05a05b4f7a1ab93b83e48676289bbcf02984`,
Vite-manifest digest
`cfd583ed7b6c27dce7f47389447608636b6b9df3e28df00c6416674da2c7c46d`,
case inventory digest
`5689bcdb5d5637205cdeb92b7c57f72d99f0b039818b8f90afdde526bbafe0ac`,
and evidence payload digest
`f046047beded19ce468be607dcf99c6b74d4e07323457ec7145c56d2f67d2c79`.
The production source responds to both a missing dynamic slug such as
`/projects/missing-project` and an unmatched path such as
`/definitely-not-a-product-route` with HTTP 404,
`text/plain;charset=UTF-8`, and the exact nine-byte body `Not Found`. The
target production server intentionally removes the Public shell for those
paths and matches that response in direct HTTP and Chromium regressions. Known
Public routes and all known Studio routes remain SPA-served; `/studio/*`
unknown paths preserve the source's in-shell HTML with HTTP 404. This is the
observed production source contract, not a generic runtime error.
## Accessibility review boundary
Automated Chromium `@a11y` coverage passes 29/29. The source-controlled manual
inventory contains exactly the 27 installed route IDs and removes the obsolete
starter records. Every human record remains `pending-manual-review` until one
reviewer evaluates keyboard, focus, modal/error behavior, color, reduced
motion, and screen-reader output against immutable candidate
`3a7c5deca06679fb9b8710da2cce87bbca07ce8a`. Therefore
`corepack pnpm review:a11y-manual` intentionally exits nonzero with 27
incomplete records and `release IDs do not match`; neither the manual gate nor
`FE-GATE-009` is represented as passing. The exact completion format is in
`docs/accessibility/manual-checklist.md`, and signed evidence must be committed
separately after it cites the candidate SHA.
## Governed route migration
The migration records 23 TechLog-owned breaking-change evidence IDs with owner
`tech-log-frontend`, an atomic same-release route/runtime/manifest migration,
and rollback to `05e3d50ba01f01c27f257d2e9040c2bc413ea053`. The accepted registry
snapshot digest is
`428479ac5845374a82dd7d02a0c59a713106405f031cdc153789174f14c1405b`;
its approval reason is `Install approved TechLog Public and Studio route
contract`. Final compatibility impact is `none` with no unacknowledged change.
## Restricted-environment test baseline
The exact repository aggregate command was run in the managed workspace:
```bash
corepack pnpm test:all
```
Its fresh staged-candidate runtime-schema phase passed 3 files/40 tests. The
unit phase passed 122 files/1,773 tests and reported 19 failures, all in the
pre-existing `ci-artifact-contract` provider/cgroup, RLIMIT/EMFILE,
restrictive-umask, `/tmp`, timing, and identity environment cases. No
`tests/features/tech-log` test failed. A pre-staging diagnostic run also found
39 `APP_HOME` release-inventory failures because new serving files were not yet
visible to `git ls-files`; staging the complete candidate fixed that test
precondition, and all 39 disappeared. The same run's one guardian aggregate
timeout passed 1/1 in isolation and 21/21 in the fresh staged aggregate.
The failing files were then reproduced in isolation outside that child-process
restriction:
```bash
corepack pnpm exec vitest run tests/unit/ci-artifact-contract.test.ts tests/unit/ci-workflow-generation.test.ts tests/unit/http-scenario-evidence.test.ts
```
Result: 3 files/529 tests, 510 passed and the same 19 environment-only
`ci-artifact-contract` cases failed. The 407 `ci-workflow-generation` and 14
`http-scenario-evidence` tests all passed. These 19 contain no TechLog code or
test. The aggregate phases after unit were also run directly: component 18
files/124 tests, integration 11 files/82 tests under the required child-process
scope, reference feature 4 files/13 tests, and recipes 2 files/17 tests all
passed.
The inventories in this document are human review baselines. Automated
coverage is intentionally limited to the two SVG byte contracts, evidence
asset lookup behavior, package manager frozen-lockfile verification, committed
target visual regressions, and governed registry/release gates. The external
source path is never required by committed CI tests.
@@ -0,0 +1,277 @@
# Template merge — `main` → `feature/techlog-ui-migration`
Record of how the frontend template sync on `main` was integrated into the
TechLog UI migration branch, and of every decision taken to resolve a conflict.
## Why this direction first
The template sync landed on `main` while the UI migration ran in a worktree.
Two orders were possible.
Merging the feature branch into `main` first would have resolved 15 conflicts
directly on the integration branch: a bad resolution would already be on `main`,
and it would have to be repaired forward, on the branch other work depends on.
Merging `main` into the worktree first keeps the resolution where the UI work
lives. Every gate runs against the resolved tree before anything reaches `main`,
and a resolution that turns out wrong is discarded by resetting one feature
branch. `main` is then only ever fast-forwarded, so it never holds a state that
was not already proved in the worktree.
That is the order used here.
## Starting state
| | |
| --- | --- |
| merge base | `325a2a0` |
| `main` | `bdee07a``chore: sync the frontend template from a0fbafb to 5434760`, 1 commit, 101 files, +3116/448 |
| `feature/techlog-ui-migration` | `0355b64` — 29 commits |
| files changed by `main` | 101 |
| files changed by the feature branch | 423 |
| overlap | 23 |
Rollback refs were created before touching anything:
```
backup/ui-before-template-merge → 0355b64
backup/main-before-ff → bdee07a
```
### Pre-merge baseline on the feature branch
Captured so that a pre-existing failure could not be mistaken for a merge
regression.
| Gate | Result |
| --- | --- |
| `check:types` | pass |
| `lint` | pass |
| `verify:documentation` | `PASS_SCOPED` |
| `tests/unit` + `tests/component` | 1815 passed / 101 failed |
The 101 failures were confined to `tests/unit/ci-artifact-contract.test.ts` (19)
and `tests/unit/ci-workflow-generation.test.ts` (82) — sandbox subprocess gates
that cannot run in this environment.
## Conflicts and how each was decided
15 conflicts. The rule applied throughout: **keep the template's mechanism, keep
the product's content, and never invent a third state that neither branch
would accept.**
### Deletions the UI migration made deliberately (2)
| Path | Decision |
| --- | --- |
| `src/presentation/examples/platform-overview-page.tsx` | deletion kept |
| `tests/visual/.../platform-overview-light-chromium-visual-linux.png` | deletion kept |
`main` modified both; the feature branch deleted them in `c5c8b94`. Nothing on
the branch references either, so the deletion stands.
### `src/features/installed-feature-contracts.ts`
The template introduced a product manifest: registries are composed from
`INSTALLED_PRODUCT_FEATURES` so that narrowing the selection withdraws a
feature's routes, operations, schemas and messages.
The manifest composition is kept for operations, schemas, mappers and
invalidation. The **route registry is deliberately not composed from
`contract.routes`**, because the reference feature still declares
`REFERENCE_RESOURCE_*` routes whose screens this product deleted during the
migration. Reducing over them would register paths with no component behind
them — a typed deep link resolving to nothing.
The registry therefore lists the platform routes (empty on this branch) and
TechLog's. This was caught by running the gates: the first resolution did
compose from `contract.routes`, and `tests/unit/product-features.test.ts`
rejected it.
`ROUTE_FEATURE_OWNER` was narrowed to registered routes for the same reason.
Attributing an unregistered route to a feature claims the kill switch governs
something no router can mount.
### `src/features/installed-feature-runtimes.tsx`
The template gates the reference feature's route codecs and components on the
manifest. This product deleted that feature's presentation layer, so the import
does not resolve and there is nothing to gate. The gating was removed and the
reason recorded in the file; it belongs back the day those screens return.
Consequence: this file no longer references the manifest, so it was moved to the
exempt list in `tests/unit/product-features.test.ts` with the same note.
`tests/component/product-feature-switch.test.tsx` was rewritten to hold the
invariant that still applies here — no registered route without a component —
which is exactly the trap the first resolution fell into.
### `src/features/installed-feature-adapters.ts`
Both the manifest check and TechLog's input are kept. The reference feature's
input stays `Partial` because the manifest may narrow it out; TechLog's is total
because it is this product's own UI and is always installed. A build that
narrows the reference feature out still ships TechLog.
### `src/features/installed-feature-messages.ts`
The template's rationale — message keys stay total so `message()` cannot become
partial — is kept, and TechLog's catalog is merged in on the same terms.
### `src/presentation/routes/app-router.tsx`
The template looked the route definition and runtime component up by id inside
the route element; this branch passes both as props from the grouped route
contract. The prop-driven signature is kept.
The template's **feature kill switch is adopted**: a route whose owning feature
the runtime document disabled renders the disabled surface instead of mounting.
Withdrawing it from navigation alone would leave a working deep link. `getRoute`
was dropped from the imports because the definition arrives as a prop.
### `vite.config.ts`
Two independent additions — TechLog route chunking and `routerBasePath` — both
kept. A brace was lost in the first concatenation and caught by `check:types`.
### `scripts/build-frontend.ts`
Both new steps exist in the merged body, so the header comment was renumbered:
runtime config becomes step 4, the TechLog serving boundary step 7, the release
manifest step 8, and the inline step comments were corrected to match.
### `scripts/test-performance.ts`
The template clicked a navigation link before measuring; this product measures
its own landing route, which `goto` already reached, and has no `targetLabel`.
The click was dropped. The template's lesson was kept as a comment because it
applies to the next click that lands here: Playwright matches accessible names
by substring, so a nav entry can also match a call to action and resolve to two
links — a strict-mode violation that produces no performance evidence at all.
### Derived baselines — recomputed, not chosen (3)
`scripts/contracts/ci-gates.ts`, `tests/unit/task3-selective-integration.test.ts`
and `tests/unit/ci-workflow-generation.test.ts` each pin counts and a digest
describing the gate contract. **Neither side's numbers describe the merged
`config/ci/gates.json`**, so taking either would have been wrong. They were
recomputed from the merged file:
| Value | Result |
| --- | --- |
| canonical gate shape SHA-256 | `5063586d799f51de94c0f0ddaf9b75e180825bba5051bc309550425013ea81ef` |
| gates | 27 |
| commands | 82 |
| command references | 94 |
| evidence artifact references | 107 |
| artifacts | 128 (126 product + 2 from the template) |
### `README.md`, `docs/accessibility/manual-checklist.md`
The template enumerated its own example routes. Replacing them with generic
prose broke `verify:documentation`, which requires both documents to name every
installed route id — a rule the template sync itself introduced. Both documents
now enumerate this product's 27 routes.
## Result
Merge commit parents: `0355b64` (UI) and `bdee07a` (template).
### Gates on the merged tree
| Gate | Result |
| --- | --- |
| `check:types` | pass (6 projects) |
| `lint` | pass, `--max-warnings=0` |
| `check:architecture` | 399 modules, 1232 dependencies, 12 fixtures pass |
| `check:adapter-inventory` | 120 files, 5 importers of the shared abort primitive |
| `check:remediation-ledger` | 25 dispositions, 0 open |
| `check:diagnostics` | 8 diagnostics, 5 telemetry producers |
| `check:i18n` | 197 keys, 4 locales |
| `check:design-system` | 48 tokens |
| `verify:documentation` | `PASS_SCOPED` |
| `test:integration` | 81 passed |
| `test:recipes` | 17 passed |
| `test:runtime-schema` | 40 passed |
| `tests/unit` + `tests/component` | 1851 passed / 98 failed |
The 98 failures are the same two sandbox files as the baseline —
`ci-workflow-generation` (82) and `ci-artifact-contract` (16, down from 19). No
file fails that did not fail before the merge.
## Mistake made during this merge
`git stash` was run inside the worktree while the merge was still in progress,
to compare a gate against the pre-merge tree. That removed `MERGE_HEAD`: the
resolved content survived, but git no longer knew a merge was underway, and
committing then would have produced a single-parent commit — leaving `main` off
the ancestry and breaking the fast-forward that step 2 depends on. `MERGE_HEAD`
was restored to `bdee07a` before committing, and the resulting commit has both
parents.
To compare against a pre-merge state, use a separate checkout rather than
stashing an in-progress merge.
## Correction after review
Two of the resolutions above were wrong, and were fixed in a follow-up commit.
The template's demonstration screens — `platform-overview-page`, the UI and
state galleries, the auth example and the reference feature's screens — exist to
explain the template. A product replaces them with its domain, and deleting them
is the expected end state, not a regression. Two gates were nevertheless coupled
to them, and the first resolution accommodated that coupling instead of fixing
it.
### `tests/unit/product-features.test.ts` — a hard-coded exemption became a rule
The guard requires every installed registry to compose from the manifest.
`installed-feature-runtimes.tsx` was added to its exempt list once the reference
feature's presentation layer was gone. That silenced the guard for that file
permanently.
It now derives its own scope: a registry must gate on the manifest **when it
imports a module belonging to a manifest-declared feature**. A product whose
registries compose only its own domain drops out of the rule honestly, and the
guard fires again the moment a declared feature is imported without gating —
verified by removing the manifest reference from
`installed-feature-adapters.ts` and watching the guard fail. A counter asserts
the sweep is still watching at least one file, so an empty scope cannot pass
silently.
### `tests/component/product-feature-switch.test.tsx` — coverage restored
The end-to-end kill-switch assertions were replaced with composition checks
because the screens they rendered were gone. The mechanism under test is the
ownership lookup plus `isFeatureActive`, which has nothing to do with which
screens ship, so **the ownership map is now the fixture**: one real registered
route is attributed to a real installed feature, and the router, shell,
components and codecs are all the product's own. The deep-link half is asserted
end to end again.
### What that restoration exposed
The navigation-withdrawal half **is not implemented in this product**. It lives
in the template's `PrimaryNavigation`, and this product does not render the
template's `AppShell` at all — the public site header is a hand-written list of
paths in `src/features/tech-log/presentation/public/components/site-header.tsx`,
and the studio has its own shell.
So a disabled feature's route is refused by the router but its link would still
be advertised. That is harmless only while no feature-owned route is navigable,
which is true today and is now asserted. If that assertion fails, the header has
to consult `ROUTE_FEATURE_OWNER` — or navigation has to move back onto
`NAVIGATION_ROUTES` — before the route ships.
## Follow-ups this merge deliberately did not decide
1. **TechLog is outside the product manifest.** It is composed directly rather
than as a `SelectableProductFeature`, so the runtime kill switch does not
govern it. That is defensible — a product's own domain is not an optional
feature — but it means the switch governs nothing user-visible today.
2. **The reference feature declares routes it cannot serve.** Its screens were
deleted with the rest of the demonstration UI, but its contract still
declares `REFERENCE_RESOURCE_*` routes. Either the declarations go, or the
feature does. `TechLog` does not import it (`git grep reference-feature --
src/features/tech-log` is empty), so removing it is a live option and
FE-GATE-020 exists to prove it can be removed.
3. **The public site header is not feature-aware.** See above.
@@ -0,0 +1,394 @@
# Tech Log 운영 출시 전 체크리스트 — 실측 검증 보고서
**검증일** 2026-08-19 · **방식** 두 저장소를 로컬에서 실제 기동해 엔드포인트·브라우저 단위로 실측
| 대상 | 위치 | 리비전 |
|---|---|---|
| Frontend | `tech-log-frontend` | `main` eb86708 → `fix/release-gate-frontend` fff5e6f |
| Backend | `tech-log-backend` | `develop` ab0447a |
| Keycloak | 로컬 컨테이너 `local-keycloak` | 26.7.0 (`:18080`) |
| PostgreSQL | 로컬 컨테이너 `techlog-pg` | 16.15 (`:5433`) |
---
## 요약 판정: **출시 보류 (P0 미충족)**
체크리스트 §31의 P0 항목 중 **인증 우회 불가 · 인가 우회 불가 · Studio 주요 기능 정상 · Publish 정상**이
현재 충족되지 않는다. 아래 근거는 전부 실행 결과다.
### 가장 중요한 구조적 사실
백엔드는 계약(`studio-v1.yaml`)이 선언한 **18개 오퍼레이션 중 2개**만 구현되어 있다.
| 상태 | 오퍼레이션 |
|---|---|
| 구현됨 (2) | `getStudioSession`, `listStudioCatalog` |
| 미구현 (16) | `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioAssets`, `uploadStudioAsset`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` |
또한 **Public 읽기 엔드포인트는 계약에 아예 없다.** `studio-v1.yaml`은 Studio 전용이고,
프론트엔드의 Public 화면(`/`, `/explore`, `/projects`, `/releases`, 문서 상세)은
`src/features/tech-log/adapters/static/public-content.ts`의 **번들에 컴파일된 정적 콘텐츠**를 읽는다.
따라서 체크리스트의 다음 절은 검증 대상 자체가 존재하지 않는다:
§2(탐색·검색·프로젝트·변경기록의 백엔드 연동), §3.1~3.3(문서 작성·관계·Publish),
§12(Public/Private 데이터 경계), §17(파일/Object Storage), §29(E2E 시나리오).
---
## P0 — 출시 차단 결함
### P0-1. Studio 라우트가 인증을 검사하지 않았다 — **수정 완료**
`TECH_LOG_ROUTE_REGISTRY`가 모든 TechLog 라우트를 `access: "public"`으로 등록하고 있었다.
라우터에 `decideRouteAccessForDefinition` 가드가 존재하지만 Studio에 대해 무력화된 상태였다.
운영 프로파일 빌드(`AUTH_MODE=external`)로 실측한 수정 전:
```
/studio http=200 h1="작업 흐름" ← 비로그인 상태에서 Studio UI 렌더링
/studio/documents http=200 h1="작업본"
/studio/assets http=200 h1="Asset"
```
`spec.layoutGroup === "STUDIO"`에서 `access`를 유도하도록 수정한 뒤:
```
/studio http=200 h1="로그인 연동이 필요합니다."
/studio/documents http=200 h1="로그인 연동이 필요합니다."
/ , /explore 변화 없음
```
로그인 후 원래 요청 화면으로 복귀하는 것도 확인했다(`/studio/documents` → 로그인 → `작업본`).
커밋 `fff5e6f`.
### P0-2. 백엔드 Studio API에 인가 검사가 없다 — **미해결**
`SecurityConfig``anyRequest().authenticated()`로 끝나고, Studio 컨트롤러에
`@RequiresPermission` 계열 애노테이션이 **하나도 없다**.
Keycloak에 Studio 권한이 없는 사용자(`plain`, realm role `plain-user`)를 만들어 확인:
```
GET /api/v1/studio/catalog?type=TOPIC
studio 사용자 (studio-author) → HTTP 200
plain 사용자 (권한 없음) → HTTP 200 ← 인가 우회
```
체크리스트 §11 "인증된 사용자라고 해서 무조건 Studio API를 호출할 수 있지 않다",
§31 P0 "인가 우회 불가" 미충족.
### P0-3. 모든 Studio 경로가 `/api/api/v1/...`에 매핑된다 (Double Prefix) — **미해결**
`PresentationWebConfig``configurer.addPathPrefix("/api", c -> true)`로 전 컨트롤러에
`/api`를 붙이는데, Studio 컨트롤러는 `@GetMapping("/api/v1/studio/...")`로 이미 `/api`를 포함해 선언한다.
```
GET /api/v1/studio/catalog → 404 ROUTE_NOT_FOUND
GET /api/api/v1/studio/catalog → 200
GET /api/v1/studio/session → 404 ROUTE_NOT_FOUND
GET /api/api/v1/studio/session → 503
```
프론트엔드는 계약대로 `/api/v1/studio/...`를 호출하므로 **현재 상태로는 단 한 건도 연결되지 않는다.**
체크리스트 §25 "`/api` Prefix 처리에서 Double Prefix가 발생하지 않는다" 미충족.
### P0-4. `getStudioSession`이 항상 503을 반환한다 — **미해결**
`auth-mode: jwt`(저장소 기본값, `src/.env:115`)에서 `SecurityConfig``csrf.disable()`
`CsrfFilter`를 제거하므로 `CsrfToken` 파라미터가 항상 `null`이고, 컨트롤러는 이를
`STUDIO_UNAVAILABLE`(503)로 정직하게 보고한다.
```
GET /api/api/v1/studio/session (유효한 studio 토큰)
→ 503 {"code":"STUDIO_UNAVAILABLE","category":"TRANSIENT_DEPENDENCY","retryable":true}
로그: "CSRF token unavailable: CSRF protection is disabled for the active auth-mode"
```
프론트엔드 HTTP 모드는 `getStudioSession`으로 CSRF 토큰을 받아 부트스트랩하므로,
**이 한 건 때문에 Studio HTTP 경로 전체가 시작조차 못 한다.**
`auth-mode: redis-session`에 필요한 세션 빈이 저장소에 없다는 점은 백엔드 HANDOFF.md도 명시하고 있다.
### P0-5. `main` 브랜치의 dev 부팅이 깨져 있었다 — **수정 완료**
`eb86708`(계약 3.0.0 머지) 이후 `public/release-manifest.json`이 2.0.0으로 남아
부팅 시 contract-set 검증이 fail-closed → **빈 화면**. 이전에 한 번 겪은 것과 같은 실패 양식이다.
```
setDigest drift: manifest sha256:e0da7765…, build sha256:261ac630…
package drift: manifest 2.0.0 / ce2e748 vs build 3.0.0 / b20d7a2
```
`generate:dev-release-manifest`로 재생성하고, 같은 값을 하드코딩하던
`tests/runtime-schema/release-manifest.test.ts`도 함께 갱신했다. 커밋 `fff5e6f`.
### P0-6. 커밋된 `.env`로는 prod 프로파일이 부팅하지 않는다 — **미해결**
`src/.env:140``APP_DATASOURCE_DDL_AUTO=update`인데, `application-prod.yml`이 문서화한
`JpaSchemaSafetyValidator`는 prod에서 `none|validate`만 허용하고 위반 시 exit 71로 종료한다.
### P0-7. `ddl-auto=validate`로는 PostgreSQL에서 부팅하지 않는다 — **미해결**
```
SchemaManagementException: Schema-validation: missing table [fs_cleanup_item]
```
`PostgreSqlPersistenceConfig`가 Flyway 위치를 `classpath:db/migration/postgresql`로 고정해
`db/migration/jpa/fileserver` 트리가 **한 번도 적용되지 않는데**, 해당 JPA 엔티티는 스캔된다.
`ddl-auto=update`가 이 사실을 가려 온 것이고, prod가 요구하는 `validate`로 바꾸는 순간 드러난다.
(본 검증은 `ddl-auto=none`으로 우회해 진행했다.)
---
## P1 — 출시 전 해결 권장
| # | 항목 | 실측 근거 |
|---|---|---|
| P1-1 | Keycloak realm 구성이 두 저장소 어디에도 없다 | compose에 keycloak 서비스 없음, realm export 파일 없음. 검증을 위해 `ca-skeleton` realm·클라이언트·audience 매퍼·테스트 사용자를 수기로 생성해야 했다. §27 "Keycloak Realm 설정을 복원할 수 있다" 미충족 |
| P1-2 | 프론트엔드에 로그인 구현이 없다 | OIDC/Keycloak 클라이언트 코드 0건. `AUTH_MODE=external`은 호스팅 페이지가 `window.__CA_FRONTEND_AUTH_OWNER__`를 주입하기를 기대하며, 없으면 `createUnavailableSessionAdapter`가 "로그인 연동이 필요합니다"를 띄운다. §1.4 인증 항목 전부 검증 불가 |
| P1-3 | production 런타임 설정이 플레이스홀더 | `API_BASE_URL: https://api.example.com/`, `TELEMETRY_ENDPOINT: https://telemetry.example.com/v1/events` |
| P1-4 | Rate Limit 비활성 | `APP_RATE_LIMIT_ENABLED=false`, `APP_RATE_LIMIT_PROVIDER=disabled`. 60회 연속 호출 전부 200 |
| P1-5 | 보안 헤더를 적용하는 주체가 없다 | `config/hosting/security-headers.json`에 CSP·HSTS·X-Frame-Options 등이 정의돼 있으나 `dist/server.mjs`**하나도 적용하지 않는다**. `verify:hosting-headers`는 기본적으로 fixture 모드로 동작해 실 서버를 검사하지 않는다 |
| P1-6 | 캐시 정책도 미적용 | `cache-policy.json``/assets/*``public, max-age=31536000, immutable`을 요구하나 실제 응답은 전부 `no-cache` |
| P1-7 | 프론트엔드 배포 아티팩트 부재 | Dockerfile·nginx conf·compose 없음. `dist/server.mjs`는 프리뷰용이지 운영 파일 서버가 아니다 |
| P1-8 | robots.txt / sitemap.xml 없음 | **Studio 경로가 검색 엔진에 차단되지 않는다.** §7 미충족 |
| P1-9 | Open Graph·canonical 메타데이터 없음 | `dist/index.html``og:*`·canonical 없음. `<title>`은 라우트별로 정상 동작하나 **런타임에 설정**되므로 JS를 실행하지 않는 공유 미리보기 크롤러에는 "Tech Log" 고정값만 노출된다 |
| P1-10 | DB 타임아웃 30초 | `APP_DATASOURCE_CONNECTION_TIMEOUT=30000`. `application.yml`이 문서화한 D2 fail-fast 의도(기본 5s)와 어긋난다. 프론트엔드 `REQUEST_TIMEOUT_MS=10000`이므로 DB 장애 시 프론트가 항상 먼저 끊겨 `DB_UNAVAILABLE` 503을 보지 못한다 |
| P1-11 | Tech Log Asset의 Object Storage 배선 없음 | objectstorage 어댑터는 템플릿 자산으로 존재하나 techlog 참조 0건, MinIO/S3 환경변수 0건, `uploadStudioAsset` 엔드포인트 미구현 |
---
## 검증되어 통과한 항목
### Frontend
| 항목 | 결과 |
|---|---|
| Production Build | PASS (local·production 프로파일 모두) |
| TypeScript compile | PASS (`check:types` 6개 프로젝트) |
| ESLint | PASS (수정 후 0 error) |
| 전체 테스트 | 1,818 passed / 16 skipped / **1 기존 flake** (`provider-guardian-transaction` — 단독 실행 2회 모두 PASS, 부하 의존) |
| architecture / contract / dev-release-manifest / browser-security 게이트 | PASS |
| Production 번들에 dev·localhost URL 없음 | PASS (`localhost`·`127.0.0.1` 0건, `.local` 매치는 전부 `locale`/`localeCompare`) |
| Production 번들에 Mock API 미포함 | PASS (`createMockStudioGateway` 0건) |
| Source Map 비공개 | PASS (`.map` 0개) |
| Route 단위 Lazy Loading | PASS (30 청크, 총 954 KB / 최대 569 KB) |
| SPA 라우팅·새로고침 | PASS (열거형 allowlist 방식. 존재하지 않는 문서 경로는 의도적으로 404) |
| Route별 `<title>` | PASS (`탐색 · Tech Log`, `프로젝트 · Tech Log` …) |
| 반응형 | PASS — 360/414/768/1440 × 6개 Public 라우트 **24개 조합 전부 가로 스크롤 없음** |
| 접근성 | PASS — axe(wcag2a/2aa/21a/21aa) **serious+critical 0건** (Public 6 + Studio 4 라우트). h1 정확히 1개, heading 건너뜀 없음, alt 누락 0, 레이블 없는 icon button 0 |
| 로그인 흐름 | PASS (게이트 → 로그인 → 원래 화면 복귀) |
### Backend
| 항목 | 결과 |
|---|---|
| Production Profile Build | PASS — `:app-bootstrap:bootJar` 성공 |
| 전체 테스트 | PASS — **3,530 tests / 0 failures / 7 skipped** (BUILD SUCCESSFUL 8m 9s). app-bootstrap 797 · application-core 568 · cache-redis 423 · fileserver 398 · inbound-web 341 · httpclient 283 · shared-contract 224 · objectstorage 140 · persistence-jpa 122 · 그 외 |
| Docker Image Build | PASS — 623MB. `BUILD_VERSION`/`GIT_SHA`/`SOURCE_URL` build-arg를 강제하는 provenance 게이트가 있어 인자 없이는 의도적으로 실패한다 |
| Production Image 실제 실행 | PASS — 컨테이너에서 14.5초 기동, `healthcheck` 200 · `readiness` 200 · `catalog` 200(실데이터 2건) |
| Flyway 마이그레이션 (신규 DB, 처음부터) | PASS — 6개 적용, V7 techlog core 포함, 테이블 33개 생성 |
| 응답 봉투 일관성 | PASS — `{success,data,error,meta}` 전 경로 동일 |
| HTTP 상태 코드 | PASS — 401 / 404 / 405 / 422 / 500 / 503 모두 적절 |
| 인증 오류 코드 분리 | PASS — `AUTH_TOKEN_MISSING` / `AUTH_TOKEN_MALFORMED` / `AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_EXPIRED` |
| Validation | PASS — 잘못된 enum·필수 누락은 422 + `fieldErrors`, `limit` 상·하한 강제 |
| SQL Injection | PASS — `' OR 1=1--` 파라미터 바인딩되어 빈 결과 |
| Visibility 필터 | PASS — `ARCHIVED` 토픽이 catalog 결과에서 제외됨 |
| CORS | PASS — 허용 origin 200 + `Allow-Credentials: true`, 미허용 origin 403, 와일드카드 없음 |
| 보안 헤더 | PASS — `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Cache-Control: no-store` |
| 오류 정보 노출 | PASS — Stack trace·SQL·내부 클래스명 모두 미노출 (`details: null`) |
| 로그 위생 | PASS — 토큰·Authorization·쿠키·비밀번호 **0건**. `user=`는 가명화 해시 |
| 추적성 | PASS — 모든 요청 로그에 `req=`·`trace=`, `http_request method= uri_template= status= duration_ms=` |
| Metrics | PASS — Prometheus 127개 메트릭 패밀리 (`http_server_requests_seconds_bucket`, `jvm_gc_*`, `jvm_memory_*`, `hikaricp_connections_*`) |
| Liveness / Readiness 분리 | PASS — DB 중단 시 readiness 503 DOWN, liveness 200 UP 유지 |
| 의존성 장애 대응 | PASS(동작) — DB 중단 시 무한 대기 없이 **503 `DB_UNAVAILABLE` (retryable)** 반환, DB 복구 후 27ms/정상 데이터로 자동 회복. 단 응답까지 30초 소요(P1-10) |
---
## 다음 단계 (권장 순서)
1. **Double Prefix 해소** (P0-3) — 컨트롤러 매핑에서 `/api`를 제거하거나 `addPathPrefix` 대상에서 제외. 이걸 고치기 전에는 프론트-백엔드가 한 건도 연결되지 않으므로 최우선.
2. **세션 인프라** (P0-4) — `redis-session` 배선. 백엔드 HANDOFF.md도 Plan 02보다 앞선 선행 작업으로 지목하고 있다.
3. **Studio 인가** (P0-2) — Studio 컨트롤러에 권한 검사 추가 + 권한 없는 사용자 403 회귀 테스트.
4. **prod 부팅 설정** (P0-6, P0-7) — `.env``ddl-auto`, fileserver 마이그레이션 위치.
5. **나머지 16개 오퍼레이션** — 백엔드 HANDOFF.md가 지적한 생성 union 5종의 Jackson 파손 전략 결정이 선행.
6. 배포 레이어 (P1-5·6·7) — nginx/CDN에 보안 헤더·캐시 정책 적용, 프론트엔드 이미지.
7. robots.txt로 Studio 차단 (P1-8).
---
# 2차 검증 — 로컬에서 가능한 항목 완주 (2026-08-19)
1차에서 "물리적으로 불가능"이라 분류했던 항목 중 상당수가 실제로는 검증 가능했다.
Studio는 mock 게이트웨이가 18개 오퍼레이션을 **전부** 구현하고 있고(낙관적 락·검증
staleness·미리보기 만료·경고 승인·멱등성 포함), Public은 정적 콘텐츠지만 UI 동작
항목은 그대로 검증된다. 아래는 그 재검증 결과다.
## 1차 판정 정정
### 정정 1 — P0-6 은 결함이 아니다
`prod` 프로파일이 커밋된 `src/.env`로 부팅하지 않는 것은 **의도된 설계**다.
`application-prod.yml`이 문서화한 5개 startup validator가 개발용 값을 거부한다:
```
JpaSchemaSafetyValidator ddl-auto must be none|validate (exit 71)
FlywayProdSafetyValidator baseline-on-migrate / out-of-order / clean 비활성
StartupSafetyValidator error-detail 노출 · body-capture 로깅 off
PostgreSqlTransportSecurityValidator pgJDBC sslmode=verify-full
PersistenceVendorProdSafetyValidator vendor·URL 모두 H2 금지
```
실측으로 2개가 순서대로 발화하는 것을 확인했다:
```
exit=71 error.code=PROFILE_MISMATCH
"prod profile requires APP_DATASOURCE_DDL_AUTO ... to be none or validate,
but was update; Flyway is the production schema writer"
ddl-auto=none 으로 넘긴 뒤:
"prod PostgreSQL transport requires pgJDBC sslmode=verify-full"
```
**§9 "운영 환경에서 개발용 설정이 활성화되지 않는다"는 PASS**다.
남는 진짜 갭은 별개다 — **운영 값 세트가 저장소에도 배포 시스템에도 아직 없다**(P1로 이동).
### 정정 2 — P0-7 의 심각도 하향
`ddl-auto=validate``fs_cleanup_item` 누락으로 실패하는 것은 사실이나,
prod는 `none|validate` **둘 다** 허용하므로 `none`으로 부팅할 수 있다(실제로 그렇게 기동해 검증했다).
따라서 출시 차단은 아니고, **스키마 검증을 포기해야 한다는 제약**으로 남는다 → P1.
### 정정 3 — 1차의 오탐 2건
- **"Public UI에 Studio 노출"** — 오탐. 매칭된 "Studio"는 전부 게시된 릴리스 노트의 본문
텍스트였다("TechLog Public·Studio 경계를 확정했습니다"). 실제 `a[href^="/studio"]`
모든 Public 화면에서 **0건**. §1.3 PASS.
- **"코드 블록 미표시"** — 오탐. `code-block.tsx``<figure class="code-block">` +
`<pre role="region" tabindex=0>`을 렌더하고 CSS가 `overflow-x:auto`·`max-width:100%`
준다. 정적 공개 문서에 CODE_BLOCK이 0건이라 발견하지 못한 것이며, Studio 편집기에
직접 넣어 확인하니 정상 렌더되고 페이지 가로 오버플로도 없었다. §4 PASS.
## 새로 발견한 결함
| # | 항목 | 근거 |
|---|---|---|
| **N-1** | **로그아웃할 방법이 없다** | `signOut` 포트와 `app-shell.tsx`의 세션 버튼(`action.signOut`="로그아웃")은 존재하지만, **TechLog는 자체 셸(`public-shell.tsx` + Studio 셸)을 쓰고 `AppShell`을 렌더하지 않는다.** 로그인 후 Public·Studio 어느 화면에서도 로그아웃 버튼이 없다. §1.4 "로그아웃", "로그아웃 후 보호된 데이터가 UI 상태에 남지 않는다" 미충족 |
| **N-2** | **중복 관계 생성이 방지되지 않는다** | 같은 대상을 두 번 연결해 저장해도 경고가 없다. 계약에 `uniqueItems` 제약이 없고(`relations: maxItems 20`뿐), `validate-working-copy.ts`도 slug 중복만 검사한다(`SLUG_DUPLICATE`). **백엔드를 구현해도 계약이 허용하므로 같은 결과가 난다.** §3.2 미충족 |
| **N-3** | **CLS 0.192 (기준 0.1)** | 원인 단일: `FOOTER.site-footer`가 t=538ms에 0.1922 이동. 나머지 shift는 0.0001. 세 라우트 모두 동일 값 → 앱 셸 마운트 시점의 footer 점프. §4 "주요 화면의 Layout Shift가 없다" 미충족 |
| **N-4** | **`navigation_path`(slug 조회)에 인덱스가 없다** | 20,000행 기준 `Seq Scan`, `Rows Removed by Filter: 19999`, **236ms**. `enable_seqscan=off`로도 인덱스를 못 쓴다 → 존재하지 않는다. 체크리스트가 명시한 "Slug 조회" 쿼리 패턴 |
| **N-5** | 검색 trgm 인덱스가 플래너에 선택되지 않음 | GIN trgm 인덱스는 존재하고 강제하면 3.96ms로 동작하나, 20k 규모에서 플래너가 Seq Scan(10.3ms)을 고른다. 운영 규모에서 재확인 필요 |
| **N-6** | `/api/v3/api-docs`가 500 | `/swagger-ui`·`/v3/api-docs`는 404로 미배포(정상)인데, path prefix가 붙은 `/api/v3/api-docs`만 500 INTERNAL_ERROR |
## 검증 결과 — 절별
### §1.2 Routing · §1.3 경계 · §2 기능 — 27/27 PASS
```
§1.2 존재하지 않는 Case / 잘못된 explore kind / 없는 프로젝트 / 없는 릴리스
→ 전부 "페이지를 찾을 수 없습니다."
§1.2 Not Found 화면, Back/Forward (/explore→/projects→back→forward) 정상
§1.3 Public 6개 화면에 studio 링크 0건, Draft 표식 0건
§2.1 탐색 목록 6건 · 중복 0 · 필터 적용 6→2건
§2.2 검색창 열림 / Focus 이동 / Overlay 겹침 없음 / 입력 중 과요청 0
결과 없음 UI / ESC 닫기 / 빈 검색어 정책 / 결과 클릭 → 상세 이동
§2.3 프로젝트 목록 2건 · 상세("Backend Skeleton") · 포함 문서 6건
§2.4 변경 기록 목록·상세, 연결 문서 7건, 시간순 정렬 일관
```
### §3 Studio — 20/22 PASS (mock 기준)
```
§3.1 새 문서(유형 4종) → 편집 진입 → 저장 → 상태 전달 PASS
§3.1 저장 버튼 3연타 → 문서 수 8→9 (증가 1) PASS ← 멱등성 실동작
§3.1 미저장 변경 이동 경고 [머무르기/변경 버리기/저장 후 이동] PASS
§3.1 머무르기 후 입력값 보존 PASS
§3.1 검증 화면("저장본 검증") / 게시 화면("게시 준비") PASS
§3.2 관계 추가·순서 이동·삭제, 대상 카탈로그 4건 PASS
§3.2 중복 관계 방지 FAIL (N-2)
§3.3 즉시 미리보기 렌더 / Public Preview 화면 PASS
§3.3 게시 기록 8건 · 게시 취소 버튼 3개 PASS
§20 저장 충돌(409) 사용자 안내 PASS
콘솔 오류 0건
```
문서 **삭제**는 계약에 오퍼레이션 자체가 없다(`deleteStudioAsset`만 존재). §3.1의 "삭제"는 설계 범위 밖.
### §4 UX/UI · §5 접근성 · §6 성능 — 15/18 PASS
```
§4 Layout Shift FAIL CLS=0.1924 (N-3)
§4 Header가 콘텐츠를 가리지 않음 PASS
§4 긴 제목(150자)/긴 본문/긴 URL PASS scrollWidth==clientWidth 1440
§4 코드 블록 (pre overflow-x:auto) PASS
§5 Modal Focus 이동 / role=dialog / Focus Trap / 닫은 뒤 복귀 PASS
§5 키보드 순회 19개 요소 · Focus 표시 전부 존재 PASS
§6 긴 문서 렌더링 296ms PASS
§6 이미지 lazy loading · width/height 명시 PASS
§6 동일 요청 중복 0 · 2초간 DOM 변경 0건(render loop 없음) PASS
§6 검색 21자 입력+반영 847ms PASS
```
### §14 데이터베이스
```
Constraint PK 33 · FK 36 · UNIQUE 18 · CHECK 89 · NOT NULL 267 · PK 없는 테이블 0 PASS
Index 실행계획 (20,000행 기준)
Public 목록(최신순) Index Scan idx_public_latest 0.113ms PASS
유형별 조회 Index Scan idx_public_type 0.129ms PASS
Topic별 조회 Bitmap Index Scan idx_public_topic 0.229ms PASS
검색(trgm) Seq Scan (인덱스 미선택) 10.3ms 주의 (N-5)
slug 조회 Seq Scan (인덱스 부재) 236ms FAIL (N-4)
```
`public_resource_projection`의 인덱스들이 `WHERE publication_state='ACTIVE' AND
visibility='PUBLIC'` 부분 인덱스로 정의되어 있다 — Public/Private 경계를 인덱스 수준에서
강제하는 좋은 설계다(§12를 구현할 때 그대로 활용 가능).
### §19 악용 방지 · §28 Swagger
```
pagination 최대 크기 (limit=1000) 422 REQUEST_VALIDATION_FAILED PASS
q 길이 제한 (500자) 422 REQUEST_VALIDATION_FAILED PASS
Rate Limit APP_RATE_LIMIT_ENABLED=false 미적용
대용량 Body 쓰기 엔드포인트 부재로 검증 불가
/swagger-ui, /v3/api-docs 404 (미배포) PASS
/api/v3/api-docs 500 주의 (N-6)
```
### §26 의존성 장애
```
PostgreSQL Down catalog 503 DB_UNAVAILABLE(retryable) 30s · readiness 503 DOWN
liveness 200 UP 유지 · 복구 후 27ms 정상 PASS
Keycloak Down JWKS 캐시로 기존 토큰 32ms/200 · 잘못된 서명 21ms/401
readiness 200 UP 유지(외부 IdP를 readiness에 걸지 않음)
복구 후 정상 PASS
Backend 단절 Public 화면 정상 유지(정적 소스) PASS
MinIO / Redis 해당 없음(미배선)
```
### §0 · §9 설정
```
src/.env 가 git에 커밋되어 있다 — 값은 local 프로파일용이지만 .gitignore에 .env가 없어
구조적으로 막혀 있지 않다. Redis HMAC은 secret://environment/... 간접 참조를 쓴다(좋은 패턴).
prod 5개 validator 실동작 확인 (정정 1)
show-sql=false · 로그에 토큰/쿠키/비밀번호 0건 · user= 는 가명화 해시
```
## 남은 것 — 로컬에서 불가능
| 절 | 이유 |
|---|---|
| §12 Public/Private 경계 | Public 엔드포인트·문서 엔드포인트 부재 |
| §15 N+1 / JPA Query | Tech Log에 JPA 리포지토리 0건 (catalog는 raw JDBC 단일 쿼리) |
| §16 Transaction | 쓰기 유스케이스 부재 |
| §17 파일/Object Storage | 업로드 엔드포인트·스토리지 배선 부재 |
| §18 HTTPS/HSTS/Redirect | TLS 종단 필요 |
| §22 Grafana·Loki 대시보드 | 관측 스택 필요 (수집 측 127개 메트릭은 확인 완료) |
| §24 Kubernetes | 매니페스트·오케스트레이터 부재 |
| §25 Ingress 라우팅 · X-Forwarded-* | 리버스 프록시 필요 |
| §27 Backup / Restore | 실제 볼륨·운영 DB 필요 |
| §30 Production Smoke Test | 운영 환경 부재 |
| §1.4 세션 만료 · 토큰 만료 후 프론트 동작 | demo 어댑터에 만료 개념이 없음 (외부 IdP 연동 필요) |
@@ -0,0 +1,111 @@
# Adapter Review — TechLog Asset Multipart Upload
> 검토 기준: `feature/techlog-backend-alignment` (2026-08-18, Task 7)
>
> 범위: `src/features/tech-log/adapters/http/asset-upload-transport.ts` 1개 파일과 그 wiring — `create-tech-log-feature-input.ts`, `installed-feature-adapters.ts`, `bootstrap/runtime-adapters.ts`의 `attachCredentials`/`techLogCsrf`. `src/adapters/**` 전수 리뷰([INVENTORY](./INVENTORY.md))와는 별도 트랙이다: 이 파일은 `src/features/tech-log/adapters/**` 아래에 있고, TechLog는 자체 canonical HTTP 계약을 갖는 product feature이지 템플릿의 범용 adapter 계층이 아니다.
## 결론
TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개는 `external-contract-runtime.ts`가 표현할 수 있는 `requestBody: "NONE" | "JSON"` 범위 안에 있고, 플랫폼의 V3 실행기(`http-execution-v3.ts`) · 저수준 client(`client.ts`) · `attachCredentials` credential seam을 그대로 통과한다. 나머지 1개, `uploadStudioAsset`(`POST /api/v1/studio/assets`)만 `multipart/form-data`를 요구한다. 이 요구를 플랫폼이 표현할 수 없으므로, 이 operation 하나만 별도의 좁은 transport(`asset-upload-transport.ts`)로 분리했다.
이 seam은 플랫폼을 대체하지 않는다. CSRF, `Idempotency-Key`, canonical 오류 코드 매핑, timeout, credentials는 동일한 provider·동일한 오류 taxonomy로 다시 구현해 대칭을 유지한다. 포기하는 것은 플랫폼이 대신 강제해 주던 부분 — 계약 실행기의 byte 상한, retry policy, V3 진단 계측 — 뿐이며 이는 아래에 명시적으로 기록한다. 조립 지점은 `createTechLogFeatureInstalledInput`이 무조건(HTTP/MOCK 무관) `createHttpStudioAssetGateway`를 구성하고, 그 안에 이 transport를 주입하는 한 곳뿐이다.
## 우회 대상과 이유
- `src/contracts/external-contract-runtime.ts``requestBody` union은 `"NONE" | "JSON"` 두 값만 갖는다. `multipart/form-data`를 표현할 세 번째 값이 없다.
- `src/adapters/http/client.ts:719` 부근의 저수준 client는 본문이 있는 모든 request를 `JSON.stringify(input)`으로 직렬화해 고정 `content-type: application/json`으로 보낸다. `File`을 이 경로에 태우면 파일 바이트 대신 그 JSON 표현(빈 객체거나 오류)이 전송된다.
- 두 제약 모두 이번 task의 global constraint로 수정 금지 대상이다(`external-contract-runtime.ts`, `client.ts`, `http-execution-v3.ts`, `mutation-intent.ts`, 생성된 계약 산출물, `studio-gateway.ts` 포트). 계약 실행기 자체를 바꾸는 대신, `uploadStudioAsset` 한 operation만 포트 경계 뒤에서 다른 구현으로 우회한다.
## 우회 범위
canonical Studio API 전체는 19개 operation이다. `tech-log-studio-contract-contribution.ts`는 그중 18개만 등록한다 — `uploadStudioAsset`은 계약 실행기가 표현할 수 없으므로 애초에 그 파일에 없다(`studio-contract-contribution.test.ts`의 "declares every canonical operation except the multipart upload"가 18을 고정한다).
| 분류 | operation | 경로 |
| --- | --- | --- |
| JSON, 계약 실행기 경유 | `getStudioSession`, `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioCatalog`, `listStudioAssets`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` (18개) | `contractHttp.execute()``client.ts``attachCredentials` |
| multipart, 플랫폼 우회 | `uploadStudioAsset` (1개) | `asset-upload-transport.ts`의 직접 `fetch()` |
18개는 `contractOperations.execute(operationId, input, { routeId, intent? })`를 통해 나가며, 그중 17개(`getStudioSession` 제외)에 `attachCredentials`가 매 요청 `x-csrf-token`을 싣는다 — `getStudioSession`은 그 토큰을 발급하는 operation 자신이라 CSRF 헤더를 요구하지 않는 `TECH_LOG_STUDIO_BOOTSTRAP` auth profile을 쓴다(자세한 내용은 아래 "유지되는 보증"의 CSRF 행). 1개(`uploadStudioAsset`)만 이 경로를 완전히 벗어나 `createAssetUploadTransport`가 직접 `fetch()`한다. `StudioAssetGateway.uploadAsset()`이 이 transport를 호출하는 유일한 지점이며, 포트 시그니처(`Promise<Asset>`)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다.
## 유지되는 보증
플랫폼이 18개 JSON operation에 자동으로 제공하는 것을, 이 transport는 같은 provider·같은 값으로 손으로 다시 만든다.
| 보증 | JSON 경로 | multipart 경로 |
| --- | --- | --- |
| CSRF | `attachCredentials``techLogCsrf.token()`/`headerName()`으로 얻은 값을 그 이름 그대로 요청 헤더에 싣는다 | `StudioAssetGateway.uploadAsset()`**같은** `techLogCsrf` provider에서 `token()`/`headerName()`을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다. `getStudioSession` 자신은 이 provider를 거치지 않는 `TECH_LOG_STUDIO_BOOTSTRAP` auth profile을 쓴다: 그 provider가 토큰을 얻으려고 호출하는 operation이 같은 provider의 토큰을 요구하면 순환이 되기 때문이다(`docs`가 아니라 코드로 고정: `studio-csrf-composition.test.ts`) |
| Idempotency-Key | 실행 intent(`mutationIntent()`)에서 나와 client가 헤더로 싣는다 | 호출자(`uploadAsset` options)의 `idempotencyKey`를 gateway가 그대로 헤더로 전달한다 |
| canonical 오류 코드 매핑 | `toStudioGatewayError()``STUDIO_ERROR_CODES`에 있는 `problem.code``StudioGatewayError`로 승격하고, 계약 밖 코드는 `STUDIO_UNAVAILABLE`로 접는다 | transport가 동일한 `STUDIO_ERROR_CODES` 집합을 재사용해 같은 규칙으로 매핑한다. 서버가 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`처럼 이 목록에 있는 코드를 보내면 그대로 `StudioGatewayError`가 되고, 계약에 없는 코드나 파싱 불가능한 본문은 도메인 코드를 지어내지 않고 `STUDIO_UNAVAILABLE`로 접는다 |
| timeout | 계약의 `requestDeadlineCeilingMs` | `AbortSignal.timeout(deps.timeoutMs)`를 호출자 signal과 `AbortSignal.any()`로 합성한다 |
| credentials | `TECH_LOG_STUDIO_SESSION` 프로필의 `credentials: "include"` | `fetch()` 호출에 동일하게 `credentials: "include"`를 명시한다 |
## 포기하는 보증
- **byte 상한**: 계약 실행기의 bounded body reader/writer가 응답 크기를 강제하는 것과 달리, 이 transport의 요청 body(`FormData`)와 응답 JSON 파싱에는 별도 상한이 없다. 서버가 `413 PAYLOAD_TOO_LARGE`로 거절하는 것에 의존한다.
- **retry policy**: 계약 실행기의 `retry-policy.ts`는 이 operation에 적용되지 않는다. `uploadStudioAsset`은 애초에 계약에서 `retrySemantics`를 선언할 수 없는 경로 밖에 있으므로, 재시도는 호출자(향후 Task 11의 Asset Library UI)가 명시적으로 다시 `uploadAsset()`을 호출하는 형태로만 존재한다.
- **V3 진단 계측**: `createHttpObservationProjector`가 만드는 `api.request.*` diagnostics/telemetry 이벤트는 `contractHttp.execute()` 내부에서만 발생한다. 이 transport는 그 관찰 경계 밖에서 직접 `fetch()`하므로 업로드 성공/실패는 diagnostics 스트림에 나타나지 않는다. `routeId: "TECH_LOG_STUDIO_ASSETS"`는 나머지 4개 asset JSON operation에는 여전히 붙지만, `uploadStudioAsset` 자체에는 대응하는 diagnostics 레코드가 없다.
이 세 항목 모두 이번 task 범위에서 새로 만들지 않는다 — 다시 만들려면 플랫폼과 동일한 bounded reader/retry/observation을 복제해야 하고, 그것은 계약 실행기를 다시 짓는 것과 다르지 않다. 대신 아래 교체 계획으로 닫는다.
## `ROUTE_ID` 검토 (Task 6 리뷰 인계 항목)
`http-studio-asset-gateway.ts``const ROUTE_ID = "TECH_LOG_STUDIO_ASSETS"`는 diagnostics/telemetry 버킷을 나누는 low-cardinality routing 메타데이터이지, 등록된 route 경로가 아니다. 이제 gateway가 실제로 배선되어 나머지 4개 asset JSON operation(`listAssets`, `getAsset`, `updateAssetMetadata`, `deleteAsset`)이 이 값으로 나가는 시점에서 다시 확인한 결과, **그대로 유지한다.** 문서/편집 operation의 `TECH_LOG_STUDIO`와 별개 값을 쓰는 것은 두 가지를 갖는다.
1. Asset 수명주기(업로드·목록·삭제)는 문서 편집과 실패 특성이 다르다 — 예를 들어 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`/`ASSET_QUARANTINED`는 asset 쪽에만 있다. 별도 routeId는 이 실패를 diagnostics에서 문서 편집 트래픽과 섞지 않는다.
2. `uploadStudioAsset` 자체는 계약 실행기를 우회해 이 routeId를 진단에 보고하지 않지만, 같은 이름을 4개 JSON operation에 유지해 두면 향후 업로드가 presigned/resumable로 옮겨가거나 플랫폼에 MULTIPART 모드가 생겨 계약 경로로 복귀할 때, 같은 routeId 아래 asset 트래픽 전체가 이미 일관되게 모여 있다.
값을 바꿀 이유(예: 기존 registry 충돌, 명명 규칙 위반)는 없었다.
## 교체 계획
1. **presigned/resumable 업로드로 이전.** `src/adapters/browser-transfer/`에 이미 presigned capability와 resumable checkpoint 인프라가 있다(별도 리뷰: [04 — Browser transfer](./04-browser-transfer.md)). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다. `POST /api/v1/studio/assets`의 multipart 자체가 없어진다.
2. **플랫폼에 `requestBody: "MULTIPART"` 모드가 생기는 경우.** `external-contract-runtime.ts``client.ts``FormData` 본문을 표현할 수 있게 확장되면, `uploadStudioAsset`을 이미 등록된 다른 18개 operation과 함께 `tech-log-studio-contract-contribution.ts`에 등록하고 `createHttpStudioAssetGateway``upload` 의존성을 제거한다. `StudioAssetGateway` 포트 시그니처(`uploadAsset(form, options): Promise<Asset>`)는 바뀌지 않는다 — 교체는 이 파일과 `create-tech-log-feature-input.ts`의 배선 한 줄에서 끝난다.
두 경로 모두 `StudioAssetUploadTransport`/`StudioAssetGateway` 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다.
## MOCK 의존성 리비전은 계약의 요구가 아니라 mock의 구현이다
`createMockStudioGateway`의 기본 `dependencyRevision.current()`가 무엇을 관찰하는지 — 그리고 그것이 **계약이 요구하는 계산이 아니라는 점** — 을 여기에 남긴다. 나중에 mock의 구현을 계약의 요구로 오독하지 않기 위해서다.
### 실제 백엔드
`DependencyRevision`(`studio-api.openapi.yaml` / `generated.ts`)은 값의 **형식**만 계약이다: 불투명한 문자열. 계약이 요구하는 것은 값이 아니라 규칙 하나뿐이다 — *검증에 사용한 dependency set을 publish 시 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다.* 무엇을 dependency set에 넣을지(Topic/Project 존재, relation target 상태, Asset READY/QUARANTINED 상태, slug/route ownership, catalog revision, 필요 시 renderer/content-format version), 그리고 그것을 어떻게 정규화·hash할지는 **서버가 스스로 정한다.** 프론트엔드는 이 값을 생성하지도, 해석하지도, 비교하지도 않는다. `ValidationReport.dependencyRevision`을 받아 그대로 되돌려 보내고, 서버가 내린 `VALIDATION_STALE` 판정을 표시할 뿐이다. HTTP gateway(`http-studio-gateway.ts`)에는 리비전을 계산하는 코드가 없다 — 있어서도 안 된다.
### MOCK
MOCK `studioSource`에는 그 서버가 없으므로, mock이 같은 규칙을 스스로 만족시켜야 한다. 기본 리비전은 `dependency-revision.ts``mockDependencyRevision`이 계산한다.
- **catalog 성분**: `MOCK_CATALOG_REVISION`(`"catalog-2026-08-14"`) 상수. `createMockStudioState`가 고정 fixture catalog 하나를 싣고 변경하지 않으므로 catalog의 기여는 실제로 상수다. `fixtures.ts`의 seed validation/preview도 같은 정의를 import해 쓴다 — 두 값이 갈라지면 seed된 문서가 전부 조용히 stale이 된다.
- **asset 성분**: Asset store를 정규화해 만든 128비트 digest. 각 Asset을 **투영(projection)** 으로 줄이고(`id`, `assetKey`, `managementStatus`, `publicPath`, `updatedAt`, `decorative`, `altText`, `mediaType`, `width`, `height`), `stableStringify`로 정규 문자열을 만든 뒤 정렬해 접는다. 따라서 `Map` 삽입 순서와 무관하게 같은 논리적 Asset 집합은 항상 같은 리비전을 낸다 — 이 mock의 재현성은 저장소 전체 테스트가 의존하는 성질이다.
- 빈 store는 성분을 더하지 않아 `MOCK_CATALOG_REVISION` 그대로다. seed fixture가 Asset이 없는 세계에서 만들어졌고 그 문자열을 그대로 싣기 때문이다.
레코드 전체가 아니라 투영을 hash하는 이유: `usageCount`는 그 Asset을 참조하는 문서 수라 실제 백엔드였다면 **아무 문서나 publish할 때마다** 다른 저자의 진행 중인 검증이 전부 무효가 된다 — 검증기도 렌더러도 읽지 않는 필드인데도. `version`은 이 mock에서 `updatedAt`과 함께 움직여 신호를 더하지 않고, `kind`·`originalFilename`·`byteSize`·`createdAt`은 검증에도 render model에도 도달하지 않는다.
### 이 기본값이 닫는 구멍
기본 리비전이 리터럴 상수였을 때, `createStudioPreview`/`publishStudioDocument`의 staleness guard는 Asset store를 전혀 관찰하지 못했다. 그래서 **validate와 preview 사이의 Asset 변경이 guard에게 보이지 않았다.** 구체적으로: 어떤 evidence key의 Asset이 `decorative: true`뿐이면 `alt=""`인 directive는 정당하게 VALID다(장식용 이미지는 대체 텍스트가 없어도 된다). 그 사이에 같은 key에 `decorative: false`인 더 새로운 Asset이 도착하면, `createStudioPreview`는 성공하고 figure는 `decorative: false, alt: ""`로 해석되며 `publishDocument`가 그 render model을 그대로 snapshot한다. **의미 있는 이미지가 접근 가능한 이름 없이, 검증은 깨끗한 채로, 아무도 오류를 보고하지 않은 채 공개된다.** 이제 그 변경이 리비전을 움직여 guard가 `VALIDATION_STALE`을 내고, 저자가 재검증하면 `EVIDENCE_ALT_REQUIRED`로 진짜 문제를 듣는다.
수정은 `findResolvableAsset`이 아니라 리비전에 있다. `findResolvableAsset`*한 시점의* 술어이고 그 자체로는 옳다 — 두 시점 사이의 변화를 보는 것은 리비전의 일이다.
**주의**: 위 필드 목록은 이 mock이 스스로 무엇을 읽는지에 대한 서술이지, 서버가 무엇을 dependency set에 넣어야 하는지에 대한 요구가 아니다. 서버는 프론트엔드가 볼 수 없는 것(예: relation target의 게시 상태, route ownership)까지 포함할 수 있고 그래야 한다. 이 mock을 계약의 참조 구현으로 삼지 말 것.
고정 테스트: `tests/features/tech-log/mock-dependency-revision.test.ts`(보고된 시나리오 end-to-end, 순서 무관 결정성, 무변경 authoring loop 안정성).
## 검증
```
corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts
corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts
corepack pnpm exec vitest run tests/features/tech-log/runtime-composition.test.ts
corepack pnpm exec vitest run tests/features/tech-log/studio-csrf-composition.test.ts
corepack pnpm exec vitest run tests/features/tech-log/studio-session-csrf.test.ts
corepack pnpm exec vitest run tests/features/tech-log/mock-dependency-revision.test.ts
corepack pnpm check:types
corepack pnpm test:tech-log
```
`studio-csrf-composition.test.ts`는 fix round 1에서 추가됐다 — 실 `createContractHttpExecutor` · `createCsrfTokenProvider` · `attachStudioSessionCredentials`를 composition root와 같은 방식으로 조립해 `getStudioSession`이 정확히 한 번만 나가고 그 토큰이 JSON operation과 업로드 양쪽에 모두 실리는지 검증한다. `studio-session-csrf.test.ts`는 provider의 재진입 가드를 단독으로 고정한다.
fix round 2에서 같은 파일에 "JSON operation의 403이 캐시된 토큰을 무효화해 다음 operation이 세션을 다시 가져온다"는 테스트를 더했다 — `invalidateTechLogCsrfOnOutcome`(`studio-session-credentials.ts`)를 composition root와 동일하게 호출한다. `asset-upload-transport.test.ts`에는 업로드 transport가 계약 밖 상태 코드의 실제 HTTP status를 그대로 통과시키는지, 그리고 계약 밖 401 본문도 게이트웨이의 토큰 무효화를 실제로 촉발하는지 검증하는 테스트를 더했다. `studio-contract-contribution.test.ts`에는 `getStudioSession`이 bootstrap profile의 유일한 사용자인지와 `assertExactlyOneTechLogStudioBootstrapOperation`이 0개·2개 위반을 거절하는지 고정하는 테스트를 더했다.
정확한 실행 결과는 `.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-7-report.md`에 있다.
+66 -59
View File
@@ -69,64 +69,71 @@
| 59 | `src/adapters/platform/abortable-operation.ts` | [Network/state](./01-network-and-state.md) | | 59 | `src/adapters/platform/abortable-operation.ts` | [Network/state](./01-network-and-state.md) |
| 60 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) | | 60 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) |
| 61 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) | | 61 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) |
| 62 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) | | 62 | `src/adapters/platform/bounded-capacity.ts` | [Network/state](./01-network-and-state.md) |
| 63 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) | | 63 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) |
| 64 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) | | 64 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) |
| 65 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) | | 65 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) |
| 66 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) | | 66 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) |
| 67 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) | | 67 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) |
| 68 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 68 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) |
| 69 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 69 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 70 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 70 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 71 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 71 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 72 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 72 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 73 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 73 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 74 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 74 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 75 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 75 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 76 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 76 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 77 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 77 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 78 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 78 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 79 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 79 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 80 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 80 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 81 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 81 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 82 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 82 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 83 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 83 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 84 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | | 84 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 85 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 85 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
| 86 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 86 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 87 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 87 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 88 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 88 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 89 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 89 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 90 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 90 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 91 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 91 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 92 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 92 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 93 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 93 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 94 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 94 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 95 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 95 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 96 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 96 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 97 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 97 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 98 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 98 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 99 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 99 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 100 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 100 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 101 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 101 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 102 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 102 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 103 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 103 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 104 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 104 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 105 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 105 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 106 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 106 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 107 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | | 107 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 108 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) | | 108 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
| 109 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 109 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
| 110 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 110 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 111 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 111 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 112 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 112 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 113 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 113 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 114 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 114 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 115 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 115 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 116 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 116 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 117 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 117 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 118 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 118 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 119 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) | | 119 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
| 120 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
합계: **119/119**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다. 합계: **120/120**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
## Feature-scoped adapter 리뷰 (`src/adapters/**` 밖)
이 표는 `corepack pnpm check:adapter-inventory``git ls-files src/adapters`와 대조하는 목록이라 `src/features/**/adapters/**` 파일은 포함하지 않는다. TechLog는 자체 canonical HTTP 계약을 갖는 product feature이며 그 adapter는 별도 트랙으로 검토한다.
- `src/features/tech-log/adapters/http/asset-upload-transport.ts` — [06 — TechLog asset multipart upload](./06-tech-log-asset-upload.md)
@@ -0,0 +1,812 @@
# TechLog Public and Studio UI Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move every Public and Studio screen from `/home/donghyeon/workspace/techlog-studio-frontend` into this Vite frontend while preserving the source DOM, text, assets, CSS, responsive behavior, accessibility semantics, and user-visible interactions exactly.
**Architecture:** Keep the target repository's Vite, React Router, application API, diagnostics, route lifecycle, and adapter boundaries. Install one `tech-log` feature whose presentation is split into nested `PUBLIC` and `STUDIO` layout groups; expose immutable Public queries and a factory-scoped `StudioGateway` through application feature inputs; use source-equivalent static Public data and the deterministic mock Studio adapter. The Public renderer is shared with Studio previews so one content-format implementation produces both views.
**Tech Stack:** Node 24, pnpm 11, TypeScript 7, React 19, React Router 7, Vite 8, Vitest 4, Testing Library, Playwright, Axe, Tailwind 4, Pretendard 1.3.9, IBM Plex Mono 5.3.0, Unified 11.0.5, Remark Parse 11.0.0, Remark GFM 4.0.1, Remark Directive 4.0.0.
## Source of truth and delivery branch
- Approved design: [`docs/superpowers/specs/2026-08-15-techlog-ui-migration-design.md`](../specs/2026-08-15-techlog-ui-migration-design.md).
- Visual/behavior source: `/home/donghyeon/workspace/techlog-studio-frontend` at the locally inspected revision used when Task 1 records the baseline.
- Delivery flow is already initialized with `main` as production and `develop` as integration. Execute every task and commit on `feature/techlog-ui-migration`; finish through `git flow feature finish techlog-ui-migration` only after Task 14 is green and the user authorizes integration.
- Do not push, merge, finish the feature, or delete branches as part of an individual task.
## Non-negotiable constraints
- Visual parity means no redesign: preserve source element order, nesting, class names, visible copy, labels, ARIA, SVGs, typography, spacing, color values, borders, shadows, animation, and responsive rules.
- Copy `app/globals.css`, `app/studio.css`, `app/studio-editor.css`, `components/studio/workflow.module.css`, and `components/studio/publication-flow.module.css` without value or selector changes. Remove only the source `@import "tailwindcss"` because target `theme.css` already owns that import.
- Preserve the source breakpoints, including 1179, 1050, 1024, 980, 900, 767, and 420 pixels. Do not substitute the target generic design-system components where doing so changes source markup or styles.
- Preserve `public/favicon.svg` and `public/media/fetch-strategy-boundary.svg` byte-for-byte. Add only assets demonstrably referenced by a migrated screen.
- The only permitted framework translations are: `next/link` to React Router `Link` with `href` renamed to `to`; the Studio `공개 사이트 보기` boundary remains a plain `<a href="/">` so it performs the source-intended full reload/session reset; `usePathname`/Next navigation to `useLocation`/`useNavigate`; `next/image` to an `img` that preserves classes, dimensions, alt text, loading intent, and wrapper structure; server page inputs to validated route params/search plus injected feature queries.
- No Next.js, Vinext, Cloudflare, server actions, or direct presentation-to-adapter imports enter the target.
- Public behavior stays deterministic and static. Studio behavior stays session-scoped and deterministic through one provider-owned mock gateway instance. Reload resets Studio state; remounting child routes does not.
- Studio authentication is deliberately deferred. Studio routes use `access: "public"` in this migration so the current shell is reachable, while `layoutGroup: "STUDIO"` and the application feature input remain the future auth seam. Do not add fake sign-in UI.
- Unknown public content renders the Public not-found experience; unknown `/studio/*` and unknown document/publication IDs render Studio not-found inside the Studio shell. Param codecs accept non-empty strings and do not reject unknown IDs before gateway lookup.
- Every production change follows red → green → focused regression → commit. Never update a test merely to legitimize a visual or behavioral difference.
- The pre-existing `tests/unit/ci-artifact-contract.test.ts` child-process failures caused by the restricted environment are baseline infrastructure evidence, not permission to add failures. Record exact commands/counts; focused TechLog tests and static gates must pass.
## Fixed contracts
### Route grouping
Add this field to every route definition:
```ts
export type RouteLayoutGroup = "PUBLIC" | "STUDIO";
export type RouteDefinition = Readonly<{
routeId: string;
path: string;
layoutGroup: RouteLayoutGroup;
paramsSchema: string | null;
searchSchema: string | null;
access: "public" | "session-required";
loadingSurface: string;
errorSurface: string;
chunkId: string;
title: string;
navigationLabel: string | null;
navigationOrder: number | null;
}>;
```
The installed TechLog route IDs and paths are fixed:
| Layout | Route ID | Path |
| --- | --- | --- |
| PUBLIC | `TECH_LOG_HOME` | `/` |
| PUBLIC | `TECH_LOG_EXPLORE` | `/explore` |
| PUBLIC | `TECH_LOG_EXPLORE_KIND` | `/explore/:kind` |
| PUBLIC | `TECH_LOG_CASE` | `/cases/:slug` |
| PUBLIC | `TECH_LOG_REFERENCE` | `/references/:slug` |
| PUBLIC | `TECH_LOG_QUESTION` | `/questions/:slug` |
| PUBLIC | `TECH_LOG_TOPIC` | `/topics/:slug` |
| PUBLIC | `TECH_LOG_PROJECTS` | `/projects` |
| PUBLIC | `TECH_LOG_PROJECT` | `/projects/:slug` |
| PUBLIC | `TECH_LOG_PROJECT_RECORDS` | `/projects/:slug/records` |
| PUBLIC | `TECH_LOG_PROJECT_DECISIONS` | `/projects/:slug/decisions` |
| PUBLIC | `TECH_LOG_PROJECT_ACTIVITY` | `/projects/:slug/activity` |
| PUBLIC | `TECH_LOG_RELEASES` | `/releases` |
| PUBLIC | `TECH_LOG_RELEASE` | `/releases/:version` |
| PUBLIC | `TECH_LOG_PROFILE` | `/profile` |
| PUBLIC | `TECH_LOG_SEARCH` | `/search` |
| STUDIO | `TECH_LOG_STUDIO_HOME` | `/studio` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENTS` | `/studio/documents` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_NEW` | `/studio/documents/new` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_EDIT` | `/studio/documents/:id/edit` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_VALIDATION` | `/studio/documents/:id/validation` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_PREVIEW` | `/studio/documents/:id/preview` |
| STUDIO | `TECH_LOG_STUDIO_DOCUMENT_PUBLISH` | `/studio/documents/:id/publish` |
| STUDIO | `TECH_LOG_STUDIO_PUBLICATIONS` | `/studio/publications` |
| STUDIO | `TECH_LOG_STUDIO_PUBLICATION_PREVIEW` | `/studio/publications/:publicationEventId/preview` |
| STUDIO | `TECH_LOG_STUDIO_NOT_FOUND` | `/studio/*` |
| PUBLIC | `NOT_FOUND` | `*` |
Route schema IDs are also fixed. Parameter codecs are `TechLogExploreKindParams` (`kind` non-empty; page-level lookup recognizes only `cases | references | questions`), `TechLogSlugParams` (`slug` non-empty), `TechLogVersionParams` (`version` non-empty), `TechLogDocumentIdParams` (`id` non-empty), `TechLogPublicationEventIdParams` (`publicationEventId` non-empty), `TechLogStudioSplat`, and the existing `NotFoundSplat`. Search codecs are `TechLogHomeSearch` (`focus`, `state`), `TechLogExploreSearch` (`type`, `topic`, `project`), `TechLogExploreKindSearch` (`topic`, `project`), `TechLogSearchQuery` (`q`), and `TechLogCaseStateSearch` (`state`); all fields are optional single strings and canonicalization keeps the first repeated value, trims selections where the source does, and drops unknown fields. Other routes use `none`.
Each TechLog route uses a unique kebab-cased chunk/module identity derived from its ID: lower-case the route ID, replace underscores with hyphens, and prefix `route-` (for example, `TECH_LOG_STUDIO_DOCUMENT_EDIT``route-tech-log-studio-document-edit`). The global `NOT_FOUND` keeps `route-not-found`. This same identity must appear in the runtime contract, lazy import map, release manifest, diagnostics, and chunk-recovery tests.
### Feature input and gateway
```ts
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
export type TechLogFeatureInput = Readonly<{
publicContent: PublicContentQueries;
createStudioGateway(): StudioGateway;
}>;
declare module "../../../application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"tech-log": TechLogFeatureInput;
}
}
export interface StudioGateway {
getDashboard(options?: RequestOptions): Promise<StudioDashboard>;
listDocuments(query: ListDocumentsQuery, options?: RequestOptions): Promise<DocumentPage>;
createDocument(input: CreateDocumentInput, options: IdempotentOptions): Promise<WorkingCopy>;
getDocument(documentId: string, options?: RequestOptions): Promise<WorkingCopyDetail>;
saveDocument(documentId: string, command: SaveDocumentCommand, options: IdempotentOptions): Promise<WorkingCopyDetail>;
validateDocument(documentId: string, command: ValidateDocumentCommand, options: IdempotentOptions): Promise<ValidationReport>;
createPreview(documentId: string, command: CreatePreviewCommand, options: IdempotentOptions): Promise<PublicPreview>;
getCurrentPreview(documentId: string, options?: RequestOptions): Promise<PreviewDetail>;
publishDocument(documentId: string, command: PublishDocumentCommand, options: IdempotentOptions): Promise<PublishResult>;
unpublishPublication(publicationId: string, command: UnpublishCommand, options: IdempotentOptions): Promise<PublishResult>;
listPublications(query: ListPublicationsQuery, options?: RequestOptions): Promise<PublicationPage>;
getPublicationSnapshot(publicationEventId: string, options?: RequestOptions): Promise<PublicationSnapshot>;
getCatalog(query: CatalogQuery, options?: RequestOptions): Promise<CatalogPage>;
}
```
Use the source generated contract names and exact payload fields from `lib/studio/api/generated.ts`. `StudioGatewayError` retains RFC 9457-like problem details, HTTP status, stable code, and retryability. Abort remains distinguishable from not-found/conflict/validation failures.
`PublicContentQueries` is the immutable boundary for the source functions `listRecords`, `getRecord`, `getProject`, `getRelease`, `getProjectRecords`, `getProjectDecisions`, `getProjectActivity`, `getHomeFocusItems`, and `searchPublicContent`, with the source argument and return types unchanged.
### State derivation
Port the source pure functions and values exactly:
```ts
deriveValidationState(input: StateInput): ValidationState;
derivePreviewState(input: StateInput): PreviewState;
deriveNextAction(input: StateInput): NextAction;
deriveDocumentState(input: StateInput): StudioDocumentState;
```
Editor state is `CLEAN | DIRTY | SAVING | CONFLICT`; validation freshness is `NONE | CURRENT | STALE`; publication state is `NEVER_PUBLISHED | PUBLISHED | UNPUBLISHED`. The saved revision/version token owns optimistic concurrency.
## Deterministic source-to-target map
| Source | Target |
| --- | --- |
| `lib/content-format/*`, `lib/public-render-content.ts` | `src/features/tech-log/domain/content-format/*`, `src/features/tech-log/domain/public-render-content.ts` |
| `lib/content.ts`, `lib/evidence-assets.ts`, `lib/public-content.ts`, `lib/public-query.ts` | `src/features/tech-log/adapters/static/*` behind `PublicContentQueries` |
| `lib/studio/api/*`, `contracts/studio-api.openapi.yaml` | `src/features/tech-log/contracts/studio/*` and `src/features/tech-log/application/ports/studio-gateway.ts` |
| `lib/studio/document-state.ts`, `lib/studio/local-id.ts` | `src/features/tech-log/domain/studio/*` |
| `lib/studio/mock/*` | `src/features/tech-log/adapters/mock/*` |
| public `components/*.tsx` | `src/features/tech-log/presentation/public/components/*` |
| Studio `components/studio/*.tsx` | `src/features/tech-log/presentation/studio/components/*` |
| public `app/**/page.tsx` | `src/features/tech-log/presentation/public/pages/*` |
| Studio `app/studio/**` | `src/features/tech-log/presentation/studio/pages/*` |
| source CSS | `src/features/tech-log/presentation/styles/*` |
| `public/favicon.svg`, `public/media/fetch-strategy-boundary.svg` | same target-relative paths |
Component and stylesheet ports must retain source file boundaries where practical. Rename a file only for Vite/React Router clarity; do not combine components in a way that obscures parity review.
---
### Task 1: Freeze the migration baseline and dependency/assets contract
**Files:**
- Create: `docs/operations/techlog-ui-migration-baseline.md`
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Create: `public/favicon.svg`
- Create: `public/media/fetch-strategy-boundary.svg`
- Test: `tests/features/tech-log/migration-baseline.test.ts`
- [ ] **Step 1: Write the red asset-integrity test.** Assert each target SVG's SHA-256 against a hand-recorded expected source hash (never an external source-path read in CI) and exercise the evidence asset lookup so an altered path/hash breaks a consumer-visible contract. Dependency pins are verified by the package manager's frozen-lockfile command; the human baseline document and its route/CSS inventory are reviewed rather than tested as source text. Include the source commit SHA or, if the source worktree has uncommitted changes, its HEAD SHA plus `git status --short` in the baseline document.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/migration-baseline.test.ts
```
Expected: the migrated assets and evidence lookup are absent.
- [ ] **Step 3: Add exact dependencies and assets.** Run `corepack pnpm add pretendard@1.3.9 @fontsource/ibm-plex-mono@5.3.0 unified@11.0.5 remark-parse@11.0.0 remark-gfm@4.0.1 remark-directive@4.0.0`, copy only the two approved assets, and record checksums, source state, the 27 expected routes, and the five CSS files in the baseline document. Do not add Next/Vinext/Cloudflare packages.
- [ ] **Step 4: Run green and lockfile verification.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/migration-baseline.test.ts
corepack pnpm verify:lockfile
git diff --check
```
- [ ] **Step 5: Commit.**
```bash
git add package.json pnpm-lock.yaml public/favicon.svg public/media/fetch-strategy-boundary.svg docs/operations/techlog-ui-migration-baseline.md tests/features/tech-log/migration-baseline.test.ts
git commit -m "chore: establish TechLog migration baseline"
```
### Task 2: Port Studio API contracts and the application-facing feature seam
**Files:**
- Create: `src/features/tech-log/contracts/studio/studio-api.openapi.yaml`
- Create: `src/features/tech-log/contracts/studio/generated.ts`
- Create: `src/features/tech-log/contracts/studio/contract.ts`
- Create: `src/features/tech-log/application/ports/studio-gateway.ts`
- Create: `src/features/tech-log/application/ports/studio-gateway-error.ts`
- Create: `src/features/tech-log/application/ports/public-content-queries.ts`
- Create: `src/features/tech-log/application/tech-log-feature-input.ts`
- Test: `tests/features/tech-log/studio-contract.test.ts`
- Test: `tests/features/tech-log/feature-input.test.ts`
- [ ] **Step 1: Write red contract tests.** Port the source shape assertions and add compile/runtime assertions that the exact gateway method set above is exposed, feature ID is `tech-log`, and `ApplicationFeatureInputs["tech-log"]` accepts queries plus a gateway factory but no concrete adapter.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-contract.test.ts tests/features/tech-log/feature-input.test.ts
```
Expected: TechLog contracts and feature input do not exist.
- [ ] **Step 3: Port contracts without reshaping payloads.** Copy the OpenAPI and generated types, replace source-local aliases only, implement the port/error, define `PublicContentQueries` from the source query return shapes, and add the module augmentation shown above.
- [ ] **Step 4: Run green and inward-boundary checks.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-contract.test.ts tests/features/tech-log/feature-input.test.ts
corepack pnpm check:types:app
corepack pnpm check:architecture
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/contracts src/features/tech-log/application tests/features/tech-log/studio-contract.test.ts tests/features/tech-log/feature-input.test.ts
git commit -m "feat: add TechLog feature contracts"
```
### Task 3: Port Content Format v1 and the shared public render model
**Files:**
- Create: `src/features/tech-log/domain/content-format/heading-id.ts`
- Create: `src/features/tech-log/domain/content-format/inline-plain-text.ts`
- Create: `src/features/tech-log/domain/content-format/parse-case-content.ts`
- Create: `src/features/tech-log/domain/content-format/serialize-case-content.ts`
- Create: `src/features/tech-log/domain/content-format/project-public-render-model.ts`
- Create: `src/features/tech-log/domain/public-render-content.ts`
- Create: `src/features/tech-log/presentation/shared/public-render/*`
- Test: `tests/features/tech-log/content-format.test.ts`
- Test: `tests/features/tech-log/public-render.test.tsx`
- [ ] **Step 1: Port the source parser/serializer/renderer tests first.** Keep headings, inline text, GFM tables, directives, evidence figures, code blocks, callouts, malformed-input fallback, unsafe HTML/script/`javascript:`/unknown-asset rejection, and parse→serialize round-trip fixtures byte-equivalent. Include code-copy success/failure/live-region reset and evidence zoom open/backdrop-close/button-close/trigger-focus restoration.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts tests/features/tech-log/public-render.test.tsx
```
Expected: shared content functions/components are absent.
- [ ] **Step 3: Port the pure format code and renderer components.** Map `components/public-render/*`, `code-block.tsx`, `document-toc.tsx`, and both evidence-figure implementations into `presentation/shared`. Preserve emitted tags/classes/ARIA and sanitize/escape behavior; do not use raw HTML insertion.
- [ ] **Step 4: Run green and security/architecture gates.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts tests/features/tech-log/public-render.test.tsx
corepack pnpm check:browser-security
corepack pnpm check:architecture
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/domain src/features/tech-log/presentation/shared tests/features/tech-log/content-format.test.ts tests/features/tech-log/public-render.test.tsx
git commit -m "feat: port TechLog content format and renderer"
```
### Task 4: Compose deterministic Public content and the mock Studio gateway
**Files:**
- Create: `src/features/tech-log/adapters/static/content.ts`
- Create: `src/features/tech-log/adapters/static/evidence-assets.ts`
- Create: `src/features/tech-log/adapters/static/public-content.ts`
- Create: `src/features/tech-log/adapters/static/public-query.ts`
- Create: `src/features/tech-log/domain/studio/document-state.ts`
- Create: `src/features/tech-log/domain/studio/local-id.ts`
- Create: `src/features/tech-log/adapters/mock/*`
- Create: `src/features/tech-log/adapters/create-tech-log-feature-input.ts`
- Modify: `src/features/installed-feature-adapters.ts`
- Test: `tests/features/tech-log/public-query.test.ts`
- Test: `tests/features/tech-log/studio-document-state.test.ts`
- Test: `tests/features/tech-log/mock-studio-gateway.test.ts`
- Test: `tests/features/tech-log/runtime-composition.test.ts`
- [ ] **Step 1: Port red source tests.** Cover Public type/kind/status/search filtering, stable ordering, source fixtures, state derivation, deterministic IDs/cursors, create/save conflict, validation, preview freshness/expiry, publish/unpublish, snapshots, idempotency, abort, and gateway error shapes.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-query.test.ts tests/features/tech-log/studio-document-state.test.ts tests/features/tech-log/mock-studio-gateway.test.ts tests/features/tech-log/runtime-composition.test.ts
```
Expected: data adapters and installed `tech-log` input are absent.
- [ ] **Step 3: Port data/state/mock code exactly.** Preserve fixture IDs, timestamps, copy, pagination cursors, validation issue order, error codes, stable stringify rules, preview content, and publication history. Add the TechLog input beside the temporarily retained reference-feature input in `createInstalledFeatureInputs`; one call to `createStudioGateway` creates one isolated mutable session.
- [ ] **Step 4: Run green and boundary checks.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-query.test.ts tests/features/tech-log/studio-document-state.test.ts tests/features/tech-log/mock-studio-gateway.test.ts tests/features/tech-log/runtime-composition.test.ts
corepack pnpm check:types:app
corepack pnpm check:architecture
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/adapters src/features/tech-log/domain/studio src/features/installed-feature-adapters.ts tests/features/tech-log/public-query.test.ts tests/features/tech-log/studio-document-state.test.ts tests/features/tech-log/mock-studio-gateway.test.ts tests/features/tech-log/runtime-composition.test.ts
git commit -m "feat: compose TechLog static and mock adapters"
```
### Task 5: Add the nested route-group capability and freeze TechLog route contracts
**Files:**
- Modify: `src/contracts/routes.ts`
- Modify: `src/contracts/route-runtime-contract.ts`
- Modify: `src/features/installed-feature-contracts.ts`
- Modify: `src/features/installed-feature-runtimes.tsx`
- Modify: `src/features/reference-feature/contracts/reference-feature-contract.ts`
- Modify: `config/contracts/registry-governance.json`
- Modify: `src/presentation/routes/app-router.tsx`
- Modify: `src/presentation/routes/route-codecs.ts`
- Modify: `src/presentation/routes/platform-route-codecs.ts`
- Create: `src/features/tech-log/contracts/tech-log-route-contract.ts`
- Create: `src/features/tech-log/contracts/tech-log-message-catalog.ts`
- Create: `src/features/tech-log/presentation/tech-log-route-codecs.ts`
- Test: `tests/features/tech-log/route-contract.test.ts`
- Modify: `tests/component/router.test.tsx`
- [ ] **Step 1: Add red route tests.** Assert the exact standalone 27-entry TechLog contract table, `layoutGroup`, Studio routes currently public, non-empty string codecs, generic Public/Studio parent assembly, Studio catch-all precedence over global catch-all, and canonical URL creation. The installed starter registry remains intact through this task so no route points to an unfinished screen.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/route-contract.test.ts tests/component/router.test.tsx
```
Expected: layout grouping and the standalone TechLog route contract are missing.
- [ ] **Step 3: Implement grouped route assembly without incomplete runtime entries.** Build a pure `createGroupedRouteObjects` helper that accepts a registry, matching runtime, and layout elements, then creates Public/Studio parent `RouteObject`s while retaining `RouteLifecycle`, `RouteInputProvider`, `ProtectedRoute`, Suspense, render boundary, and chunk recovery around each registered leaf. Add `layoutGroup: "PUBLIC"` to currently installed platform/reference definitions and keep the existing `AppShell` as the installed Public layout until Task 13 atomically installs the complete TechLog runtime. Extend `FE-REG-ROUTE` governance with required string field `layoutGroup`, allowed values `PUBLIC | STUDIO`, and the TechLog route/search schema IDs; add `layoutGroup` to breaking fields because layout lifetime changes navigation behavior.
- [ ] **Step 4: Run green and registry gates.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/route-contract.test.ts tests/component/router.test.tsx
corepack pnpm check:registries:structure
corepack pnpm check:architecture
```
- [ ] **Step 5: Commit.**
```bash
git add src/contracts/routes.ts src/contracts/route-runtime-contract.ts src/features/installed-feature-contracts.ts src/features/installed-feature-runtimes.tsx src/features/reference-feature/contracts/reference-feature-contract.ts src/features/tech-log/contracts/tech-log-route-contract.ts src/features/tech-log/contracts/tech-log-message-catalog.ts src/features/tech-log/presentation/tech-log-route-codecs.ts src/presentation/routes/app-router.tsx src/presentation/routes/route-codecs.ts src/presentation/routes/platform-route-codecs.ts config/contracts/registry-governance.json tests/features/tech-log/route-contract.test.ts tests/component/router.test.tsx
git commit -m "feat: add grouped TechLog route contracts"
```
### Task 6: Port exact styles, fonts, Public shell, header, and search dialog
**Files:**
- Create: `src/features/tech-log/presentation/styles/globals.css`
- Create: `src/features/tech-log/presentation/styles/studio.css`
- Create: `src/features/tech-log/presentation/styles/studio-editor.css`
- Create: `src/features/tech-log/presentation/styles/workflow.module.css`
- Create: `src/features/tech-log/presentation/styles/publication-flow.module.css`
- Modify: `src/main.tsx`
- Create: `src/features/tech-log/presentation/public/components/site-header.tsx`
- Create: `src/features/tech-log/presentation/public/components/search-dialog.tsx`
- Create: `src/features/tech-log/presentation/public/components/fatal-error-state.tsx`
- Create: `src/features/tech-log/presentation/public/public-shell.tsx`
- Test: `tests/features/tech-log/style-contract.test.ts`
- Test: `tests/features/tech-log/public-shell.test.tsx`
- [ ] **Step 1: Add red style and interaction contracts.** Render the real shell and assert its DOM/class/ARIA relationships plus consumer-visible computed typography, color, width, spacing, focus, and minimum target behavior where the test browser supports it; media-query transitions and full computed-style/pixel equality remain Task 14 browser assertions. Assert header links/labels match, `/` brand navigation works, search opens by click and keyboard, Escape/focus restoration work, and dialog results navigate to canonical routes. Do not grep CSS source text or assert private CSS-module key inventories.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/style-contract.test.ts tests/features/tech-log/public-shell.test.tsx
```
Expected: source styles/shell are absent.
- [ ] **Step 3: Copy styles and port shell components.** Import fonts and TechLog CSS after target `theme.css`; translate navigation APIs only. Preserve source header DOM and mobile behavior. Ensure Public pages render inside source-equivalent `<main>` without the old `AppShell` chrome.
- [ ] **Step 4: Run green and static style checks.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/style-contract.test.ts tests/features/tech-log/public-shell.test.tsx
corepack pnpm lint
corepack pnpm check:types:app
```
- [ ] **Step 5: Commit.**
```bash
git add src/main.tsx src/features/tech-log/presentation/styles src/features/tech-log/presentation/public tests/features/tech-log/style-contract.test.ts tests/features/tech-log/public-shell.test.tsx
git commit -m "feat: port TechLog shells and styles"
```
### Task 7: Port Public home, explore, and search screens
**Files:**
- Create: `src/features/tech-log/presentation/public/components/home-focus.tsx`
- Create: `src/features/tech-log/presentation/public/components/latest-index.tsx`
- Create: `src/features/tech-log/presentation/public/components/explore-filter-form.tsx`
- Create: `src/features/tech-log/presentation/public/components/public-record-list.tsx`
- Create: `src/features/tech-log/presentation/public/pages/home-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/explore-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/explore-kind-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/search-page.tsx`
- Create: `src/features/tech-log/domain/public/focus-state.ts`
- Test: `tests/features/tech-log/public-discovery-screens.test.tsx`
- [ ] **Step 1: Add red component cases.** Port source expectations for headings, introductory copy, counts, latest records, focus-tab URL normalization and Arrow/Home/End keyboard movement, explore kind/status/query filters, empty results, URL search synchronization, result ordering, keyboard submit, and result navigation.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-discovery-screens.test.tsx
```
Expected: discovery pages are missing.
- [ ] **Step 3: Port exact source JSX and bind queries.** Replace async Next server inputs with `useRouteInput` plus `application.features.get("tech-log").publicContent`; preserve DOM/classes/copy and query semantics. Use `Link`/`useNavigate` translations only.
- [ ] **Step 4: Run green and typecheck.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-discovery-screens.test.tsx
corepack pnpm check:types:app
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/domain/public/focus-state.ts src/features/tech-log/presentation/public tests/features/tech-log/public-discovery-screens.test.tsx
git commit -m "feat: port TechLog discovery screens"
```
### Task 8: Port Public cases, references, questions, and topics
**Files:**
- Create: `src/features/tech-log/presentation/public/components/public-document-header.tsx`
- Create: `src/features/tech-log/presentation/public/components/public-document-relations.tsx`
- Create: `src/features/tech-log/presentation/public/components/case-document-page.tsx`
- Create: `src/features/tech-log/presentation/public/components/reference-document-page.tsx`
- Create: `src/features/tech-log/presentation/public/components/question-document-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/case-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/reference-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/question-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/topic-page.tsx`
- Test: `tests/features/tech-log/public-document-screens.test.tsx`
- [ ] **Step 1: Add red cases from source rendered-HTML and interaction tests.** Assert each known slug's exact title, metadata, relation sections, table of contents, rendered blocks, evidence media, anchors, back links, and topic aggregation. Assert unknown slugs use Public not-found rather than a generic runtime error.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-document-screens.test.tsx
```
Expected: document route pages are missing.
- [ ] **Step 3: Port page/component JSX and connect shared renderer.** Preserve all source record ordering and classes. Keep specialized hard-coded source case pages represented by the same exact output at their canonical slugs.
- [ ] **Step 4: Run green plus accessibility component checks.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-document-screens.test.tsx tests/features/tech-log/public-render.test.tsx
corepack pnpm check:types:app
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/presentation/public tests/features/tech-log/public-document-screens.test.tsx
git commit -m "feat: port TechLog document screens"
```
### Task 9: Port projects, releases, profile, and Public fallbacks
**Files:**
- Create: `src/features/tech-log/presentation/public/components/project-navigation.tsx`
- Create: `src/features/tech-log/presentation/public/components/project-page-header.tsx`
- Create: `src/features/tech-log/presentation/public/pages/projects-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/project-overview-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/project-records-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/project-decisions-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/project-activity-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/releases-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/release-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/profile-page.tsx`
- Create: `src/features/tech-log/presentation/public/pages/public-not-found-page.tsx`
- Test: `tests/features/tech-log/public-index-screens.test.tsx`
- [ ] **Step 1: Add red cases.** Assert exact project tabs/active states, record/decision/activity filtering and order, release index/detail text, profile content, cross-links, and Public unknown-route/unknown-record copy.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-index-screens.test.tsx
```
Expected: remaining Public screens are missing.
- [ ] **Step 3: Port exact source markup and route wiring.** Translate Next links only; derive active tab from React Router location without changing element structure.
- [ ] **Step 4: Run complete Public green suite.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/public-*.test.tsx tests/features/tech-log/content-format.test.ts tests/features/tech-log/public-query.test.ts
corepack pnpm check:types:app
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/presentation/public tests/features/tech-log/public-index-screens.test.tsx
git commit -m "feat: complete TechLog public screens"
```
### Task 10: Port Studio provider, runtime boundary, shell, dashboard, list, and creation
**Files:**
- Create: `src/features/tech-log/presentation/studio/studio-provider.tsx`
- Create: `src/features/tech-log/presentation/studio/use-studio.ts`
- Create: `src/features/tech-log/presentation/studio/studio-runtime-boundary.tsx`
- Create: `src/features/tech-log/presentation/studio/components/studio-header.tsx`
- Create: `src/features/tech-log/presentation/studio/components/studio-dashboard.tsx`
- Create: `src/features/tech-log/presentation/studio/components/document-list.tsx`
- Create: `src/features/tech-log/presentation/studio/components/new-document-form.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/studio-home-page.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/documents-page.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/new-document-page.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx`
- Create: `src/features/tech-log/presentation/studio/studio-shell.tsx`
- Test: `tests/features/tech-log/studio-shell-smoke.test.tsx`
- Test: `tests/features/tech-log/studio-screens-smoke.test.tsx`
- [ ] **Step 1: Port red shell/screen tests.** Assert one gateway creation per Studio shell session, a new gateway plus cleared requests/dialogs on `pageshow` with `persisted === true`, source header/nav/labels, dashboard totals/status links, list filters/pagination/empty/error/retry states, new-document type selection and redirect, direct route access without auth UI, and in-shell Studio not-found behavior.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-shell-smoke.test.tsx tests/features/tech-log/studio-screens-smoke.test.tsx
```
Expected: Studio provider and real pages are absent.
- [ ] **Step 3: Port provider/shell/screens.** Resolve `createStudioGateway` through the application feature input once via lazy state/ref, preserve gateway state across child navigation, cancel obsolete requests, and preserve source loading/error/not-found markup. Recreate the gateway and provider generation when a persisted bfcache page is shown; use provider generation keys so all child state and dialogs reset with it.
- [ ] **Step 4: Run green and composition regression.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-shell-smoke.test.tsx tests/features/tech-log/studio-screens-smoke.test.tsx tests/features/tech-log/runtime-composition.test.ts
corepack pnpm check:architecture
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/presentation/studio tests/features/tech-log/studio-shell-smoke.test.tsx tests/features/tech-log/studio-screens-smoke.test.tsx
git commit -m "feat: port TechLog Studio shell and indexes"
```
### Task 11: Port document editors and instant preview
**Files:**
- Create: `src/features/tech-log/presentation/studio/components/common-document-fields.tsx`
- Create: `src/features/tech-log/presentation/studio/components/case-fields.tsx`
- Create: `src/features/tech-log/presentation/studio/components/reference-fields.tsx`
- Create: `src/features/tech-log/presentation/studio/components/question-fields.tsx`
- Create: `src/features/tech-log/presentation/studio/components/ordered-text-list.tsx`
- Create: `src/features/tech-log/presentation/studio/components/relation-editor.tsx`
- Create: `src/features/tech-log/presentation/studio/components/document-editor.tsx`
- Create: `src/features/tech-log/presentation/studio/components/document-editor-screen.tsx`
- Create: `src/features/tech-log/presentation/studio/components/instant-preview.tsx`
- Create: `src/features/tech-log/presentation/studio/components/document-status-rail.tsx`
- Create: `src/features/tech-log/presentation/studio/components/publication-flow-classes.ts`
- Create: `src/features/tech-log/presentation/studio/pages/document-edit-page.tsx`
- Test: `tests/features/tech-log/studio-editor-smoke.test.tsx`
- [ ] **Step 1: Port red editor tests.** Assert exact controls/order/labels for case/reference/question, loaded working-copy values, add/remove/reorder relations and ordered lists, dirty state, status rail, instant preview updates, focus behavior, and source accessibility names.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-editor-smoke.test.tsx
```
Expected: the edit route screen is missing or lacks source controls.
- [ ] **Step 3: Port editor components exactly.** Keep local working-copy state presentation-owned, reuse Content Format v1/shared Public renderer for preview, and preserve CSS module class-name composition through a typed `publication-flow-classes.ts` equivalent.
- [ ] **Step 4: Run green and renderer regression.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-editor-smoke.test.tsx tests/features/tech-log/content-format.test.ts tests/features/tech-log/public-render.test.tsx
corepack pnpm check:types:app
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/presentation/studio tests/features/tech-log/studio-editor-smoke.test.tsx
git commit -m "feat: port TechLog Studio editors"
```
### Task 12: Implement save, conflict, dirty-leave, validation, and preview workflows
**Files:**
- Create: `src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx`
- Create: `src/features/tech-log/presentation/studio/components/unsaved-leave-dialog.tsx`
- Create: `src/features/tech-log/presentation/studio/components/use-before-unload.ts`
- Create: `src/features/tech-log/presentation/studio/components/validation-report.tsx`
- Create: `src/features/tech-log/presentation/studio/components/validation-screen.tsx`
- Create: `src/features/tech-log/presentation/studio/components/public-preview-screen.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/document-validation-page.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/document-preview-page.tsx`
- Modify: `src/features/tech-log/presentation/studio/components/document-editor-screen.tsx`
- Test: `tests/features/tech-log/studio-save-navigation.test.tsx`
- Test: `tests/features/tech-log/studio-validation-preview.test.tsx`
- [ ] **Step 1: Add red workflow cases.** Cover save pending/success, revision conflict without data loss, gateway error/retry, internal link leave dialog, stay/discard/save-then-navigate choices, trigger focus restoration, browser `beforeunload`, validation issue anchors, validation state/freshness, preview create/current/stale/expired states, and exact next-action labels.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-save-navigation.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx
```
Expected: saves/guards and validation/preview pages are absent.
- [ ] **Step 3: Port workflow behavior.** Generate a new idempotency key per user command and reuse it only for that command's safe retry. Keep source dirty/conflict semantics, dialog DOM/focus trap/return focus, and derived state copy. Abort route-obsolete reads without converting aborts into visible errors.
- [ ] **Step 4: Run green and relevant browser smoke.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-save-navigation.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx tests/features/tech-log/mock-studio-gateway.test.ts
corepack pnpm check:browser-security
```
- [ ] **Step 5: Commit.**
```bash
git add src/features/tech-log/presentation/studio tests/features/tech-log/studio-save-navigation.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx
git commit -m "feat: port TechLog Studio validation workflow"
```
### Task 13: Port publish, unpublish, publication history, and immutable snapshots
**Files:**
- Create: `src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx`
- Create: `src/features/tech-log/presentation/studio/components/publish-screen.tsx`
- Create: `src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx`
- Create: `src/features/tech-log/presentation/studio/components/publication-list.tsx`
- Create: `src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/document-publish-page.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/publications-page.tsx`
- Create: `src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx`
- Create: `src/features/tech-log/presentation/tech-log-route-runtime.tsx`
- Modify: `src/contracts/routes.ts`
- Modify: `src/contracts/route-runtime-contract.ts`
- Modify: `src/features/installed-feature-contracts.ts`
- Modify: `src/features/installed-feature-runtimes.tsx`
- Modify: `src/features/installed-feature-adapters.ts`
- Modify: `src/features/installed-feature-messages.ts`
- Modify: `src/presentation/routes/route-runtime.tsx`
- Modify: `src/presentation/routes/platform-route-codecs.ts`
- Modify: `public/release-manifest.json`
- Modify: `scripts/test-performance.ts`
- Modify: `tests/component/router.test.tsx`
- Modify: `tests/component/runtime-application.test.tsx`
- Modify: `tests/features/reference-feature/reference-contract.test.ts`
- Remove: `src/features/reference-feature/presentation/**`
- Remove: `src/presentation/examples/auth-example-page.tsx`
- Remove: `src/presentation/examples/platform-overview-page.tsx`
- Remove: `src/presentation/examples/state-gallery-page.tsx`
- Remove: `src/presentation/examples/ui-gallery-page.tsx`
- Remove: `src/presentation/pages/home-page.tsx`
- Remove: `src/presentation/pages/not-found-page.tsx`
- Remove: `tests/component/platform-overview-page.test.tsx`
- Remove: `tests/features/reference-feature/reference-page.test.tsx`
- Remove: `tests/features/reference-feature/reference-production-vertical.test.tsx`
- Remove: `tests/e2e/platform-overview.spec.ts`
- Remove: `tests/e2e/reference-form.spec.ts`
- Remove: `tests/e2e/reference-route.spec.ts`
- Remove: `tests/e2e/ui-gallery.spec.ts`
- Test: `tests/features/tech-log/studio-publication-flow.test.tsx`
- E2E: `tests/e2e/tech-log-studio-workflow.spec.ts`
- [ ] **Step 1: Port red publication tests.** Assert blocked publish for invalid/stale preview, warning acknowledgement requirements, pending/error/retry behavior, successful event/URL/state, publication filtering, unpublish reason/confirmation, immutable historical snapshot rendering after later edits, and unknown publication Studio not-found.
- [ ] **Step 2: Run red.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-publication-flow.test.tsx
```
Expected: publication screens are missing.
- [ ] **Step 3: Port publication flow exactly.** Preserve source DOM/classes/copy and gateway command ordering. Render event snapshots through the shared Public renderer; never substitute current working-copy content.
- [ ] **Step 4: Atomically install the complete feature.** Make `ROUTE_REGISTRY`, `ROUTE_RUNTIME_CONTRACT`, codecs, runtime imports, and release-manifest chunk entries expose only the complete TechLog routes; configure `PublicShell` and `StudioShell` as their layout elements. Keep the non-UI reference contracts/adapters and their HTTP/platform tests installed as template contract fixtures, but remove their product routes, runtime pages, page-level tests, and every `/examples/*` screen. Update the reference contract test so it continues to prove API/schema/invalidation behavior without asserting product route installation. Retain platform boot, diagnostics, providers, lifecycle boundaries, service worker, and generic design-system infrastructure.
- [ ] **Step 5: Update route consumers.** Rewrite router/runtime-application expectations for the TechLog home, point the performance probe at `TECH_LOG_HOME`, and write all 27 derived chunk IDs to `public/release-manifest.json`. Search the active source/tests/config for old product route imports and prove only deliberately retained platform test-fixture IDs remain.
- [ ] **Step 6: Run green, removal, and end-to-end workflow.**
```bash
corepack pnpm exec vitest run tests/features/tech-log/studio-publication-flow.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx tests/features/tech-log/mock-studio-gateway.test.ts
corepack pnpm exec vitest run tests/component/router.test.tsx tests/component/runtime-application.test.tsx tests/features/reference-feature/reference-contract.test.ts
corepack pnpm test:sample-removal
corepack pnpm check:registries:structure
corepack pnpm exec playwright test tests/e2e/tech-log-studio-workflow.spec.ts --project=chromium
```
- [ ] **Step 7: Commit.**
```bash
git add -A -- src/contracts src/features src/presentation/examples src/presentation/pages src/presentation/routes public/release-manifest.json scripts/test-performance.ts tests/features tests/component tests/e2e/platform-overview.spec.ts tests/e2e/reference-form.spec.ts tests/e2e/reference-route.spec.ts tests/e2e/ui-gallery.spec.ts tests/e2e/tech-log-studio-workflow.spec.ts
git commit -m "feat: complete TechLog Studio publication flow"
```
### Task 14: Prove visual, responsive, accessibility, architecture, and release parity
**Files:**
- Create: `tests/visual/tech-log.visual.spec.ts`
- Create: `tests/e2e/tech-log-public-discovery.spec.ts`
- Create: `tests/e2e/tech-log-accessibility.spec.ts`
- Create: `tests/e2e/tech-log-responsive.spec.ts`
- Modify: `tests/e2e/app-shell.spec.ts`
- Modify: `tests/e2e/accessibility.spec.ts`
- Modify: `tests/e2e/responsive.spec.ts`
- Remove: `tests/e2e/compact-smoke.spec.ts`
- Remove: `tests/e2e/design-system-interactions.spec.ts`
- Remove: `tests/e2e/i18n.spec.ts`
- Remove: `tests/e2e/theme.spec.ts`
- Remove: `tests/visual/platform.visual.spec.ts`
- Remove: `tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/**`
- Modify: `config/contracts/registry-change-evidence.json`
- Modify: `config/contracts/registry-baseline.json`
- Modify: `config/contracts/registry-baseline.approval.json`
- Modify: `docs/operations/techlog-ui-migration-baseline.md`
- Modify: `README.md`
- [ ] **Step 1: Add parity suites before accepting snapshots.** Cover every canonical route definition, all known Public fixture slugs/versions, and every Studio screen/state. Capture full parity at 360 and 1440 pixels, breakpoint transitions at 1179/1180, 1050, 1024, 980, 900, 767/768, 420, 390, 820, and a compact 375-pixel viewport, with fixed timezone, fonts-ready wait, animation disabled, deterministic clock/data, and no masks.
- [ ] **Step 2: Establish source references.** Run the source app and target app under the same Chromium viewport/device scale/color scheme, capture both into temporary artifact directories, and use pixel diff plus DOM/class/text/ARIA assertions. Require zero pixel difference after deterministic controls; if browser rasterization still differs, document the exact pixels/cause and obtain user approval before accepting a target baseline. Source reference images are not copied into target snapshots as a shortcut, and committed/CI tests read only target fixtures and snapshots rather than the external source path.
- [ ] **Step 3: Run visual/responsive/a11y red.**
```bash
corepack pnpm exec playwright test tests/visual/tech-log.visual.spec.ts --config=playwright.visual.config.ts --project=chromium
corepack pnpm exec playwright test tests/e2e/tech-log-responsive.spec.ts tests/e2e/tech-log-accessibility.spec.ts --project=chromium
```
Expected before final corrections: any remaining framework-port drift is reported with a route/viewport-specific diff.
- [ ] **Step 4: Correct only parity defects.** Fix DOM/CSS/import ordering/router lifecycle differences without redesign. Confirm zero unexpected console errors, no horizontal overflow, keyboard-accessible dialogs/navigation, valid heading/landmark order, restored focus, and Axe results matching or improving on source without changing appearance.
- [ ] **Step 5: Retire starter-only browser evidence.** Rewrite `app-shell`, registry-wide accessibility, and responsive suites against TechLog. Delete the example-gallery/theme/locale E2E cases because those controls intentionally leave the product UI, while retaining direct component/design-system tests for the underlying platform capabilities. Replace old platform screenshots with reviewed TechLog screenshots; do not leave stale snapshots unreferenced.
- [ ] **Step 6: Record and accept the governed route/schema migration.** Run `corepack pnpm check:registries` once to generate `artifacts/quality/registries.json` and list the exact breaking change IDs. Add one complete evidence row per reported breaking change to `registry-change-evidence.json`, covering the TechLog route migration/version, atomic release-manifest/runtime update, same-release compatibility window, rollback to the prior feature commit, and owner `tech-log-frontend`. Rerun until evidence passes, then execute:
```bash
REGISTRY_BASELINE_OWNER=tech-log-frontend REGISTRY_BASELINE_REASON="Install approved TechLog Public and Studio route contract" node scripts/update-registry-baseline.ts artifacts/quality/registries.json
corepack pnpm check:registries
```
Expected: approval digest matches the newly committed snapshot and compatibility impact is `none` with no unacknowledged change.
- [ ] **Step 7: Run focused full TechLog gates.**
```bash
corepack pnpm exec vitest run tests/features/tech-log
corepack pnpm exec playwright test tests/e2e/tech-log-public-discovery.spec.ts tests/e2e/tech-log-studio-workflow.spec.ts tests/e2e/tech-log-responsive.spec.ts tests/e2e/tech-log-accessibility.spec.ts --project=chromium
corepack pnpm test:visual
```
- [ ] **Step 8: Run repository gates and classify baseline-only failures.**
```bash
corepack pnpm check:types
corepack pnpm lint
corepack pnpm check:architecture
corepack pnpm check:design-system
corepack pnpm check:i18n
corepack pnpm check:registries
corepack pnpm check:browser-security
corepack pnpm test:all
corepack pnpm build
git diff --check
git status --short
```
All gates must pass except an exactly reproduced, documented environment-only baseline. For any baseline exception, rerun its test in isolation, record command/output/count in the baseline document, and prove no TechLog test is among the failures.
- [ ] **Step 9: Use the verification and review skills.** Invoke `superpowers:verification-before-completion`, then `superpowers:requesting-code-review`. Resolve findings with focused red/green tests and rerun affected gates.
- [ ] **Step 10: Commit final parity evidence.**
```bash
git add -A -- tests/visual tests/e2e config/contracts/registry-change-evidence.json config/contracts/registry-baseline.json config/contracts/registry-baseline.approval.json README.md docs/operations/techlog-ui-migration-baseline.md
git commit -m "test: prove TechLog UI migration parity"
```
- [ ] **Step 11: Stop before integration.** Report the feature branch commit range, exact green commands, baseline-only exceptions, visual-diff result, and changed-route inventory. Wait for explicit user approval before `git flow feature finish techlog-ui-migration`, merging into `develop`, pushing, or deleting the feature branch.
## Definition of done
- All 27 canonical route definitions resolve under the correct nested shell, with source-equivalent unknown-content behavior.
- Public content/search/filter/navigation output matches the source data and UI.
- Studio create/edit/save/conflict/validate/preview/publish/unpublish/history flows match the source and persist for one shell session.
- No migrated presentation module imports an adapter, Next.js, Vinext, or Cloudflare module.
- Source assets and all non-Tailwind CSS rules are preserved exactly; every specified viewport has reviewed visual evidence with no unexplained pixel drift.
- Focus, keyboard, dialog, landmarks, labels, and Axe coverage pass.
- Focused tests, typecheck, lint, architecture, registry, browser-security, build, and applicable repository suites pass, with any infrastructure-only baseline reproduced and documented.
- The work remains on `feature/techlog-ui-migration` until the user explicitly authorizes GitFlow feature completion into `develop`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,328 @@
# TechLog 전체 UI 이식 설계
## 상태
- 승인일: 2026-08-15
- 원본: `/home/donghyeon/workspace/techlog-studio-frontend`
- 대상: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend`
- 결정: 원본 UI를 보존하고 현재 Vite·React Router·포트/어댑터 구조로 내부 경계만 치환한다.
## 목적
원본 TechLog의 Public과 Studio 전체 화면, URL 구조, 표시 콘텐츠, 상호작용과 세션 기반 Mock 동작을 대상 프로젝트로 이식한다. 대상 프로젝트의 부트스트랩, 오류 경계, 진단, 서비스 워커, route registry와 clean architecture 경계는 유지한다.
이 작업은 새 디자인을 만드는 작업이 아니다. 원본의 DOM 구조, CSS 계산값, 폰트, 자산, 문구, 반응형 동작과 접근성 구조를 기준본으로 삼는다. 구현 편의를 위한 시각적 재해석이나 대상 디자인 시스템에 맞춘 재디자인을 허용하지 않는다.
## 범위
### Public
- 홈과 현재 집중 항목
- 통합 탐색과 유형별 탐색
- Case, Reference, Question 공개 문서
- Topic별 기록
- Project 목록, 개요, 기록, 결정, 활동
- Release 목록과 상세
- Profile
- 헤더 검색 dialog와 검색 결과
- Public 오류, 빈 상태와 404
- 공통 header, footer, document renderer, TOC, 관계, 코드, 표, callout, evidence figure
### Studio
- Studio dashboard
- 작업본 검색·필터·정렬 목록
- Case, Reference, Question 생성
- 편집, 즉시 preview와 저장 상태
- 검증과 issue 위치 이동
- Public preview
- 게시·재게시·게시 취소
- 게시 이벤트 목록과 불변 snapshot
- 충돌, 만료, 요청 실패, 세션 전용 not-found
- 미저장 변경의 내부 이동 dialog와 native `beforeunload`
### 동작 경계
- 원본의 정적 Public 콘텐츠와 query 동작을 유지한다.
- 원본의 `StudioGateway` 계약과 `MockStudioGateway` 상태 전이를 유지한다.
- Studio 세션의 게시 결과는 Public 정적 콘텐츠와 검색 결과를 변경하지 않는다.
- `localStorage`, 실제 서버 저장, 파일 업로드와 실제 배포 게시를 추가하지 않는다.
- Studio 인증은 후속 작업으로 유보한다. 이번 이식에서는 Studio URL에 직접 접근할 수 있고 가짜 로그인 UI를 추가하지 않는다.
## 선택한 접근
### 원본 UI 보존형 이식
원본의 React 마크업, class 이름, CSS, 폰트, 자산과 문구를 유지하고 Next/Vinext 전용 경계만 대상 런타임으로 치환한다.
- `next/link`는 React Router `Link` 또는 의도된 전체 문서 이동 `<a>`로 바꾼다.
- `usePathname`, App Router params와 search params는 React Router route input으로 바꾼다.
- Next layout 수명은 React Router 중첩 layout route로 재현한다.
- 서버 컴포넌트의 정적 조회는 순수 application query와 동기 projection으로 바꾼다.
- Studio의 비동기 요청과 오류는 주입된 `StudioGateway` port를 통해 유지한다.
별도 legacy SPA 삽입은 라우터·상태·오류 경계를 이중화하므로 사용하지 않는다. 대상을 Next/Vinext로 전환하는 방식은 현재 템플릿과 운영 계약을 폐기하므로 사용하지 않는다.
## 대상 아키텍처
TechLog 기능은 하나의 feature boundary 안에서 Public과 Studio 하위 영역을 공유한다. Public renderer를 Studio preview가 함께 사용해야 하므로 두 영역을 서로 독립된 feature로 분리하지 않는다.
```text
src/features/tech-log/
├── contracts/ route, content format, gateway DTO와 schema
├── domain/ Public content와 Studio document/publication 모델
├── application/ Public query, projection, Studio state 계산과 port
├── adapters/
│ ├── static/ 원본 Public 콘텐츠 catalog
│ └── mock/ 세션 수명의 MockStudioGateway
└── presentation/
├── public/ Public shell, pages와 renderer
├── studio/ Studio shell, pages, provider와 editor
├── shared/ 양쪽이 공유하는 안전한 render component
└── styles/ 원본 CSS와 CSS Module
```
공통 플랫폼은 feature의 route contract, route runtime과 adapter factory만 조립한다. Presentation이 adapter 구현을 직접 import하지 않으며 composition root가 gateway와 Public catalog를 주입한다.
대상 템플릿의 기존 example navigation, sidebar, theme·locale selector와 인증 예제 UI는 TechLog 제품 화면에서 제거한다. 더 이상 route registry에서 참조되지 않는 sample presentation은 대상의 sample-removal 정책에 따라 제거한다. 부트 오류와 플랫폼 진단 경계는 유지한다.
## 라우팅과 layout 수명
공통 router는 플랫폼 provider와 오류 경계를 유지하고 두 개의 시각 layout group을 만든다.
```text
공통 부트스트랩·플랫폼 오류 경계
├── PUBLIC layout
│ ├── PublicShell
│ ├── Public routes
│ └── Public 404
└── STUDIO layout
├── StudioRuntimeBoundary
├── StudioProvider
├── StudioShell
├── Studio routes
└── Studio 전용 상태
```
### Public route pattern
| 경로 | 책임 |
| --- | --- |
| `/` | 홈 |
| `/explore` | 통합 탐색 |
| `/explore/:kind` | 유형별 탐색 |
| `/cases/:slug` | Case 문서 |
| `/references/:slug` | Reference 문서 |
| `/questions/:slug` | Question 문서 |
| `/topics/:slug` | Topic별 기록 |
| `/projects` | Project 목록 |
| `/projects/:slug` | Project 개요 |
| `/projects/:slug/records` | Project 기록 |
| `/projects/:slug/decisions` | Project 결정 |
| `/projects/:slug/activity` | Project 활동 |
| `/releases` | Release 목록 |
| `/releases/:version` | Release 상세 |
| `/profile` | Profile |
| `/search` | 검색 결과 |
| `*` | Public 404 |
### Studio route pattern
| 경로 | 책임 |
| --- | --- |
| `/studio` | dashboard |
| `/studio/documents` | 작업본 목록 |
| `/studio/documents/new` | 새 문서 생성 |
| `/studio/documents/:id/edit` | 편집과 즉시 preview |
| `/studio/documents/:id/validation` | 검증 보고서 |
| `/studio/documents/:id/preview` | 저장·검증된 Public preview |
| `/studio/documents/:id/publish` | 게시·재게시 |
| `/studio/publications` | 게시 이벤트 목록 |
| `/studio/publications/:publicationEventId/preview` | 불변 snapshot |
| 정의되지 않은 `/studio/*` | 실제 route 404 |
알 수 없는 동적 document ID와 publication event ID는 route에는 일치하지만 Studio shell 안의 전용 찾을 수 없음 상태를 렌더링한다. 선행 검증이나 preview가 부족한 직접 진입은 redirect하지 않고 원본과 같은 차단 이유와 다음 행동을 보여 준다.
Studio layout의 provider는 Studio 내부 client navigation 동안 유지된다. Studio에서 Public으로 가는 `공개 사이트 보기`는 일반 `<a>`를 사용해 전체 문서 이동, `beforeunload`와 세션 초기화를 보존한다.
라우트 정의에는 `PUBLIC` 또는 `STUDIO` layout group을 명시한다. Studio 인증을 구현할 때는 `STUDIO` group의 access policy만 `session-required`로 전환할 수 있어야 하며 화면 컴포넌트나 URL을 다시 설계하지 않는다.
## 컴포넌트 이식 규칙
- 원본 HTML tag, class 이름, 표시 문구, 요소 순서와 ARIA 관계를 유지한다.
- 원본 컴포넌트 경계를 가능한 한 유지하되 Next layout과 router hook에만 필요한 변경을 한다.
- 대상 generic design-system primitive로 화면을 다시 그리지 않는다.
- Public과 Studio가 공유하는 Public renderer는 하나만 유지한다.
- 문자열 HTML과 `dangerouslySetInnerHTML`을 도입하지 않는다.
- source의 semantic heading, landmark, tab, dialog, live region과 focus restoration을 유지한다.
- 검색 dialog는 하나만 렌더링하고 원본처럼 viewport 중앙에 둔다.
- Studio editor 탭 전환은 draft를 잃지 않으며 즉시 preview는 gateway 상태를 바꾸지 않는다.
## 스타일·폰트·자산 보존
원본의 다음 파일을 시각 기준으로 사용한다.
- `app/globals.css`
- `app/studio.css`
- `app/studio-editor.css`
- `components/studio/workflow.module.css`
- `components/studio/publication-flow.module.css`
- `pretendard/dist/web/variable/pretendardvariable.css`
- `@fontsource/ibm-plex-mono/400.css`
- `@fontsource/ibm-plex-mono/500.css`
- `public/favicon.svg`
- `public/media/fetch-strategy-boundary.svg`
대상 `theme.css`의 Tailwind import와 플랫폼 token은 부트 오류 같은 플랫폼 표면을 위해 유지한다. TechLog 전역 스타일은 그 뒤에 unlayered CSS로 한 번만 로드한다. 원본 `globals.css`의 중복 `@import "tailwindcss"`만 제외하며 그 뒤 rule 순서와 선언값은 유지한다.
다음 값은 재해석하거나 대상 token 값으로 치환하지 않는다.
- `--canvas`, `--paper`, `--ink`, `--muted`, `--faint`, `--signal` 등 원본 color token
- `--shell: 1180px`, `--body-copy: 42rem`
- font size, weight, line-height와 letter-spacing
- border, radius, shadow와 transition
- `1179`, `1050`, `1024`, `980`, `900`, `767`, `420px` breakpoint
- reduced-motion 동작과 최소 `44px` interaction target
Public과 Studio의 최종 computed style은 원본이 기준이다. 자산은 내용 변경 없이 복사하고 build가 제공하는 동일-origin URL을 사용한다.
## Public 데이터와 query
Public 콘텐츠는 immutable static catalog adapter가 소유한다. Application query는 catalog port만 사용해 다음 결과를 파생한다.
- 홈 focus와 latest index
- 유형·topic·project filter
- 제목·요약·topic·project 검색
- Project별 record, decision, activity
- 문서 relation과 related content
- Release와 Profile
페이지 컴포넌트는 URL params와 query를 route codec으로 검증한 뒤 application query를 호출한다. 잘못된 filter 값은 원본의 canonical 상태로 정규화하고 검색 query는 URL에 보존한다. 존재하지 않는 slug와 version은 Public 404로 보낸다.
## Content Format과 공유 renderer
Case 본문의 Content Format v1 parser, serializer와 `PublicRenderModel` 판별 union을 유지한다. 지원 block은 heading, paragraph, blockquote, ordered/unordered list, code block, data table, callout와 evidence figure다. 지원 inline은 text, emphasis, strong, inline code, link와 status다.
Public 문서와 Studio 즉시 preview, 검증 preview, publication snapshot은 같은 typed renderer를 사용한다. raw HTML, script, `javascript:` URL과 임의 asset URL은 계속 거절한다.
## Studio port, adapter와 상태
Presentation은 `StudioGateway` port만 사용한다. 모든 method는 `Promise`를 반환하고 선택적인 `AbortSignal`을 받는다. 예상 가능한 실패는 RFC 9457 본문, status, code와 retryable 정보를 가진 `StudioGatewayError`로 정규화한다. `AbortError`만 조용히 무시하고 프로그래밍 오류는 runtime boundary로 전달한다.
Mock adapter는 원본 seed data, cursor, conflict fixture, preview expiry, validation, publication aggregate와 idempotency 동작을 보존한다.
Studio 상태 축은 다음 값을 유지한다.
- Editor: `CLEAN`, `DIRTY`, `SAVING`, `CONFLICT`
- Validation result: `NOT_RUN`, `INVALID`, `WARNINGS`, `VALID`
- Validation freshness: `NONE`, `CURRENT`, `STALE`
- Preview: `NONE`, `CURRENT`, `STALE`, `EXPIRED`
- Publication: `NEVER_PUBLISHED`, `PUBLISHED`, `UNPUBLISHED`
저장은 불완전 draft를 허용하고 version을 증가시킨다. 저장 뒤 validation은 `NOT_RUN``NONE`, 기존 preview는 `STALE`이 된다. 게시 준비 검증과 다음 행동 우선순위, warning acknowledgement, publication idempotency와 unpublish 규칙은 원본 계약을 유지한다.
Studio 세션은 layout이 유지되는 동안만 살아 있다. 새로고침, Public 전체 이동과 `pageshow.persisted === true`에서 새 Mock adapter를 만들고 draft, request와 dialog를 초기화한다. 고정 fixture ID는 seed 상태로 돌아가고 세션 생성 ID는 Studio 전용 찾을 수 없음 상태가 된다.
## 오류와 빈 상태
- Boot failure는 기존 `BootErrorShell`이 담당한다.
- Public query·render 실패는 Public shell 안의 원본 fatal error surface를 사용한다.
- Public empty, no-result와 not-found는 서로 다른 원본 상태를 유지한다.
- Studio request failure는 `StudioGatewayError`의 code와 retryable을 기준으로 원본 메시지와 action을 표시한다.
- Studio render failure는 `StudioRuntimeBoundary`가 담당한다.
- save conflict는 현재 입력을 보존하고 gateway 최신본과 field path를 비교한다.
- auto merge와 auto overwrite를 추가하지 않는다.
- dirty 내부 이동은 머무르기, 변경 버리기와 저장 후 이동을 제공하고 trigger focus를 복원한다.
## 인증 유보
Studio는 인증이 필요한 제품 영역이지만 이번 이식에서는 인증 구현을 범위 밖으로 둔다.
- Studio route group을 별도로 유지한다.
- access policy 전환 지점을 route contract에 둔다.
- 현재는 직접 URL 접근을 허용한다.
- 가짜 로그인, 임시 계정과 인증된 것처럼 보이는 UI를 추가하지 않는다.
- 후속 인증 작업은 Studio 화면 DOM, URL과 gateway contract를 변경하지 않고 route guard와 session adapter를 연결하는 방식으로 수행한다.
## GitFlow와 전달 브랜치
저장소는 `main`을 production 브랜치, `develop`을 integration 브랜치로 사용하는 GitFlow로 초기화한다. 이 설계 문서와 선행 template 동기화가 포함된 현재 `main`에서 `develop`을 만든다.
- feature prefix는 `feature/`를 사용한다.
- 화면 이식 작업은 `develop`에서 시작한 `feature/techlog-ui-migration`에서만 수행한다.
- 구현 계획, 테스트, source port, 자산과 검증 문서는 같은 feature 브랜치에 커밋한다.
- `main``develop`에는 화면 이식 production code를 직접 커밋하지 않는다.
- feature 통합은 구현·검증 완료 뒤 사용자가 선택한 방식으로 수행한다.
- 원격 push, remote branch 생성과 GitFlow feature finish는 별도 사용자 요청 전에는 수행하지 않는다.
## 테스트 전략
모든 production 동작 변경은 TDD로 진행한다. 각 slice는 기대 동작을 표현하는 실패 테스트를 먼저 추가하고 예상한 이유로 실패하는 것을 확인한 다음 최소 구현을 추가한다.
### 구조·계약 테스트
- 모든 Public·Studio route ID, path, layout group과 runtime module mapping
- Public/Studio layout 수명과 Studio gateway 단일 instance
- 원본 heading, landmark, class, element 순서와 ARIA 관계
- source CSS color, typography, width, breakpoint와 touch target 계약
- Public content graph의 유효한 내부 링크와 404 경계
- Content Format parser·serializer round trip과 unsafe input 거절
### 상호작용·상태 테스트
- 홈 focus tab과 URL 정규화
- 탐색 filter, reset, empty와 no-result
- 검색 dialog open/close, focus trap·restore와 query 보존
- document TOC, code copy와 evidence dialog
- Studio document 생성, 편집, 저장, validation, preview, publish와 unpublish
- conflict, stale·expired preview, warning acknowledgement와 idempotency
- dirty navigation dialog와 native `beforeunload`
- 새로고침·Public 이동·bfcache 복원 뒤 세션 reset
### 접근성·반응형·시각 검증
동일한 Chromium, font와 reduced-motion 조건에서 원본과 대상을 캡처한다.
- 고정 fixture로 접근 가능한 모든 canonical Public·Studio route: `360px`, `1440px`
- breakpoint 대표 화면: `390`, `768`, `820`, `1024`, `1180px`
- 열린 검색 dialog와 mobile menu
- Studio editor, 즉시 preview, warning과 dirty-leave dialog
- viewport 전체의 예상하지 않은 가로 overflow
- axe, keyboard navigation, focus visibility, single H1과 `aria-current`
동적 timestamp, caret와 animation을 고정한 뒤 pixel difference는 원칙적으로 `0`을 요구한다. 차이는 개선 여부가 아니라 원본과 동일한지로 판정한다. 브라우저 rasterization처럼 통제할 수 없는 차이가 발견되면 원인을 기록하고 사용자의 별도 승인을 받기 전에는 baseline을 갱신하지 않는다.
CI는 커밋된 target snapshot과 계약 테스트를 사용하며 외부 원본 경로에 의존하지 않는다. 구현 중 로컬 one-time source/target 비교로 baseline을 만들고 이후 target regression test로 고정한다.
## 검증 명령과 완료 기준
구현 완료 전에 다음 범주를 모두 실행한다.
- TechLog route·component·integration test
- TechLog Studio gateway·state·publication test
- Playwright visual·accessibility test
- TypeScript 전체 project 검사
- ESLint
- architecture, route registry, design-system과 i18n contract 검사
- production build
- repository full test suite
현재 저장소에 이미 기록된 RLIMIT, EMFILE, umask와 `/tmp` 관련 19개 환경 의존 실패는 known baseline으로 분리한다. 그 외 새 실패를 허용하지 않으며 TechLog 이식으로 추가된 테스트는 모두 통과해야 한다. 병합 전에는 known baseline과 새 실패를 구분한 결과를 사용자에게 보고한다.
완료 조건은 다음과 같다.
1. 원본 Public과 Studio canonical URL이 대상에서 모두 열리고 정의되지 않은 경로가 올바른 404를 반환한다.
2. 원본의 표시 콘텐츠, DOM·ARIA 구조, CSS 계산값, font와 asset이 유지된다.
3. Public 검색·탐색·문서 연결과 Studio 전체 Mock workflow가 원본과 동일하게 동작한다.
4. Studio 내부 이동 동안 상태가 유지되고 전체 문서 이동·새로고침·bfcache에서 초기화된다.
5. 대상의 clean architecture, route registry, 부트·진단·서비스 워커 계약이 유지된다.
6. 승인되지 않은 시각 diff와 새 test failure가 없다.
## 범위 밖
- Studio 인증과 권한
- 실제 HTTP Studio adapter와 백엔드 연결
- PostgreSQL, Cloudflare D1·R2와 파일 업로드
- Public 콘텐츠 CMS화
- 디자인 개선, 문구 수정과 정보 구조 재해석
- 원본에 없는 화면이나 기능 추가
@@ -0,0 +1,402 @@
# TechLog Backend 정합 설계
## 상태
- 승인일: 2026-08-17
- 기준선: `tech-log-frontend` `main` (UI 이식 완료 상태)
- 원본 요구: `/home/donghyeon/workspace/tech-log-alignment-design/01-tech-log-frontend-alignment-design.md`
- Canonical 계약: `/home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml`
- Specification Version `2.0.0`
- **digest·revision의 단일 기록처는 `src/features/tech-log/contracts/studio/canonical-source.json`이다.**
이 문서는 그 값을 복제하지 않는다. 승인 시점에 여기 적혀 있던
`sha256:85a65004…` / revision `0ec5582`은 구현 중 canonical yaml이 갱신되면서
무효가 됐고, 실제로 vendor·고정된 값은 `canonical-source.json`이 기록한
`sha256:99f54f56…` / revision `ce2e748`이다(Task 12 표 3행의 `ce2e748`과 동일).
- 계약 기여(`tech-log-studio-contract-contribution.ts`)는 그 파일을 import해서
`EXTERNAL_PACKAGE` provenance를 채우고, `check:tech-log-contract`가 vendor
사본·`generated.ts`와의 일치를 검증한다. 두 곳이 다시 갈라질 수 있는 지점은
없다.
- 결정: 현재 Public/Studio UI 기준선을 고정하고, Studio 계약·전송 경계와 Asset capability를 canonical 계약에 정합시킨다. Public 조회의 HTTP 전환은 이 사이클에서 제외한다.
### Task 12 완료 상태 (2026-08-18)
12개 Task 전부 `feature/techlog-backend-alignment`에 커밋됐다. 아래는 §완료 조건의 12개 항목을 Task 12 게이트 실행(전체 로그는
`.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-12-report.md`)과 Task 1–11이 기록한 구현 상태를 근거로 판정한 결과다.
| # | 완료 조건 | 판정 | 근거 |
|---|---|---|---|
| 1 | 현재 Public UI·라우트가 변경되지 않는다 | 충족 | Public 화면 테스트(`public-document-screens.test.tsx` 등) 무변경 통과; `case-body-renderer.tsx`/`evidence-figure.tsx``9e5fbd1..HEAD` 사이 diff가 비어 있어 렌더러 코드에 변경이 없다(`check:architecture`/`check:registries`는 import 그래프·레지스트리 정합만 보고 Public 렌더 출력을 관찰하지 않으므로 이 판정의 근거가 아니다). `test:visual`의 Public 스냅샷 실패는 이 브랜치가 아니라 `main``79e9aa8`에서 물려받은 것이다(아래 §Task 12 참고) |
| 2 | 현재 Studio 작업 흐름이 변경되지 않는다 | 충족 | 기본 `MOCK`에서 `test:tech-log`(36 files/303 tests) 전부 PASS; `tech-log-studio-workflow.spec.ts` chromium 2/2 PASS |
| 3 | Studio 계약이 canonical에서 생성되고 digest 고정·drift 게이트 동작 | 충족 | `check:tech-log-contract`: "in sync: @tech-log/studio-contract@2.0.0 (ce2e748), 19 operations". 최종 fix wave에서 이 명령을 `config/ci/gates.json`의 FE-GATE-010과 `test:all`에 연결했다 — 그전까지는 손으로 칠 때만 실행돼 drift 게이트가 실질적으로 비어 있었다 |
| 4 | `StudioGateway` 전체 operation이 HTTP 어댑터로 구현·MSW 검증 | 충족 | `test:unit`/`test:integration`의 HTTP·MSW 계약 스위트 PASS (환경 요인 실패 1건 제외, 아래 참고) |
| 5 | WorkingCopy 저장이 Public Projection을 변경하지 않는다 | 충족 | `studio-publication-flow.test.tsx`, `public-document-screens.test.tsx` PASS |
| 6 | Validation/Preview/Publish가 version·dependency revision으로 묶인다 | 충족 | `studio-validation-preview.test.tsx` PASS |
| 7 | Publication Event/Snapshot 조회 가능, 과거 Snapshot 불변 | 충족 | `studio-publication-flow.test.tsx` PASS |
| 8 | Image/SVG 업로드 + Asset 기반 evidence 삽입 | 충족 | Task 10/11 Asset Picker·업로드 다이얼로그·Asset Library; `test:tech-log` 내 asset 관련 스위트 PASS |
| 9 | `READY` Asset만 Preview/Publish에 사용, `QUARANTINED` 미노출 | 충족 | Task 6/9가 구현; 관련 렌더러·게이트 테스트 PASS |
| 10 | 23개 오류 코드 + idempotency/version 충돌 구분 | 충족 | `error-classification.test.ts` 등 PASS |
| 11 | 프론트가 Backend 도메인 Aggregate를 복제하지 않는다 | 충족 | `check:architecture` PASS (415 modules, 전 import 해석, 12개 회귀 fixture PASS) |
| 12 | 런타임 스위치 `MOCK`/`HTTP` 전환, 기본 `MOCK`에서 기존 parity 스위트 전부 통과 | 충족 | 위 1·2 근거 + 수동 확인: `TECH_LOG_STUDIO_SOURCE=HTTP`에서 Backend 부재 시 Studio 쉘은 정상 렌더되고 패널은 "작업 흐름을 불러오지 못했습니다" 인라인 오류로 우아하게 저하됨(백지·미처리 예외 없음) |
명시적 비완료 항목 — 계획대로 이번 사이클에 포함되지 않는다:
- **실행 중 Backend와의 실응답 대조**: 이 환경에 Backend가 없다. Task 12 수동 확인은 "Backend 부재 시 우아한 오류 상태"까지만 검증했고, 실제 Backend 응답과의 대조는 Backend Studio 구현 완료 후 별도로 수행한다.
- **Public 조회의 HTTP 전환**: 범위에서 명시적으로 제외됐다(§범위 "제외 — Public 조회의 HTTP 전환"). `adapters/static/public-query.ts`는 이번 사이클에서 손대지 않았고, `public-v1.yaml` 기준 별도 spec/plan 사이클로 수행한다.
- **성공 payload의 런타임 계약 검증**: `tech-log-studio-contract-contribution.ts`의 18개 operation은 전부 `passthrough`(`z.unknown()`) `inputValidator`/`outputValidator`를 쓴다. 의도된 선택이고 코드에도 주석으로 남아 있다 — canonical 계약이 payload를 소유하고 `generated.ts`가 컴파일 시점 계약이며, 런타임 재검증은 계약 갱신 때마다 두 곳을 고치게 만든다. `problemValidator`는 그대로 엄격하다(23개 코드 enum + 필드 제약). **결과적으로 성공 응답의 shape 불일치로는 `CONTRACT_VIOLATION`이 발생할 수 없다.** 이 한계는 바로 위 "실행 중 Backend와의 실응답 대조 없음"과 같은 종류의 위험이다: Backend가 없으므로 MSW는 테스트 작성자가 적은 것을 그대로 돌려주고, 그것을 canonical 스키마와 대조하는 주체가 없다. 즉 shape 회귀를 잡을 수 있는 층이 지금은 컴파일 타임 한 겹뿐이다. Backend 대조 사이클에서 (a) 실서버 응답 대조로 대체할지 (b) canonical에서 생성한 런타임 스키마로 `outputValidator`를 채울지 함께 판단한다.
완료 조건 자체는 아니지만, 게이트 실행 중 확인된 사전 존재(pre-existing) 또는 환경적(environmental) 이슈:
- `tests/unit/ci-artifact-contract.test.ts` 16개 테스트가 `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted` 샌드박스 제약으로 실패한다. 브랜치 분기점 `9e5fbd1`에서도 동일하게 재현되는 환경 문제이며 이번 작업과 무관하다.
- `test:coverage`는 위 환경 실패 때문에 vitest가 non-zero로 종료해 `check-risk-coverage.ts`까지 도달하지 못한다(vitest 기본값 `coverage.reportOnFailure: false`). 그 파일만 제외한 진단 실행에서는 `src/application/policies/compatibility.ts`(re-export전용, 계측 가능한 statement 0개)와 `reference-http-gateway.ts`(statements 86.95%/branches 85%, 임계값 90%) 2건이 걸리는데, 둘 다 병합 지점(`9e5fbd1`) 이후 이 브랜치가 건드리지 않은 파일이다.
- `test:visual`(chromium): **최종 fix wave에서 130개 중 13개 실패로 정리했고, 남은 13개는 전부 merge-base `9e5fbd1`에서 동일하게 실패한다** — 이 브랜치가 만든 시각 회귀는 0건이다.
- **판정 근거는 추론이 아니라 실측이다.** merge-base `9e5fbd1`을 detached checkout해 `test:visual`을 그대로 실행했고(13 failed / 117 passed), 두 실행이 남긴 `-actual.png`를 SHA-256으로 대조했다. 13개는 merge-base와 HEAD의 실제 렌더 결과가 **바이트 동일**했다 — 즉 이 브랜치의 코드와 무관하다.
- **물려받은 13개(갱신하지 않음)**: `TECH_LOG_CASE` 1440, `known Public fixture /cases/collection-fetch-join-pagination` 1440, `TECH_LOG_STUDIO_DOCUMENT_PREVIEW` 1440, `TECH_LOG_STUDIO_PUBLICATION_PREVIEW` 1440, `Studio current-preview` 1440, `Studio publication-snapshot` 1440, `Studio immediate preview` 1440(이상 7개는 높이가 **줄었다**), `TECH_LOG_STUDIO_DOCUMENTS` 360/1440, `Studio document-list` 1440, `TECH_LOG_STUDIO_DOCUMENT_NEW` 1440, `Studio new-document` 1440(이상 5개는 크기 변화 없는 픽셀 차이), `TECH_LOG_STUDIO_DOCUMENT_NEW` 360(360×1000 → 360×1130). 높이가 줄어든 7개의 원인은 `main`에 이미 있고 merge-base `9e5fbd1`의 조상인 `79e9aa8`("fix: align TechLog article content widths")로, `.evidence-figure`의 CSS 폭을 `min(61rem, calc(100% + 15rem))`(≈912px)에서 `min(var(--body-copy), 100%)`(672px, 비율 73.6%)로 바꿨다. golden PNG는 `79e9aa8`보다 앞선 `3a7c5de`에서 마지막으로 기록됐다. 갱신은 이 브랜치가 아니라 `main``79e9aa8`에 대해 기록해야 한다.
- **이전 기록의 정정 2건**: (1) "나머지 5개는 크기 변화 없는 픽셀 차이(추가 조사하지 않음)"로 남겨뒀던 항목은 조사 결과 **전부 물려받은 것**이다. (2) `TECH_LOG_STUDIO_DOCUMENT_NEW` 360(360×1000 → 360×1130)을 Asset Picker 때문이라고 원인 B로 분류했었는데, merge-base에서 **동일한 픽셀 수(28,413)로 동일하게 실패**한다 — 물려받은 것이다. 따라서 이 브랜치가 만든 실패는 6개가 아니라 5개다.
- **이 브랜치가 만들어 갱신한 5개**: `TECH_LOG_STUDIO_DOCUMENT_EDIT` 360(360×3313 → 360×3627), `TECH_LOG_STUDIO_DOCUMENT_EDIT` 1440·`Studio case-editor` 1440·`Studio conflict-editor` 1440·`Studio dirty-leave dialog` 1440(전부 1440×2706 → 1440×2999). 전부 Case 편집기 화면이고, 늘어난 293px 영역은 Task 10이 추가한 "EVIDENCE / 본문에 Asset 삽입" 패널(업로드 종류 select + `Asset 업로드` 버튼 + Picker 빈 상태)임을 갱신본에서 직접 확인했다. `--update-snapshots`는 이 5개 테스트에만 `--grep`으로 한정해 실행했다 — 일괄 갱신은 물려받은 13개까지 조용히 흡수해 이 구분을 없애기 때문이다.
- `test:e2e`/`test:a11y`는 chromium에서 전부 통과하고, firefox/webkit 실패는 이 환경의 브라우저 의존성 문제다(firefox: Pretendard 폰트의 "name records not sorted" 경고를 strict 콘솔 검사가 실패로 잡음; webkit: 호스트에 필요한 시스템 라이브러리 없음 — `playwright install-deps` 필요).
전체 명령·원문 출력은 `.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-12-report.md`에 기록했다.
## 목적
현재 TechLog 프론트엔드는 Studio를 세션 수명 `MockStudioGateway`로, Public을 정적 동기 catalog로 구동한다. 이 설계는 다음을 달성한다.
1. Studio HTTP 계약을 canonical `studio-v1.yaml` 단일 출처에서 생성하고, 두 계약이 다시 갈라지지 못하게 빌드로 막는다.
2. `StudioGateway`의 모든 operation을 canonical 계약 기준 HTTP 어댑터로 구현한다.
3. Backend에 이미 존재하는 Asset/Image/SVG capability를 프론트 편집 흐름에 연결한다.
4. 위 전부를 실행 중인 Backend 없이 완료하고, Backend가 완성되면 런타임 스위치만으로 대조할 수 있게 한다.
현재 Public UI 라우트·화면 구성과 Studio의 `작업본 → 편집 → 저장 → 검증 → Public Preview → 게시/재게시 → 게시 기록/Snapshot` 흐름은 변경하지 않는다.
## Source of Truth 우선순위
| 영역 | Source of Truth |
|---|---|
| Public 화면·라우트·사용 흐름 | 현재 `tech-log-frontend` |
| Studio 화면 구조·사용 흐름 | 현재 `tech-log-frontend` |
| Studio HTTP 계약 | `studio-v1.yaml` (단일 canonical) |
| Asset lifecycle·불변 조건 | `studio-v1.yaml` |
| 전송·오류·재시도·관측 규약 | 현재 프론트 플랫폼 (`src/contracts/external-contract-runtime.ts`) |
UI는 Backend 도메인 구조를 그대로 노출하지 않는다. 프론트의 `WorkingCopy`는 Backend Aggregate가 아니라 **편집 계약**이다.
## 확인된 사실과 정정
착수 전 검증에서 원본 요구 문서 및 설계 패키지 README와 실제 코드가 어긋나는 지점을 확인했다. 아래는 구현 기준으로 채택하는 정정이다.
### 이미 충족된 항목
- 설계 패키지 README는 프론트 격차로 "`RecordKind``PROJECT_DECISION` 없음"을 든다. **이미 충족돼 있다**`contracts/studio/studio-api.openapi.yaml:236`, `contracts/studio/generated.ts:200`. README가 지칭하는 대상은 구 `techlog-studio-frontend` 저장소다.
- `application/ports/studio-gateway.ts`의 operation 집합·필터·`IdempotentOptions`는 원본 요구 §4와 이미 일치한다. 포트 재설계가 아니라 구현체 교체가 필요하다.
### 실재하는 격차
- 프론트 계약에 `X-CSRF-TOKEN` 선언이 없다 (canonical 6회 참조, 프론트 0회). canonical은 모든 mutating operation에 CSRF를 요구한다.
- 프론트 계약의 `Idempotency-Key` 선언이 1회로, canonical(2회, 공용 parameter)과 구조가 다르다.
- canonical에는 프론트 계약에 없는 operation이 6개 있다: `getStudioSession`, `listStudioAssets`, `uploadStudioAsset`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset`. canonical 총 operation은 **19개**(프론트 현재 13개).
- canonical 오류 코드는 **23개**로, 원본 요구 §10의 21개에 `IDEMPOTENCY_KEY_REUSED`와 `WARNING_ACKNOWLEDGEMENT_REQUIRED`가 추가된다. 두 코드 모두 필요하다 — 전자는 §10이 요구하는 "version conflict와 idempotency replay 구분"의 실제 코드이고, 후자는 이미 존재하는 `warning-acknowledgements.tsx`가 처리해야 하는 거절 사유다. **23개 전부를 채택한다.**
- 원본 요구는 세션/CSRF를 다루지 않는다. canonical은 CSRF 토큰을 `getStudioSession`이 발급한다고 정한다. 아래 §"세션과 CSRF"에서 경계를 정한다.
### 플랫폼 제약
- `ContractContributionSource``TEMPLATE_FIXTURE`는 타입상 `fixtureId: "REFERENCE_FEATURE_V1"`로 닫혀 있다(`external-contract-runtime.ts:186-190`). TechLog는 `EXTERNAL_PACKAGE`만 사용할 수 있다.
- 플랫폼 계약 런타임은 `requestBody: "NONE" | "JSON"`만 허용하고, 그 외를 구성 시점에 거절한다(`external-contract-runtime.ts:145`, `:407`). 저수준 `client.ts`도 본문을 `JSON.stringify`로 고정한다(`client.ts:719`). **`multipart/form-data`를 표현할 수 없다.**
- 기존 `browser-transfer` capability는 presigned `GET`/`PUT`과 resumable part 업로드용이다(`PresignedTransferMethod = "GET" | "PUT"`). canonical의 `POST /assets` multipart MVP 계약에 그대로 맞지 않는다.
- 설계 패키지의 `MANIFEST.sha256`은 stale하다(yaml은 2026-08-17 갱신, manifest는 08-11 기준이며 `preview-v1.yaml`을 아직 나열한다). 계약 고정은 manifest가 아니라 위 §상태에 기록한 실측 digest를 기준으로 한다.
## 범위
### 포함
| | 범위 |
|---|---|
| **A. 계약 정합** | canonical `studio-v1.yaml` 도입, 타입 생성 자동화, digest 고정, drift 게이트 |
| **B. Studio 전송** | 계약 기여(JSON 18개 전체) + `StudioGateway` HTTP 어댑터(14개 소비) + 세션/CSRF + 오류 매핑 + 런타임 스위치 |
| **C. Asset** | `StudioAssetGateway` 포트, 5개 operation(JSON 4 + multipart 1), Asset Library/Picker/Upload UI, `/studio/assets` 라우트, evidence directive 연결, alt/decorative 규칙 정정 |
### 제외 — Public 조회의 HTTP 전환
원본 요구 §11은 `adapters/static/public-query.ts`를 HTTP로 교체하도록 나열한다. 이 사이클에서 제외하고 별도 사이클로 분리한다.
근거:
- `PublicContentQueries`는 전 메서드가 **동기**이고, 프레젠테이션 19개 파일 31개 호출 지점이 렌더 중 직접 호출한다. async 전환은 전 Public 화면에 loading/error/empty 상태를 도입하는 작업이다.
- 방금 완료한 UI 이식의 시각·접근성 parity 기준선을 광범위하게 흔든다.
- 실행 중인 Public Backend가 없어 지금 전환해도 검증할 대상이 없고 사용자 가치도 없다.
- canonical `public-v1.yaml`(1,765줄)은 준비돼 있으므로, Public API가 실제로 서비스될 때 자체 spec/plan 사이클로 수행한다.
이 제외는 범위 축소가 아니라 순서 결정이다. 완료 조건에서 해당 항목을 별도로 명시한다.
## 선택한 접근
### A. 계약 정합 — digest로 고정된 단일 출처
canonical yaml을 저장소에 vendor하고, 타입을 생성하고, **계약 기여의 `EXTERNAL_PACKAGE` provenance로 canonical revision에 암호학적으로 고정**한다.
`InstalledContractPackageIdentity`(`external-contract-runtime.ts:173`)는 이미 이 목적에 맞는 필드를 요구한다.
```text
packageId @tech-log/studio-contract
version 2.0.0 (canonical info.version, exact SemVer)
digest canonical studio-v1.yaml의 SHA-256
runtimeProtocolVersion 1
sourceRevision 설계 패키지 git revision
```
`digest`·`sourceRevision`의 실제 값은 이 문서가 아니라
`contracts/studio/canonical-source.json`에 기록한다(§상태 참고). 계약 기여가 그
파일을 직접 읽으므로, 문서에 값을 복제하면 갱신을 한쪽에서만 하다가 어긋난다 —
승인본이 실제로 그렇게 어긋났다.
`assertPackageIdentity`는 shape을 검증하므로 npm 레지스트리 없이 지금 사용할 수 있다. 실제 패키지 배포로 승격할 때 같은 필드를 그대로 채운다.
drift 방지는 저장소 관례(`generate:*` / `check:*`)를 따르는 스크립트 한 쌍으로 강제한다.
```text
generate:tech-log-contract canonical yaml → vendor 사본 + generated.ts + digest 기록
check:tech-log-contract 재생성 결과가 커밋 내용과 바이트 동일한지, digest가 계약 기여의
선언과 일치하는지 검증. 불일치 시 실패.
```
`openapi-typescript`를 devDependency로 고정한다. 현재 `generated.ts`는 이 도구로 만들어졌으나 도구도 스크립트도 저장소에 없어 재생성이 불가능하다 — 이것이 두 계약이 갈라진 근본 원인이다.
`X-CSRF-TOKEN`과 공용 `Idempotency-Key` parameter는 생성 결과에 자동 반영된다. 프론트 yaml을 손으로 고치지 않는다.
### B. Studio 전송 — 플랫폼 계약 런타임 사용
계약 기여는 서비스 패키지당 하나이므로, `features/tech-log/contracts/tech-log-studio-contract-contribution.ts` 한 파일에 canonical의 **JSON operation 18개 전부**(19개 중 multipart 업로드 제외)를 선언한다. Asset의 JSON operation 4개도 같은 기여에 속한다 — 같은 `studio-v1` 패키지이기 때문이다. 이 중 `StudioGateway`가 14개를, `StudioAssetGateway`가 4개를 소비한다.
선언 형식은 `reference-feature-contract-contribution.ts`가 확립한 것을 따른다: operation별 `inputValidator`/`outputValidator`/`problemValidator`, `acceptedStatuses`, `retrySemantics`, `commandRecovery`, `commandEffect`, `projectRequest`, 그리고 byte limit·deadline·retry budget·diagnostics 이름.
`retrySemantics`는 canonical의 안전성 구분을 그대로 반영한다. 조회는 `SAFE`, mutating operation은 `KEYED`이며 `commandRecovery.mode = "IDEMPOTENCY_REPLAY"`, retry budget은 0이다 — 발신된 KEYED 명령의 자동 재시도는 금지된다.
`StudioGateway` 구현체는 `contractOperations` executor 위에 얹고, 포트 시그니처는 변경하지 않는다. UI는 어댑터가 mock인지 HTTP인지 알지 못한다.
#### 세션과 CSRF
CSRF는 전송 관심사이며 UI 관심사가 아니다. 현재 Studio에는 인증 UI가 없고(이식 시 유보), 이 사이클에서도 추가하지 않는다.
따라서 `getStudioSession`을 포트로 노출하지 않는다. HTTP 어댑터 내부가 첫 mutating 요청 전에 세션을 조회해 CSRF 토큰을 캐시하고, `csrfHeaderName`으로 헤더를 붙인다. `401`/`403`은 기존 오류 경로로 흘려보낸다. `displayName`/`roles`는 이 사이클에서 소비하지 않는다.
이 결정으로 완료 조건 "현재 Studio 작업 흐름이 변경되지 않는다"가 유지된다.
#### 런타임 스위치
`config/runtime/*.json`에 스위치를 추가한다.
```text
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP"
local, development → MOCK (기본값)
staging, production → HTTP
```
`createTechLogFeatureInstalledInput`이 이 값으로 gateway factory를 고른다. mock은 삭제하지 않고 test fixture 겸 fallback으로 유지한다. Backend 완성 시 `local.json` 한 줄로 대조를 시작한다.
기본값을 `MOCK`으로 두는 이유는 현재 앱과 이식 parity 테스트가 그대로 통과해야 하기 때문이다.
### C. Asset — 포트 분리와 업로드 전송 경계
`StudioAssetGateway``StudioGateway`와 별도 포트로 둔다. 파일 전송과 JSON orchestration의 실패 모델이 다르고, 향후 presigned/resumable 교체가 이 포트 뒤에서 끝나야 한다.
```text
StudioAssetGateway
listAssets(query, options) GET /api/v1/studio/assets
uploadAsset(form, options) POST /api/v1/studio/assets (multipart)
getAsset(assetId, options) GET /api/v1/studio/assets/{assetId}
updateAssetMetadata(assetId, cmd, o) PUT /api/v1/studio/assets/{assetId}
deleteAsset(assetId, options) DELETE /api/v1/studio/assets/{assetId}
```
#### 업로드 전송
플랫폼 계약 런타임이 multipart를 표현할 수 없으므로, 업로드 한 operation만 전용 전송 seam으로 분리한다.
```text
StudioAssetUploadTransport (좁은 인터페이스: form + 헤더 → 결과)
└ fetch + FormData 구현체
런타임 config의 API_BASE_URL·타임아웃 재사용
CSRF·Idempotency-Key 헤더는 B와 동일 경로로 획득
오류는 B와 동일한 코드 매핑 테이블 사용
```
나머지 4개 JSON operation은 A/B와 같은 계약 런타임을 통과한다. 즉 계약 런타임을 우회하는 것은 **19개 중 1개**다.
이 경계를 명시적 seam으로 두는 이유는 §7.4의 교체 가능성 요구를 만족시키기 위해서다. 플랫폼에 `MULTIPART` 모드가 생기거나 presigned로 옮길 때 구현체 한 파일만 바뀐다. 플랫폼 파일(`external-contract-runtime.ts`, `client.ts`)은 template 동기화 대상이므로 이 사이클에서 수정하지 않는다.
결정 사유와 우회 범위는 `docs/reviews/adapters/`에 기록한다.
#### UI 접근점
Studio primary navigation을 Asset 중심 CMS로 되돌리지 않는다. 두 접근점을 둔다.
1. **Editor contextual Asset Picker** — 작성 흐름의 기본 진입점. 선택 시 evidence directive를 삽입한다.
2. **`/studio/assets` Asset Library** — 검색·메타데이터·사용처·정리용 보조 화면. navigation에는 secondary utility link로만 노출한다.
`/studio/assets``tech-log-route-contract.ts``layoutGroup: "STUDIO"`로 추가한다(현재 27개 → 28개). 라우트 registry 거버넌스 기준선을 함께 갱신한다.
#### Evidence Figure와 Asset 연결
현재 content format directive를 유지한다.
```text
:::evidence key="asset-key" alt="설명" caption="캡션" zoom="true"
```
변경점:
- 정적 `evidenceAssets` 레지스트리(`adapters/static/evidence-assets.ts`) 대신 Backend Asset의 `assetKey`를 사용한다. `assetKey`는 canonical에서 immutable이며 공개 이력 이후 재사용이 금지된 안정 key다.
- Asset Picker 선택 시 directive를 자동 삽입한다. 사용자가 raw object-storage URL을 Markdown에 직접 넣지 않게 한다.
- 렌더러는 API가 제공한 Asset descriptor를 resolver로 주입받는다. 렌더러 경계는 변경하지 않는다.
- `QUARANTINED` Asset은 Public/Preview에 렌더링하지 않는다.
- 사용자 업로드 SVG 원문을 `innerHTML`로 주입하지 않는다. 검증된 `publicPath``<img src>`로 렌더링한다.
정적 `evidenceAssets`는 기존 하드코딩 Case 화면의 parity 유지를 위해 fixture로 남기고, Asset 기반 경로와 공존시킨다.
#### alt / decorative 규칙 정정
현재 parser는 빈 alt를 syntax error로 차단한다.
```text
parse-case-content.ts:505
if (!attributes.alt) invalid(node, "evidence alt text is required");
```
Asset capability와 연결하면 이 위치에서는 판단할 수 없다 — 필요 여부가 Asset의 `decorative`에 달려 있다. 규칙을 옮긴다.
```text
parser 빈 alt를 문법 오류로 차단하지 않는다 (구조만 검증)
publish 검증 Asset decorative=false + 사용 위치 alt 비어 있음 → ERROR
Asset decorative=true → alt="" 허용
```
이는 content format의 **의미 변경**이므로 parser/serializer round-trip 픽스처와 기존 검증 테스트를 함께 갱신한다.
## 대상 아키텍처
```text
src/features/tech-log/
├── contracts/
│ ├── studio/
│ │ ├── studio-api.openapi.yaml canonical vendor 사본 (생성물, 수동 편집 금지)
│ │ ├── generated.ts 생성물
│ │ ├── contract.ts 타입 alias (Asset 계열 추가)
│ │ └── canonical-source.json digest·revision 기록
│ └── tech-log-studio-contract-contribution.ts JSON operation 18개 + EXTERNAL_PACKAGE provenance
├── application/ports/
│ ├── studio-gateway.ts 변경 없음
│ └── studio-asset-gateway.ts 신규
├── adapters/
│ ├── http/
│ │ ├── http-studio-gateway.ts contractOperations 위 구현
│ │ ├── http-studio-asset-gateway.ts JSON 4 + 업로드 위임
│ │ ├── asset-upload-transport.ts multipart seam
│ │ ├── studio-session-csrf.ts CSRF 토큰 획득·캐시
│ │ └── studio-error-mapping.ts 23 코드 → 플랫폼 FailureKind
│ ├── mock/ 유지 (fixture·fallback)
│ └── static/ 유지 (Public, 이 사이클 범위 외)
└── presentation/studio/
├── pages/assets-page.tsx 신규
└── components/
├── asset-library.tsx 신규
├── asset-picker.tsx 신규
└── asset-upload-dialog.tsx 신규
```
기존 유지 대상: `presentation/public/**`, `presentation/studio/pages/**`, `document-*`, `validation-*`, `public-preview-screen`, `publish-screen`, `publication-*`, `presentation/shared/public-render/**`.
## 오류·동시성 계약
23개 canonical 코드를 전부 처리한다. 매핑은 `studio-error-mapping.ts` 한 곳에 둔다.
```text
AUTHENTICATION_REQUIRED STUDIO_ACCESS_DENIED
DOCUMENT_NOT_FOUND VERSION_CONFLICT
REQUEST_VALIDATION_FAILED VALIDATION_FAILED
VALIDATION_STALE PREVIEW_NOT_FOUND
PREVIEW_STALE PREVIEW_EXPIRED
PUBLICATION_NOT_FOUND PUBLICATION_CONFLICT
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
IDEMPOTENCY_KEY_REUSED WARNING_ACKNOWLEDGEMENT_REQUIRED
ASSET_NOT_FOUND ASSET_NOT_READY
ASSET_IN_USE ASSET_QUARANTINED
PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
STUDIO_UNAVAILABLE
```
규칙:
- 모든 mutation은 `Idempotency-Key`를 보낸다. 명령 하나당 새 key를 만들고, 그 명령의 안전한 재시도에만 같은 key를 재사용한다.
- `VERSION_CONFLICT`(낙관적 잠금 실패)와 `IDEMPOTENCY_KEY_REUSED`(같은 key·다른 요청)를 혼동하지 않는다. 전자는 사용자 데이터 손실 없는 충돌 화면으로, 후자는 클라이언트 결함으로 다룬다.
- `Idempotency-Replayed` 응답 헤더를 replay 판별에 사용한다.
- workflow 상태(`publicationStatus`, `hasUnpublishedChanges`, `nextAction`)는 서버가 계산한 값을 그대로 신뢰한다. 프론트에서 재계산하지 않는다.
- 업로드 상태는 `선택 실패 / 업로드 중 / 전송 실패 / READY / REJECTED / QUARANTINED / 크기 초과 / 미지원 형식`을 구분한다. 업로드 전송 성공과 서버 검증 성공을 분리한다.
- Asset은 `READY`일 때만 Preview/Publish에 사용한다.
## 테스트 전략
TDD로 진행한다. 각 단위는 red → green → 게이트 순서를 지킨다.
**계약(A)**
- `check:tech-log-contract`가 vendor 사본·`generated.ts`·digest 불일치를 잡는다 (의도적 변조로 red 확인).
- canonical 19개 operationId가 전부 덮이는지 parity 검증: 18개는 계약 기여에, `uploadStudioAsset`은 업로드 전송 seam에 존재해야 한다. 어느 쪽에도 없는 operationId가 있으면 실패한다.
- `EXTERNAL_PACKAGE` identity가 `assertPackageIdentity`를 통과하고 `contractSet`에 나타나는지 확인.
**전송(B)** — MSW로 canonical 응답·오류를 재현
- 불완전 draft 저장 성공, `expectedVersion` 충돌 → `VERSION_CONFLICT`.
- idempotency replay(`Idempotency-Replayed: true`)와 `IDEMPOTENCY_KEY_REUSED` 구분.
- workflow 전이: `INVALID → FIX_VALIDATION`, `VALID/WARNINGS → CREATE_PREVIEW`, `CURRENT → PUBLISH`, 게시 후 `NONE`, 편집 후 `VALIDATE` 복귀, expired preview 재생성.
- Publication: 최초 `PUBLISHED`, 재게시 `REPUBLISHED`, 취소 `UNPUBLISHED`, 과거 Snapshot 불변성.
- 23개 코드 전부의 UI 관측 가능한 처리.
- CSRF 토큰 획득 실패·만료 경로.
- 런타임 스위치: `MOCK`/`HTTP` 각각에서 gateway 종류가 선택되는지.
**Asset(C)**
- PNG/JPEG/WebP/SVG 업로드 성공, 미지원 형식 `UNSUPPORTED_MEDIA_TYPE`, 크기 초과 `PAYLOAD_TOO_LARGE`.
- `decorative=false` + 빈 alt → publish ERROR / `decorative=true` + `alt=""` 허용.
- `QUARANTINED` Asset의 Public·Preview 렌더링 차단.
- 사용 중 Asset hard delete 차단 → `ASSET_IN_USE`, `ARCHIVED` 전환 경로.
- Asset Picker가 evidence directive를 정확한 문법으로 삽입.
- parser가 빈 alt를 더 이상 syntax error로 차단하지 않음 + round-trip 픽스처 갱신.
**렌더러 불변**
동일 픽스처에 대해 `Instant Preview`, `Server Public Preview`, `Published Public`, `Publication Snapshot`의 semantic output이 동일해야 한다. Asset resolver도 같은 렌더러 경계로 주입한다.
**회귀**
기존 이식 parity 스위트(시각·접근성·라우트·아키텍처 경계)가 전부 통과해야 한다. 기본 스위치가 `MOCK`이므로 이 스위트는 영향받지 않아야 한다.
## 위험과 완화
| 위험 | 완화 |
|---|---|
| 실행 중 Backend 없음 → 실응답 미검증 | canonical 계약 기준 MSW 검증. 스위치로 대조 지점을 남긴다. 완료 조건에 "실서버 대조 미포함"을 명시한다. |
| canonical yaml이 계속 변경 중 (오늘도 수정됨) | digest·revision을 커밋에 고정하고 drift 게이트로 감지. canonical 갱신은 의도적 재생성 커밋으로만 반영. |
| 계약 런타임 우회(업로드 1개)가 거버넌스 위반으로 보일 수 있음 | 좁은 seam으로 격리, 사유·범위를 adapter review 문서에 기록, 나머지 18개는 런타임 통과. |
| content format 의미 변경(alt)이 기존 픽스처를 깨뜨림 | parser 변경과 픽스처·검증 갱신을 한 단위로 묶어 red→green으로 수행. |
| 라우트 1개 추가가 registry 기준선을 깨뜨림 | 거버넌스 기준선 갱신을 같은 단위에 포함. |
| `openapi-typescript` 도입이 생성물 diff를 크게 만듦 | 첫 생성 결과를 별도 커밋으로 분리해 리뷰 가능하게 한다. |
## 완료 조건
1. 현재 Public UI·라우트가 변경되지 않는다.
2. 현재 Studio 작업 흐름이 변경되지 않는다.
3. Studio 계약이 canonical `studio-v1.yaml`에서 생성되고, digest 고정과 drift 게이트가 동작한다.
4. `StudioGateway`의 모든 operation이 HTTP 어댑터로 구현되고 MSW 계약 테스트로 검증된다.
5. WorkingCopy 저장이 Public Projection을 변경하지 않는다.
6. Validation/Preview/Publish가 version과 dependency revision으로 묶인다.
7. Publication Event와 Snapshot을 현재 UI에서 조회할 수 있고 과거 Snapshot이 불변이다.
8. Image/SVG를 업로드하고 Case content에 Asset 기반 evidence로 삽입할 수 있다.
9. Asset은 `READY`일 때만 Preview/Publish에 사용된다. `QUARANTINED`는 렌더링되지 않는다.
10. 23개 오류 코드와 idempotency/version 충돌 구분이 처리된다.
11. 프론트가 Backend 도메인 Aggregate를 복제하지 않는다.
12. 런타임 스위치로 `MOCK`/`HTTP`를 전환할 수 있고, 기본 `MOCK`에서 기존 parity 스위트가 전부 통과한다.
명시적 비완료 항목:
- 실행 중 Backend와의 실응답 대조. Backend Studio 구현 완료 후 별도로 수행한다.
- Public 조회의 HTTP 전환. `public-v1.yaml` 기준 별도 spec/plan 사이클로 수행한다.
@@ -0,0 +1,54 @@
# TechLog 본문·콘텐츠 자료 너비 정렬 설계
## 상태
- 승인일: 2026-08-17
- 우선순위: Decision 작성 기능보다 먼저 적용
- 결정: 코드 블록, 데이터 표, SVG·이미지 evidence를 본문과 같은 `42rem` 너비에 맞춘다.
## 문제
Public 문서 본문은 `--body-copy: 42rem`이지만 코드 블록과 데이터 표는 최대 `58rem`, evidence figure는 최대 `61rem`으로 가운데 돌출된다. 이 때문에 본문 문장과 자료의 좌우 경계가 달라지고 문서를 읽을 때 시선축이 흔들린다.
## 선택한 접근
본문의 읽기 너비는 유지하고 자료 쪽을 본문 너비에 맞춘다.
- `.code-block`, `.data-table-wrap`, `.evidence-figure`의 기본 너비를 `min(var(--body-copy), 100%)`로 통일한다.
- 세 요소의 좌우 중앙 정렬은 일반 `margin-inline: auto`로 표현하고, 폭을 넓히기 위한 `50%` 이동과 `translateX`를 제거한다.
- 긴 코드는 기존처럼 `pre` 내부에서 가로 스크롤한다.
- 넓은 표는 기존처럼 wrapper 내부에서 가로 스크롤한다.
- SVG·이미지는 비율을 유지해 컨테이너 너비에 맞추고 기존 확대 dialog를 유지한다.
- 작은 화면에서는 세 자료가 계속 `width: 100%`를 사용한다.
본문 자체를 `58rem` 이상으로 넓히는 접근은 긴 문장의 가독성을 바꾸므로 사용하지 않는다. 코드·이미지만 계속 돌출시키는 접근도 이번 문제를 유지하므로 사용하지 않는다.
## 범위
다음 표면에 동일하게 적용한다.
- Case 공개 문서
- Studio 즉시 미리보기
- 저장·검증된 Public preview
- 게시 snapshot preview
- 공유 Public renderer를 사용하는 모든 코드, 표, evidence figure
다음은 이번 변경에 포함하지 않는다.
- 본문 글꼴, 행간, `--body-copy` 값 변경
- 이미지 업로드와 evidence catalog 자동 등록
- TOC 위치와 문서 전체 shell 너비 변경
- Decision 작성 기능
## 반응형·접근성
- 데스크톱에서 본문과 자료의 좌우 경계가 같아야 한다.
- 모바일에서는 viewport를 넘지 않아야 한다.
- 코드와 표의 가로 스크롤 가능성을 유지한다.
- evidence 확대 버튼과 keyboard focus 동작을 유지한다.
## 검증
먼저 style contract에 본문, 코드, 표, evidence figure의 계산된 너비 규칙이 모두 `min(var(--body-copy), 100%)`인지 확인하는 실패 테스트를 추가한다. 그 후 최소 CSS 변경으로 통과시킨다.
공유 renderer 회귀 테스트로 코드의 `pre` overflow, 표 wrapper overflow, evidence 확대 control이 그대로 존재하는지 확인한다. 마지막으로 Case 문서를 데스크톱과 모바일에서 확인해 자료가 본문 경계와 정렬되고 viewport overflow가 없는지 검증한다.
+208
View File
@@ -382,6 +382,194 @@ const browserDataBoundaryPlugin = {
}, },
}; };
/**
* A DOM element rendered by React carries `__reactFiber$*` / `__reactProps$*`
* as *own enumerable* properties. `node:assert` builds its `AssertionError`
* eagerly, running `util.inspect` over both operands with `depth: 1000`,
* `getters: true` and `maxArrayLength: Infinity`; the fiber graph re-expands
* once per traversal path, so inspecting one rendered element allocates
* gigabytes and the worker dies before any `AssertionError` is ever thrown.
* A genuine regression then reports as an OOM or an opaque timeout instead of
* a failed assertion. Measured on this repo's Asset Library heading:
* depth 6 = 1.3MB, depth 8 = 7.8MB, depth 10 = 36MB, depth 12 = 135MB.
*
* Equality assertions only inspect their operands on failure, so an unsafe
* comparison stays invisible while green and detonates the day the behaviour
* it guards regresses -- which is exactly when the diagnosis is needed.
*
* `expect` is not affected: vitest prints and diffs through pretty-format's
* DOM plugin, which reads tag/attributes/children and never touches the fiber.
* So the safe form is always an `expect` matcher -- `toHaveFocus()`,
* `not.toBeInTheDocument()`, `toBe(element)` -- and this rule only forbids
* handing a DOM element to `node:assert`.
*/
const TESTING_LIBRARY_QUERY =
/^(get|query|find)(All)?By(Role|Text|LabelText|PlaceholderText|AltText|Title|DisplayValue|TestId)$/u;
const domQueryMethods = new Set([
"querySelector",
"querySelectorAll",
"getElementById",
"closest",
]);
const domElementProperties = new Set([
"activeElement",
"parentElement",
"firstElementChild",
"lastElementChild",
"nextElementSibling",
"previousElementSibling",
"offsetParent",
]);
// Fail when the operands *differ*, so on failure at least one element is
// still there to be inspected.
const positiveAssertEqualities = new Set([
"equal",
"strictEqual",
"deepEqual",
"deepStrictEqual",
]);
// Fail when the operands *match*. `assert.notEqual(element, null)` can only
// fail with `null` on both sides, so a nullish literal operand makes these
// safe; anything else leaves an element to inspect.
const negativeAssertEqualities = new Set([
"notEqual",
"notStrictEqual",
"notDeepEqual",
"notDeepStrictEqual",
]);
const noElementOperandEqualityRule: Rule.RuleModule = {
meta: {
type: "problem",
schema: [],
messages: {
unbounded:
"node:assert inspects both operands at depth 1000 to build its failure message, and a React-rendered element's __reactFiber$* graph exhausts the worker heap there, so the regression reports as an OOM instead of an assertion. Use an expect matcher instead -- expect(el).toHaveFocus(), expect(el).not.toBeInTheDocument(), expect(actual).toBe(expected) -- which prints DOM nodes through pretty-format's DOM plugin.",
},
},
create(context) {
const sourceCode = context.sourceCode;
const unwrap = (input: any): any => {
let node = input;
while (
node &&
[
"AwaitExpression",
"ChainExpression",
"TSAsExpression",
"TSNonNullExpression",
"TSSatisfiesExpression",
"TSTypeAssertion",
].includes(node.type)
) {
node = node.type === "AwaitExpression" ? node.argument : node.expression;
}
return node;
};
const memberName = (node: any): string | null => {
if (!node.computed && node.property?.type === "Identifier") {
return node.property.name;
}
if (
node.computed &&
(node.property?.type === "Literal" ||
node.property?.type === "StringLiteral") &&
typeof node.property.value === "string"
) {
return node.property.value;
}
return null;
};
const resolveInit = (node: any): any => {
const scope = sourceCode.getScope(node);
let current: any = scope;
while (current) {
const variable = current.variables.find(
(entry: any) => entry.name === node.name,
);
if (variable) {
const definition = variable.defs.at(-1);
return definition?.node?.type === "VariableDeclarator"
? definition.node.init
: null;
}
current = current.upper;
}
return null;
};
const isElementValued = (input: any, seen = new Set<any>()): boolean => {
const node = unwrap(input);
if (!node || seen.has(node)) return false;
seen.add(node);
if (node.type === "MemberExpression") {
const name = memberName(node);
return name !== null && domElementProperties.has(name);
}
if (node.type === "CallExpression") {
const callee = unwrap(node.callee);
if (callee?.type !== "MemberExpression") return false;
const name = memberName(callee);
return (
name !== null &&
(TESTING_LIBRARY_QUERY.test(name) || domQueryMethods.has(name))
);
}
if (node.type === "Identifier") {
return isElementValued(resolveInit(node), seen);
}
if (node.type === "ConditionalExpression") {
return (
isElementValued(node.consequent, seen) ||
isElementValued(node.alternate, seen)
);
}
return false;
};
const isNullish = (input: any): boolean => {
const node = unwrap(input);
if (!node) return false;
return (
(node.type === "Literal" && node.value === null) ||
(node.type === "Identifier" && node.name === "undefined")
);
};
return {
CallExpression(node: any) {
const callee = unwrap(node.callee);
if (callee?.type !== "MemberExpression") return;
const object = unwrap(callee.object);
if (object?.type !== "Identifier" || object.name !== "assert") return;
const name = memberName(callee);
if (name === null) return;
const positive = positiveAssertEqualities.has(name);
if (!positive && !negativeAssertEqualities.has(name)) return;
const operands = (node.arguments ?? []).slice(0, 2);
if (!positive && operands.some((argument: any) => isNullish(argument))) {
return;
}
const operand = operands.find((argument: any) =>
isElementValued(argument),
);
if (operand) context.report({ node: operand, messageId: "unbounded" });
},
};
},
};
const testAssertionBoundaryPlugin = {
rules: {
"no-element-operand-equality": noElementOperandEqualityRule,
},
};
const commonLanguageOptions = { const commonLanguageOptions = {
ecmaVersion: "latest", ecmaVersion: "latest",
sourceType: "module", sourceType: "module",
@@ -406,6 +594,20 @@ const commonSecurityRules = {
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']", "CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.", message: "Runtime script construction is prohibited by FE-OC-019.",
}, },
{
/*
setState , . React
`event.currentTarget` null
"Cannot read properties of null" .
lint ,
.
*/
selector:
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
message:
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
},
], ],
}; };
@@ -811,6 +1013,12 @@ export default [
...globals.node, ...globals.node,
}, },
}, },
plugins: {
"test-assertion-boundary": testAssertionBoundaryPlugin,
},
rules: {
"test-assertion-boundary/no-element-operand-equality": "error",
},
}, },
{ {
files: [`tests/support/browser/**/*.${sourceExtensions}`], files: [`tests/support/browser/**/*.${sourceExtensions}`],
+5
View File
@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Tech Log frontend" /> <meta name="description" content="Tech Log frontend" />
<title>Tech Log</title> <title>Tech Log</title>
<!-- public/favicon.svg 는 빌드가 dist 루트로 복사하고 nginx 도 서빙하지만,
참조가 없어 브라우저는 /favicon.ico 를 찾다가 404 를 받고 기본 아이콘을
띄우고 있었다. %BASE_URL% 은 Vite 가 base 로 치환한다 — 경로 프리픽스
배포(/dev/)에서도 같은 파일을 가리키게 하려면 절대경로여선 안 된다. -->
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+17 -3
View File
@@ -11,8 +11,10 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "node scripts/build-frontend.ts", "build": "node scripts/build-frontend.ts",
"build:profile": "node scripts/generate-runtime-config.ts",
"build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts", "build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts",
"preview": "vite preview", "preview": "node dist/server.mjs",
"verify:tech-log-source-parity": "node tests/support/browser/verify-tech-log-source-parity.ts",
"lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0", "lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0",
"check:architecture": "node scripts/check-architecture.ts", "check:architecture": "node scripts/check-architecture.ts",
"check:design-system": "node scripts/check-design-system.ts", "check:design-system": "node scripts/check-design-system.ts",
@@ -21,6 +23,7 @@
"check:i18n:fixture": "node scripts/check-i18n.ts --fixture", "check:i18n:fixture": "node scripts/check-i18n.ts --fixture",
"check:adapter-inventory": "node scripts/check-adapter-inventory.ts", "check:adapter-inventory": "node scripts/check-adapter-inventory.ts",
"check:remediation-ledger": "node scripts/check-remediation-ledger.ts", "check:remediation-ledger": "node scripts/check-remediation-ledger.ts",
"check:release-admission": "node scripts/check-release-admission.ts",
"check:diagnostics": "node scripts/check-diagnostics.ts", "check:diagnostics": "node scripts/check-diagnostics.ts",
"check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture", "check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture",
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker",
@@ -73,14 +76,19 @@
"test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts", "test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts",
"test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts", "test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts",
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests", "test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:tech-log": "vitest run tests/features/tech-log --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/tech-log.xml",
"check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts", "check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts",
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts", "test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature tests/features/tech-log --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
"check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json", "check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes", "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm check:tech-log-contract && corepack pnpm test:tech-log && corepack pnpm test:recipes",
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts", "verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts", "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts", "generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
"generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts",
"check:tech-log-contract": "node scripts/generate-tech-log-contract.ts --check",
"generate:dev-release-manifest": "node scripts/check-dev-release-manifest.ts --write",
"check:dev-release-manifest": "node scripts/check-dev-release-manifest.ts",
"generate:supply-chain": "node scripts/generate-supply-chain.ts", "generate:supply-chain": "node scripts/generate-supply-chain.ts",
"verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts", "verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts",
"verify:promotion": "node scripts/verify-exact-promotion-bundle.ts", "verify:promotion": "node scripts/verify-exact-promotion-bundle.ts",
@@ -123,11 +131,17 @@
"check:types:service-worker": "tsc --project tsconfig.service-worker.json" "check:types:service-worker": "tsc --project tsconfig.service-worker.json"
}, },
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"lucide-react": "1.25.0", "lucide-react": "1.25.0",
"pretendard": "1.3.9",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-router-dom": "7.18.1", "react-router-dom": "7.18.1",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"remark-parse": "11.0.0",
"unified": "11.0.5",
"zod": "4.4.3" "zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
+7 -7
View File
@@ -9,35 +9,35 @@ export default defineConfig({
["junit", { outputFile: "./artifacts/tests/e2e/results.xml" }], ["junit", { outputFile: "./artifacts/tests/e2e/results.xml" }],
], ],
use: { use: {
baseURL: "http://127.0.0.1:4173", baseURL: "http://127.0.0.1:4273",
colorScheme: "light",
locale: "ko-KR",
timezoneId: "Asia/Seoul",
trace: "retain-on-failure", trace: "retain-on-failure",
screenshot: "only-on-failure", screenshot: "only-on-failure",
}, },
webServer: { webServer: {
command: command:
"corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4173", "corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4273",
url: "http://127.0.0.1:4173", url: "http://127.0.0.1:4273",
reuseExistingServer: false, reuseExistingServer: false,
}, },
projects: [ projects: [
{ {
name: "chromium", name: "chromium",
testIgnore: "**/compact-smoke.spec.ts",
use: { ...devices["Desktop Chrome"] }, use: { ...devices["Desktop Chrome"] },
}, },
{ {
name: "firefox", name: "firefox",
testIgnore: "**/compact-smoke.spec.ts",
use: { ...devices["Desktop Firefox"] }, use: { ...devices["Desktop Firefox"] },
}, },
{ {
name: "webkit", name: "webkit",
testIgnore: "**/compact-smoke.spec.ts",
use: { ...devices["Desktop Safari"] }, use: { ...devices["Desktop Safari"] },
}, },
{ {
name: "chromium-compact", name: "chromium-compact",
testMatch: "**/compact-smoke.spec.ts", testMatch: "**/tech-log-responsive.spec.ts",
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
viewport: { width: 390, height: 844 }, viewport: { width: 390, height: 844 },
+11 -4
View File
@@ -16,15 +16,17 @@ export default defineConfig({
toHaveScreenshot: { toHaveScreenshot: {
animations: "disabled", animations: "disabled",
caret: "hide", caret: "hide",
maxDiffPixelRatio: 0.002, maxDiffPixels: 0,
scale: "css", maxDiffPixelRatio: 0,
scale: "device",
}, },
}, },
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
baseURL: "http://127.0.0.1:4174", baseURL: "http://127.0.0.1:4174",
colorScheme: "light", colorScheme: "light",
locale: "en-US", locale: "ko-KR",
timezoneId: "Asia/Seoul",
trace: "retain-on-failure", trace: "retain-on-failure",
}, },
webServer: { webServer: {
@@ -33,5 +35,10 @@ export default defineConfig({
url: "http://127.0.0.1:4174", url: "http://127.0.0.1:4174",
reuseExistingServer: false, reuseExistingServer: false,
}, },
projects: [{ name: "chromium-visual" }], projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"], deviceScaleFactor: 1 },
},
],
}); });
+729
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,5 +14,8 @@
"WEB_WORKER": "DEFAULT", "WEB_WORKER": "DEFAULT",
"SERVICE_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT" "OFFLINE_COMMANDS": "DEFAULT"
},
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

+57
View File
@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="420" viewBox="0 0 1080 420">
<title>Fetch Join과 Batch Fetch의 페이징 경계</title>
<desc>Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고른다. Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.</desc>
<rect width="1080" height="420" rx="18" fill="#FCFCFB"/>
<style>
.label { font: 600 18px Pretendard, sans-serif; fill: #17181B; }
.text { font: 500 15px Pretendard, sans-serif; fill: #3F4249; }
.muted { font: 500 13px Pretendard, sans-serif; fill: #686B72; }
.box { fill: #F7F7F5; stroke: #D9DBDE; stroke-width: 1.5; }
.bad { fill: #FFF4E8; stroke: #D99A4E; stroke-width: 1.5; }
.good { fill: #ECF6F2; stroke: #4F927F; stroke-width: 1.5; }
.arrow { stroke: #A5A8AE; stroke-width: 2; fill: none; marker-end: url(#arrow); }
</style>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 Z" fill="#A5A8AE"/>
</marker>
</defs>
<text class="label" x="40" y="54">Collection Fetch Join</text>
<rect class="box" x="40" y="80" width="190" height="76" rx="10"/>
<text class="text" x="66" y="112">목록 + 컬렉션 JOIN</text>
<text class="muted" x="66" y="136">부모 × 자식 행</text>
<path class="arrow" d="M230 118 H286"/>
<rect class="bad" x="286" y="80" width="230" height="76" rx="10"/>
<text class="text" x="312" y="112">전체 Join 결과 로드</text>
<text class="muted" x="312" y="136">DB LIMIT 없음 · 1,961행</text>
<path class="arrow" d="M516 118 H572"/>
<rect class="box" x="572" y="80" width="220" height="76" rx="10"/>
<text class="text" x="598" y="112">부모 엔티티 복원</text>
<text class="muted" x="598" y="136">중복 부모 정리</text>
<path class="arrow" d="M792 118 H848"/>
<rect class="bad" x="848" y="80" width="192" height="76" rx="10"/>
<text class="text" x="874" y="112">메모리에서 20개</text>
<text class="muted" x="874" y="136">페이지 경계가 뒤에 있음</text>
<line x1="40" y1="210" x2="1040" y2="210" stroke="#E2E3E1"/>
<text class="label" x="40" y="258">Parent Paging + Batch Fetch</text>
<rect class="good" x="40" y="284" width="230" height="76" rx="10"/>
<text class="text" x="66" y="316">부모 목록 LIMIT 20</text>
<text class="muted" x="66" y="340">정렬과 페이지 경계 확정</text>
<path class="arrow" d="M270 322 H342"/>
<rect class="box" x="342" y="284" width="210" height="76" rx="10"/>
<text class="text" x="368" y="316">부모 ID 20개</text>
<text class="muted" x="368" y="340">현재 페이지 집합</text>
<path class="arrow" d="M552 322 H624"/>
<rect class="good" x="624" y="284" width="220" height="76" rx="10"/>
<text class="text" x="650" y="316">컬렉션 IN 조회</text>
<text class="muted" x="650" y="340">현재 부모만 로드</text>
<path class="arrow" d="M844 322 H900"/>
<rect class="box" x="900" y="284" width="140" height="76" rx="10"/>
<text class="text" x="926" y="316">화면 조립</text>
<text class="muted" x="926" y="340">비용 상한 설명 가능</text>
<text class="muted" x="40" y="396">관찰: 페이징이 적용되는 지점이 부모 조회 앞으로 이동한다.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+51 -12
View File
@@ -8,20 +8,59 @@
"releaseId": "local-release", "releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z", "builtAt": "1970-01-01T00:00:00.000Z",
"routeChunks": { "routeChunks": {
"route-home": "src/presentation/pages/home-page.tsx", "route-tech-log-home": "src/features/tech-log/presentation/public/pages/home-page.tsx",
"route-examples-platform": "src/presentation/examples/platform-overview-page.tsx", "route-tech-log-explore": "src/features/tech-log/presentation/public/pages/explore-page.tsx",
"route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx", "route-tech-log-explore-kind": "src/features/tech-log/presentation/public/pages/explore-kind-page.tsx",
"route-examples-states": "src/presentation/examples/state-gallery-page.tsx", "route-tech-log-case": "src/features/tech-log/presentation/public/pages/case-page.tsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.tsx", "route-tech-log-reference": "src/features/tech-log/presentation/public/pages/reference-page.tsx",
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx", "route-tech-log-question": "src/features/tech-log/presentation/public/pages/question-page.tsx",
"route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx", "route-tech-log-topic": "src/features/tech-log/presentation/public/pages/topic-page.tsx",
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx", "route-tech-log-projects": "src/features/tech-log/presentation/public/pages/projects-page.tsx",
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx", "route-tech-log-project": "src/features/tech-log/presentation/public/pages/project-overview-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.tsx" "route-tech-log-project-records": "src/features/tech-log/presentation/public/pages/project-records-page.tsx",
"route-tech-log-project-decisions": "src/features/tech-log/presentation/public/pages/project-decisions-page.tsx",
"route-tech-log-project-activity": "src/features/tech-log/presentation/public/pages/project-activity-page.tsx",
"route-tech-log-releases": "src/features/tech-log/presentation/public/pages/releases-page.tsx",
"route-tech-log-release": "src/features/tech-log/presentation/public/pages/release-page.tsx",
"route-tech-log-profile": "src/features/tech-log/presentation/public/pages/profile-page.tsx",
"route-tech-log-search": "src/features/tech-log/presentation/public/pages/search-page.tsx",
"route-tech-log-studio-home": "src/features/tech-log/presentation/studio/pages/studio-home-page.tsx",
"route-tech-log-studio-documents": "src/features/tech-log/presentation/studio/pages/documents-page.tsx",
"route-tech-log-studio-document-new": "src/features/tech-log/presentation/studio/pages/new-document-page.tsx",
"route-tech-log-studio-document-edit": "src/features/tech-log/presentation/studio/pages/document-edit-page.tsx",
"route-tech-log-studio-document-validation": "src/features/tech-log/presentation/studio/pages/document-validation-page.tsx",
"route-tech-log-studio-document-preview": "src/features/tech-log/presentation/studio/pages/document-preview-page.tsx",
"route-tech-log-studio-document-publish": "src/features/tech-log/presentation/studio/pages/document-publish-page.tsx",
"route-tech-log-studio-publications": "src/features/tech-log/presentation/studio/pages/publications-page.tsx",
"route-tech-log-studio-publication-preview": "src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx",
"route-tech-log-studio-not-found": "src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx",
"route-not-found": "src/features/tech-log/presentation/public/pages/public-not-found-page.tsx"
}, },
"contractSet": { "contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1", "setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2", "setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
"packages": [] "packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"runtimeProtocolVersion": 1,
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "ef49d3a"
}
]
} }
} }
+21 -7
View File
@@ -13,12 +13,20 @@ import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtim
* 1. clean dist and .generated/frontend-runtime * 1. clean dist and .generated/frontend-runtime
* 2. generate contractSet and build-info source * 2. generate contractSet and build-info source
* 3. Vite app build (emptyOutDir = true) * 3. Vite app build (emptyOutDir = true)
* 4. scan app dist and generate the static asset source * 4. materialize dist/config.json from the declared APP_PROFILE
* 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false) * 5. scan app dist and generate the static asset source
* 6. generate Release Manifest V2 and the build manifest * 6. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
* 7. generate the self-contained TechLog production serving boundary
* 8. generate Release Manifest V2 and the build manifest
* *
* Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` * Steps 5 and 6 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
* and `null`: those modes never run an active worker build. * and `null`: those modes never run an active worker build.
*
* Step 4 has to follow the Vite build and precede the asset scan. Vite copies
* `public/` verbatim, so without it every build including a production one
* ships the local runtime document; and the Service Worker hashes the emitted
* `config.json`, so the profile must be in place before that inventory is
* taken.
*/ */
const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker; const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker;
@@ -45,10 +53,13 @@ run("node", ["scripts/generate-contract-set.ts"]);
// 3. app build // 3. app build
run("npx", ["vite", "build"]); run("npx", ["vite", "build"]);
// 4. runtime config for the declared profile
run("node", ["scripts/generate-runtime-config.ts"]);
if (buildsActiveWorker) { if (buildsActiveWorker) {
// 4. hashed asset inventory // 5. hashed asset inventory
run("node", ["scripts/generate-service-worker-assets.ts", "dist"]); run("node", ["scripts/generate-service-worker-assets.ts", "dist"]);
// 5. service worker build // 6. service worker build
run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]); run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]);
} else { } else {
process.stdout.write( process.stdout.write(
@@ -56,5 +67,8 @@ if (buildsActiveWorker) {
); );
} }
// 6. release + build manifest // 7. production serving boundary
run("node", ["scripts/generate-tech-log-serving-artifact.ts"]);
// 8. release + build manifest
run("node", ["scripts/generate-build-manifest.ts"]); run("node", ["scripts/generate-build-manifest.ts"]);
+43 -3
View File
@@ -723,9 +723,13 @@ function findArchitectureViolations(
continue; continue;
} }
for (const dependency of dependencies) { for (const dependency of dependencies) {
const sourceGroups = rule.from?.path
? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ??
[])
: [];
if ( if (
matchesPath(dependency.source, rule.from) && matchesPath(dependency.source, rule.from) &&
matchesPath(dependency.target, rule.to) matchesPath(dependency.target, rule.to, sourceGroups)
) { ) {
violations.push({ violations.push({
rule: rule.name, rule: rule.name,
@@ -746,16 +750,52 @@ function findArchitectureViolations(
function matchesPath( function matchesPath(
modulePath: string, modulePath: string,
criterion: PathRule | undefined, criterion: PathRule | undefined,
sourceGroups: readonly string[] = [],
): boolean { ): boolean {
if (!criterion) return true; if (!criterion) return true;
if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) { if (
criterion.path &&
!new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test(
modulePath,
)
) {
return false; return false;
} }
return !( return !(
criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath) criterion.pathNot &&
new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test(
modulePath,
)
); );
} }
/**
* Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from`
* pattern matched on the importing module.
*
* Without it, "an adapter may not import a *different* adapter" cannot be
* written as one rule: the target pattern has to name the importer's own
* directory to exempt it. The alternative is one rule per adapter group, which
* silently stops covering a group the moment somebody adds one exactly the
* gap that let `diagnostics` import `telemetry` while the documented rule said
* it could not.
*/
function expandSourceGroups(
pattern: string,
sourceGroups: readonly string[],
): string {
return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => {
const captured = sourceGroups[Number(index) - 1];
// A `from` pattern that did not capture leaves the token literal rather
// than quietly matching everything.
return captured === undefined ? whole : escapeRegExp(captured);
});
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
}
function validateArchitectureRules(rules: readonly ArchitectureRule[]): void { function validateArchitectureRules(rules: readonly ArchitectureRule[]): void {
if (!rules.some((rule) => rule.to?.circular === true)) { if (!rules.some((rule) => rule.to?.circular === true)) {
throw new Error("Architecture configuration must contain a circular rule"); throw new Error("Architecture configuration must contain a circular rule");
+2 -2
View File
@@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat
} }
const expectedGateIds = Array.from( const expectedGateIds = Array.from(
{ length: 26 }, { length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
); );
const passingResults: Record<string, GateResult> = Object.fromEntries( const passingResults: Record<string, GateResult> = Object.fromEntries(
@@ -139,4 +139,4 @@ if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1); process.exit(1);
} }
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n"); process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n");
+2 -1
View File
@@ -2,6 +2,7 @@ import { mkdir, readFile, readdir } from "node:fs/promises";
import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts"; import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { containsRawPaletteValue } from "./lib/design-system-source.ts";
import path from "node:path"; import path from "node:path";
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts"; import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
@@ -109,7 +110,7 @@ for (const file of sources) {
} }
if ( if (
!file.includes("src/presentation/design-system/tokens/") && !file.includes("src/presentation/design-system/tokens/") &&
/(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source) containsRawPaletteValue(source)
) { ) {
failures.push(`raw palette value in ${file}`); failures.push(`raw palette value in ${file}`);
} }
+42
View File
@@ -0,0 +1,42 @@
/**
* Gate: the hand-maintained dev fixture `public/release-manifest.json` must
* declare the same `contractSet` the build compiles.
*
* `corepack pnpm dev` serves that file verbatim, and `verifyContractSet` runs
* unconditionally at boot, so a stale fixture is a hard boot failure of
* `pnpm dev` in the default `MOCK` mode not an `HTTP`-mode caveat. Nothing
* else in the gate set reads `public/*.json`, which is how a completely broken
* `pnpm dev` shipped with every static check green.
*
* `--write` refreshes the block instead of failing; that is what
* `corepack pnpm generate:tech-log-contract` calls.
*/
import process from "node:process";
import {
DEV_RELEASE_MANIFEST_PATH,
checkDevReleaseManifestContractSet,
refreshDevReleaseManifestContractSet,
} from "./lib/dev-release-manifest.ts";
const write = process.argv.includes("--write");
if (write) {
const { changed, contractSet } = await refreshDevReleaseManifestContractSet();
process.stdout.write(
`${changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` +
`(${contractSet.packages.length} package(s), ${contractSet.setDigest})\n`,
);
} else {
const failures = await checkDevReleaseManifestContractSet();
if (failures.length > 0) {
process.stderr.write(
`dev release manifest drift:\n- ${failures.join("\n- ")}\n` +
`Run: corepack pnpm generate:dev-release-manifest\n`,
);
process.exit(1);
}
process.stdout.write(
`${DEV_RELEASE_MANIFEST_PATH} contractSet matches the compiled contract set.\n`,
);
}
+108
View File
@@ -0,0 +1,108 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import {
DEPLOYMENT_TARGETS,
findAdmissionViolations,
isDeploymentTarget,
type AdmissionInput,
} from "../src/contracts/deployment-admission.ts";
import { parseRuntimeConfigArtifact } from "../src/contracts/release-artifacts.ts";
/**
* §6.4 / FE-GATE-027. Refuses to admit an artifact to an environment it was not
* built for.
*
* Release coherence already proves the artifacts agree with each other. It
* cannot prove they belong in production, because a local build is coherent
* with itself: `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API pass
* every existing gate. This gate closes that by making the destination an
* explicit, declared input and refusing anything that does not match it.
*
* It fails closed in both directions. An undeclared destination is a refusal,
* not a default, so an artifact can never be admitted by omission; and every
* rule is stated as a reason to refuse, so an unreadable field cannot pass.
*/
const RUNTIME_CONFIG_PATH = "dist/config.json";
const RECORD_PATH = "artifacts/release/deployment-admission.json";
async function main(): Promise<void> {
const declared = process.env["RELEASE_TARGET"];
if (!isDeploymentTarget(declared)) {
process.stderr.write(
"release admission refused: RELEASE_TARGET must be declared as one of " +
`${DEPLOYMENT_TARGETS.join(", ")}; received ${
declared === undefined ? "nothing" : declared
}.\n` +
"An artifact is never admitted by default — name the environment it is for.\n",
);
process.exitCode = 1;
return;
}
let document: unknown;
try {
document = JSON.parse(await readFile(RUNTIME_CONFIG_PATH, "utf8"));
} catch (error) {
process.stderr.write(
`release admission refused: ${RUNTIME_CONFIG_PATH} is unreadable: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
return;
}
let config: AdmissionInput;
try {
config = parseRuntimeConfigArtifact(document) as AdmissionInput;
} catch (error) {
process.stderr.write(
`release admission refused: ${RUNTIME_CONFIG_PATH} is not a valid runtime config: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
return;
}
const violations = findAdmissionViolations(declared, config);
await mkdir("artifacts/release", { recursive: true });
await writeFile(
RECORD_PATH,
`${JSON.stringify(
{
schemaVersion: 1,
target: declared,
appEnv: config.APP_ENV,
authMode: config.AUTH_MODE,
apiBaseUrl: config.API_BASE_URL,
buildId: config.BUILD_ID ?? null,
releaseId: config.RELEASE_ID ?? null,
status: violations.length === 0 ? "ADMITTED" : "REFUSED",
violations,
},
null,
2,
)}\n`,
"utf8",
);
if (violations.length > 0) {
process.stderr.write(
`release admission refused for ${declared}:\n${violations
.map((violation) => ` ${violation.field}: ${violation.reason}`)
.join("\n")}\n`,
);
process.exitCode = 1;
return;
}
process.stdout.write(
`release admission: ${declared} ADMITTED ` +
`(APP_ENV=${config.APP_ENV}, AUTH_MODE=${config.AUTH_MODE}, ` +
`API=${config.API_BASE_URL}); record at ${RECORD_PATH}\n`,
);
}
await main();
+127 -13
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { z } from "zod"; import { z } from "zod";
import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts"; import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts";
import { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.ts";
import { import {
RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH, RELEASE_CANDIDATE_MANIFEST_PATH,
@@ -247,6 +248,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
"provider-provenance", "provider-provenance",
"provider-verification", "provider-verification",
"ci-contract-report", "ci-contract-report",
"deployment-admission",
]), ]),
}) })
.strict(), .strict(),
@@ -442,7 +444,36 @@ export type LoadCiGateContractOptions = Readonly<{
}>; }>;
const CANONICAL_GATE_SHAPE_SHA256 = const CANONICAL_GATE_SHAPE_SHA256 =
"a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4"; // Template merge. Both sides carried a digest of their own gate set; neither
// describes the merged one. Recomputed from the merged config/ci/gates.json.
// Task 11: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_ASSETS manual accessibility evidence artifact.
// Final fix wave item 1: recomputed again after FE-GATE-010 gained
// `check-tech-log-contract`. Recomputed with `canonicalGateShapeSha256`
// below, verified by first reproducing the previous constant from the
// previous `config/ci/gates.json` before hashing the new one.
// Alignment follow-up item 2: recomputed again after FE-GATE-007 gained
// `test-tech-log` and its junit evidence, by the same method — the previous
// constant f3cc9075… was reproduced from the previous gates.json first, so
// the transcription that produced this value is known to be the real one.
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
// reproduced from the previous gates.json before this value was hashed.
// Taxonomy route: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_TAXONOMY manual accessibility evidence artifact. The gate
// lists one evidence artifact per installed route and refuses a set that does
// not match the route scope exactly, so adding a route necessarily moves this
// digest — that is the point of pinning it.
// Release authoring: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
// was first reproduced from the previous gates.json, so the computation that
// produced this value is known to be the one the constant was pinned under.
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map( const normalized = gates.map(
@@ -473,16 +504,37 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
(total, gate) => total + gate.commandIds.length, (total, gate) => total + gate.commandIds.length,
0, 0,
); );
if (contract.gates.length !== 26) { if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`); failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
} }
if (contract.commands.length !== 81 || commandReferenceCount !== 93) { // Final fix wave, item 1: FE-GATE-010 gained `check-tech-log-contract`, the
// drift gate that pins the vendored canonical Studio contract to its digest.
// Until it was referenced by a gate it ran only when typed by hand.
// Alignment follow-up, item 2: FE-GATE-007 gained `test-tech-log`. The suite
// already ran inside `test:coverage`'s combined vitest invocation, so a
// TechLog failure was reported as a coverage-gate failure with no junit of
// its own to name it.
// Dev release manifest drift fix, item 2: FE-GATE-010 gained
// `check-dev-release-manifest`. No gate read `public/*.json` at all, so a
// fixture that did not declare the compiled contract set broke `pnpm dev`
// outright while every static gate stayed green.
if (contract.commands.length !== 85 || commandReferenceCount !== 97) {
failures.push( failures.push(
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, `command authority baseline must contain exactly 85 definitions and 97 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
); );
} }
if (contract.artifacts.length !== 105) { // Template merge. 126 product artifacts plus the two the template added.
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`); // Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
// Alignment follow-up, item 2 added the TechLog junit report.
// The taxonomy route added its own manual a11y evidence file — every installed
// route carries one, and the gate checks that the two sets match exactly.
// The project edit route did the same: it is what finally lets a project carry
// a purpose, a current objective, and a next step, so the public screens that
// read those fields stop rendering blanks.
// The release edit route followed: the editor used to open below the release
// list, so editing meant scrolling past every release to reach it.
if (contract.artifacts.length !== 134) {
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
} }
if (contract.stages.length !== 5) { if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -511,7 +563,7 @@ export function parseCiGateContract(
.join("\n"); .join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`); throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
} }
if ((options.mode ?? "canonical") === "canonical") { if ((options.mode ?? defaultCiContractMode()) === "canonical") {
const failures = canonicalAuthorityBaselineFailures(result.data); const failures = canonicalAuthorityBaselineFailures(result.data);
if (failures.length > 0) { if (failures.length > 0) {
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`); throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
@@ -520,11 +572,29 @@ export function parseCiGateContract(
return result.data; return result.data;
} }
/**
* A removal fixture runs the whole suite against a deliberately *reduced* CI
* contract: the removed capability's gates, commands and artifacts are pruned.
* Loading that contract in canonical mode re-imposes the full exact-count
* authority on it, so the fixture failed on the very reduction it exists to
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
* is honoured.
*/
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
return process.env.CI_CONTRACT_MODE === "removal-fixture"
? "removal-fixture"
: "canonical";
}
export function isReducedCiContractRun(): boolean {
return defaultCiContractMode() === "removal-fixture";
}
export async function loadCiGateContract( export async function loadCiGateContract(
root = process.cwd(), root = process.cwd(),
options: LoadCiGateContractOptions = {}, options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> { ): Promise<CiGateContract> {
const mode = options.mode ?? "canonical"; const mode = options.mode ?? defaultCiContractMode();
const [rawContract, rawPackage] = await Promise.all([ const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"), readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"), readFile(path.join(root, "package.json"), "utf8"),
@@ -723,6 +793,44 @@ function validateContractSemantics(
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`); issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
} }
} }
const accessibilityGate = contract.gates.find(
({ id: gateId }) => gateId === "FE-GATE-009",
);
const manualAccessibilityArtifacts = MANUAL_A11Y_ROUTE_IDS.map((routeId) => ({
id: `artifact-artifacts-tests-a11y-manual-${routeId.replaceAll("_", "-")}-md`,
path: `artifacts/tests/a11y-manual/${routeId}.md`,
}));
const expectedAccessibilityEvidenceArtifactIds = [
"artifact-artifacts-tests-a11y-json",
...manualAccessibilityArtifacts.map(({ id: artifactId }) => artifactId),
"artifact-artifacts-tests-a11y-manual-report-json",
];
if (
!accessibilityGate ||
JSON.stringify(accessibilityGate.evidenceArtifactIds) !==
JSON.stringify(expectedAccessibilityEvidenceArtifactIds)
) {
issue(
"FE-GATE-009 manual accessibility evidence must exactly match the installed route scope",
);
}
for (const expected of manualAccessibilityArtifacts) {
const artifact = contract.artifacts.find(
({ id: artifactId }) => artifactId === expected.id,
);
if (
!artifact ||
artifact.path !== expected.path ||
artifact.schemaId !== "markdown" ||
artifact.production !== "source-controlled"
) {
issue(
`FE-GATE-009 manual accessibility artifact registration is invalid: ${expected.id}`,
);
}
}
const referencedRetentionClasses = new Set( const referencedRetentionClasses = new Set(
contract.gates.map(({ retentionClassId }) => retentionClassId), contract.gates.map(({ retentionClassId }) => retentionClassId),
); );
@@ -759,11 +867,11 @@ function validateContractSemantics(
} }
const expectedGateIds = Array.from( const expectedGateIds = Array.from(
{ length: 26 }, { length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
); );
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) { if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
issue("gate registry must contain FE-GATE-001..026 in canonical order"); issue("gate registry must contain FE-GATE-001..027 in canonical order");
} }
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [ const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY], ["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
@@ -834,7 +942,7 @@ function validateContractSemantics(
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = { const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"], merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"], release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
immutable_build: ["FE-GATE-015"], immutable_build: ["FE-GATE-015", "FE-GATE-027"],
vulnerability_provider: [], vulnerability_provider: [],
provenance_provider: [], provenance_provider: [],
promotion: [], promotion: [],
@@ -888,7 +996,13 @@ function validateContractSemantics(
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = { const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
merge_gate: [], merge_gate: [],
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }], release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
immutable_build: [], immutable_build: [
// FE-GATE-027 admits the built artifact to a named environment, so both
// the profile it was built from and the destination it is claimed for are
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
],
vulnerability_provider: [ vulnerability_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
+56 -3
View File
@@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z
}) })
.strict(); .strict();
/**
* FE-GATE-027. The record of which environment an artifact was admitted to, and
* every reason it was refused. Refusals are kept in the artifact so a rejected
* promotion leaves evidence rather than only a non-zero exit code.
*/
export const deploymentAdmissionArtifactSchema = z
.object({
schemaVersion: z.literal(1),
target: z.enum(["local", "development", "staging", "production"]),
appEnv: z.enum(["local", "development", "staging", "production"]),
authMode: z.enum(["external", "demo"]),
apiBaseUrl: nonEmptyString,
buildId: nonEmptyString.nullable(),
releaseId: nonEmptyString.nullable(),
status: z.enum(["ADMITTED", "REFUSED"]),
violations: z.array(
z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(),
),
})
.strict();
export const releaseVerificationArtifactSchema = z export const releaseVerificationArtifactSchema = z
.object({ .object({
schemaVersion: z.literal(1), schemaVersion: z.literal(1),
@@ -1321,9 +1342,28 @@ export const documentationReviewArtifactSchema = z
reviewer: z.literal("wiki-diagram-reviewer"), reviewer: z.literal("wiki-diagram-reviewer"),
standard: z.literal("rules/diagram-standards.md v2"), standard: z.literal("rules/diagram-standards.md v2"),
evidenceReport: z evidenceReport: z
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 }) .object({
repoPath: nonEmptyString,
upstreamCanonicalPath: nonEmptyString,
canonicalSha256: sha256,
})
.strict(), .strict(),
reportDigestValid: z.boolean(), reportDigestValid: z.boolean(),
/**
* The declared review scope, derived from the installed route registry
* rather than read off a sentence. Both scope documents claimed six routes
* while ten were registered.
*/
routeScope: z.array(
z
.object({
path: nonEmptyString,
missingRouteIds: z.array(nonEmptyString),
documented: z.boolean(),
})
.strict(),
).min(1),
routeScopeDocumented: z.boolean(),
results: z.array( results: z.array(
z z
.object({ .object({
@@ -1350,8 +1390,21 @@ export const documentationReviewArtifactSchema = z
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" }); context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
} }
}); });
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) { artifact.routeScope.forEach((entry, index) => {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" }); if (entry.documented !== (entry.missingRouteIds.length === 0)) {
context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" });
}
});
if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) {
context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" });
}
if (
artifact.passed !==
(artifact.reportDigestValid &&
artifact.routeScopeDocumented &&
artifact.results.every(({ passed }) => passed))
) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" });
} }
}); });
+13 -10
View File
@@ -21,13 +21,12 @@ import {
} from "./lib/build-environment.ts"; } from "./lib/build-environment.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts"; import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts";
import {
findViteDynamicRouteChunk,
type ViteManifestRouteEntry,
} from "./lib/vite-route-chunks.ts";
assertCiBuildEnvironment(process.env); assertCiBuildEnvironment(process.env);
type ViteManifestEntry = Readonly<{
file: string;
name?: string;
isDynamicEntry?: boolean;
}>;
const packageJson = parsePackageMetadata( const packageJson = parsePackageMetadata(
JSON.parse(await readFile("package.json", "utf8")), JSON.parse(await readFile("package.json", "utf8")),
@@ -63,9 +62,9 @@ const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
ROUTE_RUNTIME_CONTRACT; ROUTE_RUNTIME_CONTRACT;
for (const definition of Object.values(ROUTE_REGISTRY)) { for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime = runtimeContracts[definition.routeId]; const runtime = runtimeContracts[definition.routeId];
const asset = Object.values(viteManifestObject).find( const asset = runtime
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry, ? findViteDynamicRouteChunk(viteManifestObject, runtime.moduleId)
); : undefined;
if (!runtime || !asset?.file) { if (!runtime || !asset?.file) {
throw new Error(`Missing built route chunk: ${definition.routeId}`); throw new Error(`Missing built route chunk: ${definition.routeId}`);
} }
@@ -163,9 +162,9 @@ function parsePackageMetadata(value: unknown): Readonly<{
function parseViteManifest( function parseViteManifest(
value: unknown, value: unknown,
): Readonly<Record<string, ViteManifestEntry>> { ): Readonly<Record<string, ViteManifestRouteEntry>> {
if (!isRecord(value)) throw new TypeError("Vite manifest must be an object"); if (!isRecord(value)) throw new TypeError("Vite manifest must be an object");
const entries: Record<string, ViteManifestEntry> = {}; const entries: Record<string, ViteManifestRouteEntry> = {};
for (const [key, candidate] of Object.entries(value)) { for (const [key, candidate] of Object.entries(value)) {
if (!isRecord(candidate) || typeof candidate.file !== "string") { if (!isRecord(candidate) || typeof candidate.file !== "string") {
throw new TypeError(`Invalid Vite manifest entry: ${key}`); throw new TypeError(`Invalid Vite manifest entry: ${key}`);
@@ -176,6 +175,10 @@ function parseViteManifest(
...(typeof candidate.isDynamicEntry === "boolean" ...(typeof candidate.isDynamicEntry === "boolean"
? { isDynamicEntry: candidate.isDynamicEntry } ? { isDynamicEntry: candidate.isDynamicEntry }
: {}), : {}),
...(Array.isArray(candidate.dynamicImports) &&
candidate.dynamicImports.every((item) => typeof item === "string")
? { dynamicImports: candidate.dynamicImports as string[] }
: {}),
}; };
} }
return entries; return entries;
+231
View File
@@ -0,0 +1,231 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Emits the nginx configuration the deployed frontend is served with.
*
* Generated rather than hand-written because three files already decide what it
* must say, and a copy of them would drift: `dist/tech-log-serving-contract.json`
* (which paths are SPA routes and what a miss answers with),
* `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`.
* The repository had no frontend deployment artifact at all no Dockerfile, no
* server config so those two hosting files described a contract nothing
* fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed
* assets and sending no security headers.
*
* This serves static files only. TLS and the BFF paths belong to the edge: the
* deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect
* chain and the identity provider to the backend directly (in Kubernetes,
* Traefik does). A second proxy hop here would only add a place for the two
* routing tables to disagree.
*
* The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is
* built with: served under a prefix, every route and asset lives under it too.
*/
const DIST = "dist";
const OUT = path.join(DIST, "nginx.conf");
type ServingContract = Readonly<{
publicSpaPathPatterns: readonly string[];
studioPathPrefix: string;
studioSpaPathPatterns: readonly string[];
notFound: Readonly<{ status: number; contentType: string; body: string }>;
}>;
type HostingHeaders = Readonly<{ headers: Readonly<Record<string, string>> }>;
type CachePolicy = Readonly<{
surfaces: Readonly<
Record<
string,
Readonly<{
path?: string;
pathPattern?: string;
cacheControl?: string;
securityHeaders?: boolean;
}>
>
>;
}>;
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await readFile(file, "utf8")) as T;
}
/** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */
function exactLocation(pathname: string): string {
return pathname;
}
/**
* A JS regex from the contract translated for nginx. Both use PCRE-ish syntax
* for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern
* carries over unchanged asserted rather than assumed, because a pattern that
* silently failed to translate would open a Studio route to the 404 branch.
*/
function studioRegex(pattern: string): string {
// nginx uses PCRE, so anchors, character classes, alternation and plain groups
// carry over as written. Lookaround and backreferences do not translate the
// same way and would silently change which paths match, so they are refused.
if (/\(\?[=!<]|\\[1-9]/.test(pattern)) {
throw new Error(
`studio SPA pattern uses a construct this generator does not translate: ${pattern}`,
);
}
return pattern;
}
function headerDirectives(
headers: Readonly<Record<string, string>>,
indent: string,
): string {
return Object.entries(headers)
.map(([name, value]) => `${indent}add_header ${name} "${value}" always;`)
.join("\n");
}
async function main(): Promise<void> {
const contract = await readJson<ServingContract>(
path.join(DIST, "tech-log-serving-contract.json"),
);
const security = await readJson<HostingHeaders>(
"config/hosting/security-headers.json",
);
const cache = await readJson<CachePolicy>("config/hosting/cache-policy.json");
const surfaces = cache.surfaces;
const indexCache = surfaces["index"]?.cacheControl ?? "no-cache";
const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store";
const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store";
const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache";
const secure = headerDirectives(security.headers, " ");
// The bundle's own base path. `/` for a deployment at the domain root, `/dev/`
// for one served under a prefix — the routes below have to carry it or nginx
// matches paths the browser never asks for.
const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/";
const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType
.split(";")
.map((part) => part.trim());
const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8")
.replace(/^charset=/i, "")
.toLowerCase();
// Regex locations now, matching the Studio half: the contract declares which
// paths the router serves, not which ones the fixture happened to contain, so
// a record published after this build is served instead of 404ed at the edge.
const publicLocations = contract.publicSpaPathPatterns
.map(
(pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
${secure}
add_header Cache-Control "${indexCache}" always;
try_files /index.html =404;
}`,
)
.join("\n\n");
const studioLocations = contract.studioSpaPathPatterns
.map(
(pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
${secure}
add_header Cache-Control "${indexCache}" always;
try_files /index.html =404;
}`,
)
.join("\n\n");
const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit.
# Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json,
# config/hosting/cache-policy.json
#
# Plain HTTP on purpose: the edge terminates TLS and this container is only ever
# reached from inside the deployment network.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The bundle is small and already compressed at rest by the build; gzip here
# covers the JSON surfaces and index.html.
gzip on;
gzip_types text/css application/javascript text/javascript application/json;
gzip_min_length 1024;
# Static surfaces, per config/hosting/cache-policy.json
# ---------------------------------------------------------------------------
location = ${basePath}/config.json {
alias /usr/share/nginx/html/config.json;
${secure}
add_header Cache-Control "${configCache}" always;
}
location = ${basePath}/release-manifest.json {
alias /usr/share/nginx/html/release-manifest.json;
${secure}
add_header Cache-Control "${manifestCache}" always;
}
# Content-hashed filenames, so the long TTL is safe and revalidation is waste.
location ${basePath}/assets/ {
# alias, not root + URI: under a base path the request is /dev/assets/x.js
# while the file is dist/assets/x.js, so root would look for
# dist/dev/assets/x.js and answer 404 for every script on the page.
alias /usr/share/nginx/html/assets/;
add_header Cache-Control "${assetCache}" always;
}
# Source maps are not published (cache-policy sourceMap.public = false).
location ~ \\.map$ {
return 404;
}
# ---------------------------------------------------------------------------
# SPA routes. Enumerated from the serving contract rather than a catch-all:
# a path that is not a real route answers 404 instead of a 200 shell, which is
# what tells a crawler the difference.
# ---------------------------------------------------------------------------
${publicLocations}
${studioLocations}
location = ${basePath}/favicon.svg {
alias /usr/share/nginx/html/favicon.svg;
add_header Cache-Control "${assetCache}" always;
}
location = ${basePath}/media/ {
return 404;
}
location ${basePath}/media/ {
alias /usr/share/nginx/html/media/;
add_header Cache-Control "${assetCache}" always;
}
# Anything else is not a route this deployment serves.
location / {
# The contract states the content type with its charset attached
# (text/plain;charset=UTF-8), but nginx takes the two separately
# default_type rejects a parameter outright.
default_type ${notFoundType};
charset ${notFoundCharset};
return ${contract.notFound.status} "${contract.notFound.body}";
}
}
`;
await writeFile(OUT, conf, "utf8");
process.stdout.write(
`nginx config: ${OUT} (${contract.publicSpaPathPatterns.length} public routes, ` +
`${contract.studioSpaPathPatterns.length} studio patterns)\n`,
);
}
await main();

Some files were not shown because too many files have changed in this diff Show More