154 Commits
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
DongHyeonkaandClaude Opus 5 93db3c184b chore: carry the RPC-02 deadline-race test fix and re-pin the template
The synced suite contained an assertion that pinned one of two equally
configured deadlines, so it passed alone and failed in a full parallel
run. Fixed upstream and carried here with the template pin moved to
`a0fbafb`.

Three consecutive runs of the 27-file set that reproduced the failure now
pass at 485 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:33:43 +09:00
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00
DongHyeonka 002ba3624e chore: complete tech log product identity 2026-08-13 18:53:52 +09:00
DongHyeonka c2e35dd8ad chore: initialize tech log frontend 2026-08-13 18:24:36 +09:00
DongHyeonka 40107eec84 chore: initialize from frontend template 4dc033c 2026-08-13 18:23:26 +09:00
1336 changed files with 349223 additions and 1 deletions
+225
View File
@@ -0,0 +1,225 @@
{
"forbidden": [
{
"name": "domain-is-framework-neutral",
"severity": "error",
"from": {
"path": "^src/domain"
},
"to": {
"path": "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)"
}
},
{
"name": "application-does-not-know-concrete-runtime",
"severity": "error",
"from": {
"path": "^src/application"
},
"to": {
"path": "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)"
}
},
{
"name": "presentation-does-not-know-adapters",
"severity": "error",
"from": {
"path": "^src/presentation/(?!adapters/query)"
},
"to": {
"path": "^(src/(adapters|bootstrap)|@tanstack)"
}
},
{
"name": "page-templates-own-layout-only",
"severity": "error",
"from": {
"path": "^src/presentation/templates"
},
"to": {
"path": "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)"
}
},
{
"name": "icon-vendor-is-facade-only",
"severity": "error",
"from": {
"path": "^src",
"pathNot": "^src/presentation/design-system/icons/vendors/lucide\\.tsx$"
},
"to": {
"path": "^lucide-react$"
}
},
{
"name": "adapters-do-not-know-presentation",
"severity": "error",
"from": {
"path": "^src/adapters"
},
"to": {
"path": "^src/(presentation|bootstrap)"
}
},
{
"name": "feature-domain-is-framework-neutral",
"severity": "error",
"from": {
"path": "^src/features/[^/]+/domain"
},
"to": {
"path": "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)"
}
},
{
"name": "feature-application-does-not-know-runtime",
"severity": "error",
"from": {
"path": "^src/features/[^/]+/application"
},
"to": {
"path": "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)"
}
},
{
"name": "feature-presentation-does-not-know-outbound-adapters",
"severity": "error",
"from": {
"path": "^src/features/[^/]+/presentation"
},
"to": {
"path": "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)"
}
},
{
"name": "feature-adapters-do-not-know-presentation",
"severity": "error",
"from": {
"path": "^src/features/[^/]+/adapters"
},
"to": {
"path": "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)"
}
},
{
"name": "concrete-adapters-compose-only-in-bootstrap",
"severity": "error",
"from": {
"path": "^src/(domain|application|presentation|contracts)"
},
"to": {
"path": "^src/adapters"
}
},
{
"name": "external-contract-package-single-import-path",
"comment": "§4.1: a generated service package may only be imported from src/features/<feature>/contracts/*-contract-contribution.ts",
"severity": "error",
"from": {
"path": "^src",
"pathNot": "^src/features/[^/]+/contracts/[^/]+-contract-contribution\\.ts$"
},
"to": {
"path": "^@org-contracts/"
}
},
{
"name": "presentation-does-not-fetch-directly",
"comment": "§9.2 / appendix B: a page or hook never opens a socket, worker or HTTP adapter itself",
"severity": "error",
"from": {
"path": "^src/(presentation|features/[^/]+/presentation)"
},
"to": {
"path": "^src/adapters/(http|realtime|service-worker|web-worker|storage)"
}
},
{
"name": "generic-worker-has-no-network-or-credentials",
"comment": "§16.13 / §21.10: a CPU worker never imports HTTP, realtime or auth",
"severity": "error",
"from": {
"path": "^src/adapters/web-worker"
},
"to": {
"path": "^src/adapters/(http|realtime|auth|web-push)"
}
},
{
"name": "service-worker-entry-is-not-page-code",
"comment": "§17.2.1: the worker realm never imports React, presentation or bootstrap page code",
"severity": "error",
"from": {
"path": "^src/adapters/service-worker"
},
"to": {
"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",
"severity": "error",
"from": {},
"to": {
"circular": true
}
}
],
"options": {
"doNotFollow": {
"path": "node_modules"
},
"exclude": {
"path": "^(dist|artifacts|tests/fixtures)"
},
"enhancedResolveOptions": {
"exportsFields": [
"exports"
],
"conditionNames": [
"import",
"require",
"node",
"default"
]
},
"tsConfig": {
"fileName": "tsconfig.app.json"
}
}
}
+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
+461
View File
@@ -0,0 +1,461 @@
# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.
name: frontend-quality-gates
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
stage:
description: Highest promotion tier to evaluate
required: true
default: merge
type: choice
options:
- merge
- release
- production
- field
- documentation
permissions:
contents: read
env:
CI: "true"
VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
VITE_COMMIT_SHA: "${{ gitea.sha }}"
RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"
jobs:
merge_gate:
name: "${{ matrix.gate }} / ${{ matrix.name }}"
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-001, name: manifest-lockfile, browser: false }
- { gate: FE-GATE-002, name: lint, browser: false }
- { gate: FE-GATE-003, name: typecheck, browser: false }
- { gate: FE-GATE-004, name: runtime-schema, browser: false }
- { gate: FE-GATE-005, name: unit, browser: false }
- { gate: FE-GATE-006, name: component, browser: false }
- { gate: FE-GATE-007, name: integration, browser: false }
- { gate: FE-GATE-008, name: e2e, browser: true }
- { gate: FE-GATE-009, name: accessibility, browser: true }
- { gate: FE-GATE-010, name: architecture, browser: false }
- { gate: FE-GATE-011, name: build, browser: false }
- { gate: FE-GATE-013, name: security, browser: false }
- { gate: FE-GATE-020, name: removability, browser: false }
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload merge gate evidence
if: always()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
release_gate:
name: "${{ matrix.gate }} / ${{ matrix.name }}"
needs: merge_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
HOSTING_BASE_URL: "${{ vars.HOSTING_BASE_URL }}"
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-012, name: bundle, browser: false }
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
- { gate: FE-GATE-019, name: hosting-header, browser: false }
- { gate: FE-GATE-026, name: lab-performance, browser: true }
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload release gate evidence
if: always()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
immutable_build:
name: "FE-GATE-015 / immutable-release-candidate"
needs: release_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
env:
APP_PROFILE: "${{ vars.APP_PROFILE }}"
RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}"
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Build candidate once and verify local evidence
run: corepack pnpm ci:gate -- FE-GATE-015
- name: Archive and validate the exact candidate file set
id: candidate
run: |
mkdir -p .release
tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \
dist \
pnpm-lock.yaml \
artifacts/performance/bundle.json \
artifacts/quality/vite-module-inventory.json \
artifacts/release/build-manifest.json \
artifacts/release/checksums.txt \
artifacts/release/dependency-inventory.json \
artifacts/release/provenance.json \
artifacts/release/verification.json \
artifacts/release/sbom.cdx.json \
artifacts/security/dependency-diff.json \
artifacts/security/license-report.json \
artifacts/security/local-evidence-assessment.json \
artifacts/security/scan.sarif \
artifacts/security/supply-chain-coherence.json \
artifacts/security/supply-chain-verification.json \
artifacts/security/vulnerability-report.json \
config/security/dependency-baseline.approval.json \
config/security/dependency-baseline.json \
config/security/dependency-change-evidence.json \
config/security/dependency-policy.json \
config/security/secret-scan-policy.json \
config/security/vulnerability-exceptions.json \
config/security/vulnerability-policy.json \
schemas/artifacts/build-manifest.schema.json \
schemas/artifacts/dependency-inventory.schema.json \
schemas/artifacts/supply-chain-verification.schema.json \
scripts/contracts/release-artifacts.ts \
scripts/create-release-candidate.ts \
scripts/generate-supply-chain.ts \
scripts/lib/build-manifest-outputs.ts \
scripts/lib/json-schema.ts \
scripts/lib/local-policy-evidence.ts \
scripts/lib/local-release-evidence.ts \
scripts/lib/release-candidate.ts \
scripts/lib/release-input-evidence.ts \
scripts/lib/release-runtime-coherence.ts \
scripts/lib/repository-file-inventory.ts \
scripts/lib/secret-scan-evaluator.ts \
scripts/lib/secret-scan-policy.ts \
scripts/lib/secret-scan.ts \
scripts/lib/supply-chain.ts \
scripts/lib/validated-json-artifact.ts \
scripts/lib/vite-route-chunks.ts \
src/contracts/release-artifacts.ts \
src/features/installed-contract-contributions.ts \
src/features/installed-feature-contracts.ts \
artifacts/release/release-candidate.json
node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"
- name: Upload release candidate
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
if-no-files-found: error
vulnerability_provider:
name: external-vulnerability-provider
needs: immutable_build
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}
env:
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
CI_RUN_ID: "${{ gitea.run_id }}"
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/vulnerability-candidate
- name: Run and validate external vulnerability provider in one trusted supervisor
id: supervise_vulnerability
run: node scripts/run-and-validate-provider.ts --kind vulnerability
- name: Confirm sealed vulnerability provider evidence
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
- name: Upload vulnerability provider evidence
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: provider-evidence/vulnerability-report.json
if-no-files-found: error
provenance_provider:
name: external-provenance-provider
needs: immutable_build
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}
env:
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
CI_RUN_ID: "${{ gitea.run_id }}"
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/provenance-candidate
- name: Run and validate external provenance provider in one trusted supervisor
id: supervise_provenance
run: node scripts/run-and-validate-provider.ts --kind provenance
- name: Confirm sealed provenance provider evidence
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
- name: Upload provenance provider evidence
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: provider-evidence/provenance-attestation.json
if-no-files-found: error
promotion:
name: promote-verified-immutable-candidate
needs: [immutable_build, vulnerability_provider, provenance_provider]
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
CI_RUN_ID: "${{ gitea.run_id }}"
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/candidate
- name: Download vulnerability provider evidence
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/vulnerability
- name: Download provenance provider evidence
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/provenance
- name: Finalize verified promotion from inode-bound captured inputs
id: finalize
run: node scripts/stage-verified-promotion.ts
- name: Upload promoted release
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: |
${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz
${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json
${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json
${{ steps.finalize.outputs.staging_root }}/provider-verification.json
${{ steps.finalize.outputs.staging_root }}/promotion-verification.json
if-no-files-found: error
- name: Always remove private promotion staging
if: always()
env:
PROMOTION_STAGING_ROOT: ${{ steps.finalize.outputs.staging_root }}
PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }}
PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }}
PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }}
PROMOTION_STAGING_DEV: ${{ steps.finalize.outputs.staging_dev }}
PROMOTION_STAGING_INO: ${{ steps.finalize.outputs.staging_ino }}
run: |
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then
node scripts/cleanup-verified-promotion.ts
fi
production_gate:
name: "${{ matrix.gate }} / ${{ matrix.name }}"
needs: promotion
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-016, name: rollback-drill }
- { gate: FE-GATE-021, name: runbook-boot-config }
- { gate: FE-GATE-022, name: runbook-chunk-mismatch }
- { gate: FE-GATE-023, name: runbook-api-degradation }
- { gate: FE-GATE-024, name: runbook-telemetry }
- { gate: FE-GATE-025, name: runbook-release-rollback }
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload production gate evidence
if: always()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
field_gate:
name: "FE-GATE-018 / field-web-vitals"
needs: production_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
FIELD_WEB_VITALS_INPUT: "${{ vars.FIELD_WEB_VITALS_INPUT }}"
MIN_ELIGIBLE_SAMPLES: "${{ vars.MIN_ELIGIBLE_SAMPLES }}"
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run blocking gate
run: corepack pnpm ci:gate -- FE-GATE-018
- name: Upload field gate evidence
if: always()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "FE-GATE-018-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
documentation_gate:
name: "FE-GATE-017 / diagram-review"
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run documentation gate
run: corepack pnpm ci:gate -- FE-GATE-017
- name: Upload documentation gate evidence
if: always()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "FE-GATE-017-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
+32
View File
@@ -0,0 +1,32 @@
node_modules/
dist/
.vite/
.generated/
.tmp/
playwright-report/
test-results/
coverage/
.worktrees/
!tests/fixtures/coverage/
!tests/fixtures/coverage/below-threshold.json
artifacts/**/*.json
artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
artifacts/tests/e2e/
artifacts/tests/browser-capabilities/
artifacts/storybook/
artifacts/tests/storybook/
artifacts/tests/visual/
!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/
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
save-exact=true
+1
View File
@@ -0,0 +1 @@
24.14.0
+15
View File
@@ -0,0 +1,15 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-a11y"],
framework: {
name: "@storybook/react-vite",
options: {},
},
core: {
disableTelemetry: true,
},
};
export default config;
+123
View File
@@ -0,0 +1,123 @@
import type { Preview } from "@storybook/react-vite";
import { QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { createAnonymousSessionAdapter } from "../src/adapters/auth/external-session-adapter.ts";
import { createQueryClient } from "../src/adapters/query-cache/tanstack-query-cache.ts";
import { createApplication } from "../src/application/create-application.ts";
import { LocaleProvider } from "../src/presentation/i18n/index.ts";
import { ApplicationProvider } from "../src/presentation/providers/application-provider.tsx";
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
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 application = createApplication({
session: createAnonymousSessionAdapter(),
preferences: {
read: (name) => ({ ok: true, value: preferences.get(name) }),
write: (name, value) => {
preferences.set(name, structuredClone(value));
return { ok: true };
},
remove: (name) => {
preferences.delete(name);
return { ok: true };
},
},
diagnostics: { record() {} },
telemetry: { emit() {} },
releaseInfo: {
getCurrent: async () => ({
buildId: "storybook-build",
releaseId: "storybook-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "storybook-assets",
routeChunks: {},
}),
refresh: async () => ({
buildId: "storybook-build",
releaseId: "storybook-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "storybook-assets",
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: {
getSnapshot: () =>
Object.freeze(
(
[
"REALTIME",
"WEB_WORKER",
"SERVICE_WORKER",
"OFFLINE_COMMANDS",
] as const
).map((capabilityId) =>
Object.freeze({
capabilityId,
selected: 0,
active: 0,
override: "DEFAULT" as const,
}),
),
),
},
navigation: { reload() {} },
});
const queryClient = createQueryClient();
const preview: Preview = {
decorators: [
(Story) => (
<ApplicationProvider application={application}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<LocaleProvider>
<ThemeProvider>
<SessionProvider>
<div id="portal-root" />
<main className="ui-page" style={{ padding: "1rem" }}>
<Story />
</main>
</SessionProvider>
</ThemeProvider>
</LocaleProvider>
</MemoryRouter>
</QueryClientProvider>
</ApplicationProvider>
),
],
parameters: {
a11y: {
test: "error",
},
controls: {
expanded: true,
},
options: {
storySort: {
order: ["Platform"],
},
},
},
};
export default preview;
@@ -0,0 +1,110 @@
# Task 5 Report: Retain and reconcile uncertain optimistic mutations
## Status
Task 5 is implemented. Mutation settlement now follows explicit effect certainty, preserves unknown optimistic projections as ordered uncertain layers, and exposes one-at-a-time reconciliation bound to the original mutation record. Missing post-dispatch certainty is fail-safe `MAYBE_APPLIED`; only controller-owned pre-dispatch failures are marked `NOT_STARTED`.
## RED evidence
- Initial focused command: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`.
- Initial result: exit `1`, 2 files, 11 failed / 28 passed. Missing lease/controller APIs failed directly; `APPLIED_CONFIRMED` and `MAYBE_APPLIED` were rolled back; effectless failures retained generic retry semantics.
- Review-driven RED: the three-file focused command including `tests/component/async-surface.test.tsx` exited `1` with 7 failed / 56 passed. It exposed applied-confirmed retry actions, active-submit reset, double reconciliation, stale-scope queue retention, overlay priority, and the incorrect refreshing copy.
- A final isolated RED proved a synchronous `NOT_APPLIED` double action could consume two FIFO records in one event turn.
- The production-form RED exited `1` with 2 failed / 8 passed: applied reconciliation left the original command dirty/retryable, while an `APPLIED_CONFIRMED` failure rendered generic unavailable.
- The final durability/lifecycle RED failed 2 / 2: an anonymous non-optimistic legacy channel did not survive remount, and render-time registry allocation exhausted the definition cap during an abandoned server render.
## Implementation
1. `OptimisticLayerLease` now supports `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`. Layers are `pending | uncertain | committed`; only a committed prefix collapses into the base, while projection continues to apply every later layer in order.
2. Reconciliation is single-settlement and idempotent. `APPLIED` converts the uncertain layer to committed; `NOT_APPLIED` removes only that layer; both then collapse/reproject later committed or pending layers. Scope expiry removes stale cache instead of restoring it.
3. Legacy optimistic mutations use the same reusable always-current ordered runtime. This prevents an old manual snapshot from erasing a later successful mutation or authoritative projection. The runtime also supports optimistic entries whose base data was absent and removes them on a not-applied rollback.
4. The mutation bridge derives effect before touching optimistic state:
- `NOT_STARTED` / `NOT_APPLIED`: rollback;
- `APPLIED_CONFIRMED`: commit, then best-effort invalidate;
- `MAYBE_APPLIED`: retain as uncertain, do not invalidate, and enqueue explicit reconciliation.
5. Missing or `NOT_APPLICABLE` command effects, returned failures after dispatch, and thrown execution failures normalize to `MAYBE_APPLIED`. Unknown effects are non-retryable with `contact-support`; applied-confirmed failures are non-retryable with no resend action. Controller-owned stale scope, duplicate admission, identity/preparation, and other known pre-dispatch failures carry `NOT_STARTED`.
6. Unknown records retain their original intent, scope, layer lease, invalidation topics, and coordinator. A FIFO queue prevents parallel `ALLOW_PARALLEL` failures from overwriting each other. Reconciliation is locked through the event turn so a double action cannot consume the next intent, and it does not reset a newer active submit.
7. Scope abort discards every queued record from that scope, settles only its local stale layers, performs no invalidation, and cannot later overwrite new-generation cache data.
8. Async state adds the mutually exclusive `mutation-effect-unknown` overlay with priority `unknown > conflict > pending > stale-degraded > refreshing`. `AsyncSurface` uses dedicated safe copy and only `APPLIED` / `NOT_APPLIED` actions; it does not expose generic retry or mark the surface busy.
9. Unknown-effect admissions live in a bounded QueryClient-owned registry, so bound and legacy controllers can remount without losing reconciliation state. Channels include definition version and generation, validate the exact scope owner, count active admissions globally in O(1), release after late settlement, and preserve FIFO order even when executions finish in reverse.
10. Channel creation and scope-abort listener registration occur only in a committed React effect. An abandoned/server render performs no registry mutation and consumes no channel capacity. Non-optimistic legacy callers must provide a stable `definitionId`; optimistic legacy callers also include their query identity.
11. The reference create form blocks all generic resubmission while effect certainty is unknown. `NOT_APPLIED` preserves input and re-enables submission; `APPLIED` reconciliation and `APPLIED_CONFIRMED` settlement use the form's success-equivalent reset path so the same create command cannot be resent.
## Test coverage
- Certainty matrix for all four mutation effects, missing effect, `NOT_APPLICABLE`, thrown execution, ambiguous conflicts, and non-retry semantics.
- Applied-confirmed commit-before-invalidate ordering and retained commit when invalidation fails.
- Out-of-order later commits behind uncertain layers; both reconciliation outcomes; duplicate/reversed lease transitions; external cache projection; expired scope.
- Bound and legacy reconciliation, no-layer legacy fallback, parallel unknown queues, same-turn double actions, active newer submit preservation, scope-wide stale cleanup, and no-prior-cache rollback.
- Bound and non-optimistic legacy remount durability, unrelated legacy isolation, generation isolation, exact scope-owner collision handling, abandoned-render capacity, late empty-channel cleanup, QueryClient-global admission bounds, reverse completion, and fence-during-invalidation races.
- Production create-form coverage for the `MAYBE_APPLIED` block, `NOT_APPLIED` input preservation, and success-equivalent `APPLIED` / `APPLIED_CONFIRMED` settlement.
- Unknown overlay derivation, mutual-exclusion priority, dedicated localized copy, non-busy state, and reconciliation-only actions.
- The negative async-overlay type fixture now includes `mutationEffectUnknown: false`, so it continues to fail for the intended pending/conflict exclusivity violation.
## Files changed
- `src/presentation/adapters/query/optimistic-layer-runtime.ts`
- `src/presentation/adapters/query/application-query.ts`
- `src/application/view-models/async-state.ts`
- `src/contracts/errors.ts`
- `src/presentation/components/async-surface.tsx`
- `src/presentation/forms/form-contracts.ts`
- `src/presentation/forms/use-app-form.ts`
- `src/presentation/i18n/catalog.ts`
- `src/features/reference-feature/presentation/reference-resource-form-page.tsx`
- `tests/unit/optimistic-layer-runtime.test.ts`
- `tests/component/application-query.test.tsx`
- `tests/component/async-surface.test.tsx`
- `tests/features/reference-feature/reference-page.test.tsx`
- `tests/fixtures/typecheck/invalid-async-overlay.ts`
The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scope expansion required to avoid rendering the new indicator as a background refresh and to preserve the overlay type contract.
## Verification
- Final focused command (error classification, optimistic runtime, mutation bridge, async surface, form facade, and production reference form): 6 files / 100 tests — PASS.
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
- `corepack pnpm lint` — PASS with zero warnings.
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 741, component 123, integration 23, reference feature 24, recipes 17.
- `git diff --check` — PASS.
- `corepack pnpm run check:types:fixture:async-overlay` — expected non-zero; TypeScript rejects `mutationConflict: true` when `mutationPending: true`, confirming the negative fixture still reaches its intended invariant.
## Self-review decisions
- The plan-prescribed `reconcileUnknownEffect(resolution)` API remains intact. Rather than introduce a public token incompatible with that interface, the controller retains intent-bound FIFO records and serializes reconciliation through the current event turn. A repeated action after the first promise settles is an explicit action on the next visible unknown record.
- Scope cleanup removes stale local projection without claiming or invalidating a server outcome. A stale generation cannot use its former record after the queue is discarded.
- Legacy manual snapshot restoration was removed because it could erase later successful work. Shared ordered layers are the minimal mechanism that gives legacy and bound mutations the same re-projection guarantees.
- A non-optimistic legacy mutation has no cache key from which a durable logical identity can be inferred. Its type contract therefore requires a stable caller-supplied `definitionId`; this preserves remount durability without merging unrelated controllers.
- Registry mutation was moved out of render into the committed effect lifecycle. The server-render regression fills the nominal definition count with abandoned renders, then proves a committed mutation can still acquire and execute.
- Browser/Playwright gates were not run; this task changed no browser-only integration. The jsdom component tests cover the new accessible status and actions.
## Final review
The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge.
## Runtime final-review fix round 3
The runtime-wide final review identified three additional Task 5 authorities. This round addresses only those findings; the provider-neutral HTTP operation port remains deferred to its separately owned remediation plans.
### RED evidence
- Composite optimistic admission: `tests/unit/optimistic-layer-runtime.test.ts` failed 2 / 8 cases because a base-valid candidate that threw only after the prior layer returned a lease and orphaned both rollback and reconciliation authority.
- Candidate replay: the isolated admission test failed with candidate updater call count `2` instead of `1`; replay through `project()` could still delete the entry after successful preflight.
- Form reconciliation: `tests/component/form-foundation.test.tsx` failed 3 / 8 cases. The hook admitted a second submit during unknown effect, settled edited value B instead of submitted snapshot A, and exposed no explicit not-applied release authority.
- Production namespace parity: the mounted reference-page regression failed because `REFERENCE_RESOURCE_QUERY_NAMESPACE` was not exported; production list/detail hooks could only duplicate its id/version literals.
### Implementation
1. `OptimisticLayerRuntime.begin()` now computes the complete ordered projection before admission. A composite failure returns pessimistic fallback `null` without changing the existing entry, cache projection, layer IDs, or earlier lease authority. The admitted candidate is written from that precomputed value, so its updater runs exactly once during admission.
2. `useAppForm` retains the exact parsed values for a `MAYBE_APPLIED` submission. The ref is the hook-level admission lock until `settleApplied`, `settleNotApplied`, or `reset` releases it; later edits preserve the unknown result and cannot trigger another command. With `resetOnSuccess: false`, APPLIED makes submitted A the baseline while edited B remains dirty. Success, applied-confirmed, validation/conflict/unavailable outcomes, reset, and explicit not-applied settlement clear the retained snapshot.
3. The reference form routes both reconciliation outcomes into the corresponding form settlement authority.
4. `REFERENCE_RESOURCE_QUERY_NAMESPACE` is exported from the governed feature contract. Both production list and detail query definitions consume its fields, while the mounted-key regression compares both real query prefixes with the installed invalidation edge.
### Verification
- Focused runtime/form/reference command: 5 files / 87 tests — PASS.
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
- `corepack pnpm lint` — PASS with zero warnings.
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 744, component 126, integration 23, reference feature 25, recipes 17.
- `git diff --check` — PASS.
- Scoped re-review by the existing Task 5 reviewer: no findings, ready to merge. The reviewer independently passed the 5-file scoped suite (96 / 96), confirmed `git diff --check`, verified all three reconciliation authorities plus candidate single-invocation, and confirmed the deferred HTTP adapter remained untouched.
@@ -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
+266 -1
View File
@@ -1 +1,266 @@
tech-log
# Tech Log Frontend
Initialized from `clean-architecture-frontend-template` revision
`4dc033cf33a5b6173bbf960d5eb464a406dc4c92`. The exact source identity is
recorded in `template.lock.json`.
A React/Vite TechLog application where architecture boundaries, integration
behavior, release coherence, accessibility, performance, and operations are
executable contracts rather than conventions.
## Start locally
Requirements: the exact Node.js version in `.nvmrc` (currently 24.14.0) and
Corepack. The repository pins pnpm in `package.json`.
Product source, tests, build/quality scripts, and supported tool configuration
are TypeScript/TSX. `allowJs` is disabled. Node-side `.ts` scripts run directly
on the pinned Node 24 runtime and are checked with NodeNext resolution plus
erasable-syntax enforcement. Project-owned executable source contains no
JavaScript-family files; negative architecture, security, and type-compatibility
fixtures are TypeScript/TSX as well.
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm dev
```
Runtime-public settings live in `public/config.json` and are validated before
the product tree mounts. Client secrets are forbidden.
## TechLog experience
The default build mounts the source-faithful TechLog Public and Studio
experience. Public routes provide discovery, search, documents, topics,
projects, releases, and profile content. `/studio` provides the session-scoped
mock authoring workflow: create, edit, validate, preview, publish, unpublish,
and immutable publication history.
The 27 canonical route definitions are divided into `PUBLIC` and `STUDIO`
nested layouts. Studio authentication remains deliberately deferred; its mock
gateway state lasts for one Studio shell session and resets on a full document
load. The exact route, dependency, stylesheet, asset, and parity inventory is
recorded in
[`docs/operations/techlog-ui-migration-baseline.md`](docs/operations/techlog-ui-migration-baseline.md).
`corepack pnpm build` emits a self-contained `dist/server.mjs` production
boundary. `corepack pnpm preview --host 127.0.0.1 --port 4174` serves known
Public and Studio routes as SPA documents, preserves the in-shell Studio 404,
and returns source-exact raw `404 text/plain` responses for missing Public
content. With the read-only source temp-copy server on `4375`, run:
```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
Dependencies point inward:
```text
presentation -> application -> domain
adapters -----^
bootstrap composes concrete adapters
contracts own cross-cutting registries
```
See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
`docs/architecture/starter-experience.md`. TechLog is one feature boundary
under `src/features/tech-log`. Its immutable Public catalog and session-scoped
Studio mock gateway are injected through the application feature input;
Public and Studio presentation code shares the typed content renderer without
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
The starter shell is implemented, but the repository review also records the
remaining work required before feature teams can use every declared contract
through one end-to-end application path:
- [platform capability review](docs/architecture/frontend-platform-capability-review.md)
- [ports, adapters, and feature boundaries](docs/architecture/frontend-ports-adapters-and-boundaries.md)
- [REST, GraphQL, Connect/gRPC-Web, Schema, Mapper, and Server State](docs/architecture/api-contract-schema-mapper-and-server-state.md)
- [Protobuf browser transports and REST Gateway](docs/architecture/protobuf-browser-transport-and-rest-gateway.md)
- [backend API and Server State handoff contract](docs/architecture/backend-api-and-server-state-contract.md)
- [TypeScript, state ownership, and data flow](docs/architecture/typescript-state-and-data-flow.md)
- [routing, page templates, and reusable patterns](docs/architecture/routing-pages-and-patterns.md)
- [browser data capability completion ledger](docs/architecture/browser-data-capability-completion-ledger.md)
- [browser file and origin-storage platform](docs/architecture/browser-file-and-origin-storage.md)
- [client cache and storage](docs/architecture/client-cache-and-storage.md)
- [realtime events, Web Push, and bounded polling](docs/architecture/realtime-events-web-push-and-bounded-polling.md)
- [presigned transfer, resumable upload, streaming download, and Image CDN](docs/architecture/presigned-transfer-and-image-cdn.md)
- [server file capability infrastructure](docs/architecture/server-file-capability-infrastructure.md)
- [design-system platform](docs/styling/design-system-platform.md)
- [frontend platform testing strategy](docs/testing/frontend-platform-testing-strategy.md)
- [implementation roadmap](docs/architecture/frontend-platform-implementation-roadmap.md)
These documents distinguish repository defaults from opt-in adapters and
project-owned integrations. They are target designs and review findings; a
capability is not treated as implemented until its branch acceptance criteria
and executable gates pass.
## Verification
Common local checks:
```bash
corepack pnpm lint
corepack pnpm check:types
corepack pnpm check:types:app
corepack pnpm check:types:node
corepack pnpm check:types:test
corepack pnpm check:architecture
corepack pnpm test:all
corepack pnpm test:e2e
corepack pnpm test:a11y
corepack pnpm build
corepack pnpm check:bundle
corepack pnpm test:performance
corepack pnpm verify:compatibility
corepack pnpm verify:release
corepack pnpm check:registries
corepack pnpm drill:runbooks
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
project로 모두 검사한다. type/architecture/security/registry의 invalid
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
실행된다. 도구 호환성 결정은
[VD-01](docs/architecture/decisions/VD-01-typescript-lint-tooling.md)에 기록돼
있다.
Application feature input은 module augmentation으로 닫힌 ID와 정확한 input
shape를 제공하며, 공통 `Result<Value, Failure = AppFailure>`는 error registry의
failure kind만 application/presentation 경계를 통과시킨다. Architecture gate는
TypeScript/TSX의 static, dynamic, type import를 별도 정적 그래프로 분석하고
runtime/source 영역의 JavaScript 재유입도 거절한다. 해석되지 않은 import,
parse failure, 금지 계층 edge와 순환 의존은 모두 fail-closed이며 전용
TypeScript/TSX negative fixture로도 검증된다.
Install the pinned Playwright browser engines before the first cross-browser
run:
```bash
corepack pnpm exec playwright install --with-deps chromium firefox webkit
```
Two gates intentionally need external evidence:
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
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
eligible-sample threshold and 28 days of production data exist.
Live release verification additionally requires `HOSTING_BASE_URL`.
## CI and evidence
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
`.gitea/workflows/quality-gates.yml`. It follows:
```text
MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY
```
`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning.
Machine-readable evidence is written below `artifacts/`; generated evidence is
ignored by Git while `.gitkeep` files preserve the taxonomy.
Operational details are in `docs/operations/`, with incident procedures in
`docs/runbooks/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+18
View File
@@ -0,0 +1,18 @@
# NOT_FOUND accessibility review
Status: pending-manual-review
Route ID: 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_CASE accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_CASE
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_EXPLORE accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_EXPLORE
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_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.
@@ -0,0 +1,18 @@
# TECH_LOG_HOME accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_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_PROFILE accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_PROFILE
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 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

+38
View File
@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"layers": {
"domain": {
"root": "src/domain",
"mayImport": ["src/domain"]
},
"application": {
"root": "src/application",
"mayImport": ["src/application", "src/domain", "src/contracts"]
},
"presentation": {
"root": "src/presentation",
"mayImport": ["src/presentation", "src/application", "src/domain", "src/contracts"]
},
"adapters": {
"root": "src/adapters",
"mayImport": ["src/adapters", "src/application", "src/domain", "src/contracts"]
},
"bootstrap": {
"root": "src/bootstrap",
"mayImport": ["src"]
}
},
"forbidden": [
["domain", "application"],
["domain", "presentation"],
["domain", "adapters"],
["domain", "bootstrap"],
["application", "presentation"],
["application", "adapters"],
["application", "bootstrap"],
["presentation", "adapters"],
["presentation", "bootstrap"],
["adapters", "presentation"],
["adapters", "bootstrap"]
]
}
+3000
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"families": {
"api": {
"additive": {
"before": { "required": ["id"], "properties": { "id": {} } },
"after": {
"required": ["id"],
"properties": { "id": {}, "displayName": {} }
}
},
"breaking": {
"before": { "required": ["id"], "properties": { "id": {} } },
"after": {
"required": ["id", "name"],
"properties": { "id": {}, "name": {} }
}
}
},
"config": {
"additive": {
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
"after": {
"required": ["APP_ENV"],
"properties": { "APP_ENV": {}, "OPTIONAL_FLAG": {} }
}
},
"breaking": {
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
"after": {
"required": ["APP_ENV", "NEW_REQUIRED"],
"properties": { "APP_ENV": {}, "NEW_REQUIRED": {} }
}
}
},
"storage": {
"additive": {
"before": { "properties": { "theme": {} } },
"after": { "properties": { "theme": {}, "contrast": {} } }
},
"breaking": {
"before": { "properties": { "theme": {} } },
"after": { "properties": {} }
}
},
"release": {
"additive": {
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
"after": {
"required": ["buildId"],
"properties": { "buildId": {}, "builtAt": {} }
}
},
"breaking": {
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
"after": {
"required": ["buildId", "assetManifestHash"],
"properties": { "buildId": {}, "assetManifestHash": {} }
}
}
}
}
}
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"snapshotDigest": "428479ac5845374a82dd7d02a0c59a713106405f031cdc153789174f14c1405b",
"owner": "tech-log-frontend",
"reason": "Install approved TechLog Public and Studio route contract",
"approvedAt": "2026-08-15T16:32:32.042Z"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,365 @@
{
"schemaVersion": 1,
"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",
"versionBump": "Runtime Config V2 (CONFIG_SCHEMA_VERSION 2.0) removes the scalar API contract version.",
"migration": "Release Manifest V2 contractSet replaces the scalar: the compiled external contract package set is canonicalized and digested, and boot compares it against the manifest.",
"compatibilityWindow": "The V1 config and V1 manifest readers stay in place for one release. A V1 document may still carry the scalar; a V2 document is rejected if it does.",
"rollback": "Restore the V1 writer in scripts/generate-build-manifest.ts and the scalar key in public/config.json; the V1 reader is still present.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:*:*:removed",
"versionBump": "Query invalidation composition moves from the legacy flat query registry to the bounded many-to-many invalidation graph.",
"migration": "Installed feature contracts now contribute topics, namespace identities, edges, and separate wire versions; bootstrap validates and indexes those contributions before constructing coordinators.",
"compatibilityWindow": "Cross-context envelopes remain opaque topic/version pairs and release cache epochs isolate mixed releases; no query keys or cached values cross contexts.",
"rollback": "Restore the flat QUERY_REGISTRY composition and its coordinator adapter together with the prior governance entry.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:$contract:allowedValues:contract-field-changed",
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
"migration": "Every installed query row declares invalidate-only; older tabs remain local-only.",
"compatibilityWindow": "Mixed releases are isolated by release cacheEpoch and never exchange query keys or values.",
"rollback": "Remove the coordinator composition and the two query registry fields.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:$contract:breakingFields:contract-field-changed",
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
"migration": "Every installed query row declares its opaque topic and transport policy.",
"compatibilityWindow": "A release cacheEpoch rejects messages from a different deployed contract.",
"rollback": "Remove the coordinator composition and restore the prior query registry contract.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:$contract:fieldTypes:contract-field-changed",
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
"migration": "The installed reference query row and all consumers were updated atomically.",
"compatibilityWindow": "Old clients do not consume the new fields; new clients validate them before boot.",
"rollback": "Restore the previous query registry field types and local-only invalidation.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:$contract:requiredFields:contract-field-changed",
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
"migration": "Missing topics now fail registry validation instead of silently degrading at runtime.",
"compatibilityWindow": "Only a fully built release consumes its own installed registry snapshot.",
"rollback": "Remove the newly required fields and cross-context composition together.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:$contract:uniqueFields:contract-field-changed",
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
"migration": "Existing query namespaces received unique registry-issued opaque topics.",
"compatibilityWindow": "Topics are scoped by release cacheEpoch, so mixed releases cannot collide.",
"rollback": "Drop invalidationTopic uniqueness after removing the transport consumer.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-RELEASE:apiContractVersion:compatibilityRole:field-changed",
"versionBump": "The release token registry adds contractSetDigest and demotes apiContractVersion to a legacy V1 scalar.",
"migration": "compareReleaseToRuntime no longer sources the scalar from Runtime Config; when neither side declares it there is nothing to compare and contractSet verification owns contract coherence.",
"compatibilityWindow": "A V1 manifest still supplies apiContractVersion and is still compared against a V1 config that declares one.",
"rollback": "Restore the previous compatibilityRole text and remove the contractSetDigest token together with the V2 manifest writer.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-STORAGE:$contract:allowedValues:contract-field-changed",
"versionBump": "Web Storage value codec contracts start at version 1.",
"migration": "Existing keys retain their physical schema version; values outside the selected codec are discarded.",
"compatibilityWindow": "Valid existing color-scheme and opaque-string records remain readable.",
"rollback": "Restore the previous registry contract; no physical key deletion is required.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-STORAGE:$contract:breakingFields:contract-field-changed",
"versionBump": "Web Storage value codec contracts start at version 1.",
"migration": "Each existing row received an explicit codec; executable migration was never consumed and is now forbidden.",
"compatibilityWindow": "Schema-versioned physical keys and the envelope shape remain unchanged.",
"rollback": "Remove valueCodec enforcement and restore the prior metadata declaration.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-STORAGE:$contract:fieldTypes:contract-field-changed",
"versionBump": "Web Storage value codec contracts start at version 1.",
"migration": "The dead function migration union was narrowed to the only implemented discard policy.",
"compatibilityWindow": "All installed definitions already used discard before this contract change.",
"rollback": "Restore the former type metadata without rewriting persisted records.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-STORAGE:$contract:requiredFields:contract-field-changed",
"versionBump": "Web Storage value codec contracts start at version 1.",
"migration": "All installed storage rows now declare their closed value codec.",
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
"rollback": "Remove the required codec field and runtime codec dispatch together.",
"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"
}
]
}
+535
View File
@@ -0,0 +1,535 @@
{
"schemaVersion": 2,
"sourceDirectories": [
"src/application",
"src/presentation",
"src/domain"
],
"registries": [
{
"registryId": "FE-REG-ROUTE",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "ROUTE_REGISTRY",
"owner": "feature-frontend-routing-release-recovery-runtime",
"keyField": "routeId",
"requiredFields": [
"routeId",
"path",
"layoutGroup",
"paramsSchema",
"searchSchema",
"access",
"loadingSurface",
"errorSurface",
"chunkId",
"title",
"navigationLabel",
"navigationOrder"
],
"fieldTypes": {
"routeId": "string",
"path": "string",
"layoutGroup": "string",
"paramsSchema": "string|null",
"searchSchema": "string|null",
"access": "string",
"loadingSurface": "string",
"errorSurface": "string",
"chunkId": "string",
"title": "string",
"navigationLabel": "string|null",
"navigationOrder": "integer|null"
},
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"access": ["public", "session-required"],
"layoutGroup": ["PUBLIC", "STUDIO"],
"paramsSchema": [
null,
"NotFoundSplat",
"ReferenceResourceParams",
"TechLogExploreKindParams",
"TechLogSlugParams",
"TechLogVersionParams",
"TechLogDocumentIdParams",
"TechLogPublicationEventIdParams",
"TechLogStudioSplat"
],
"searchSchema": [
null,
"ReferenceResourceListQuery",
"TechLogHomeSearch",
"TechLogExploreSearch",
"TechLogExploreKindSearch",
"TechLogSearchQuery",
"TechLogCaseStateSearch"
],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
"reference-resource-detail",
"reference-resource-form",
"reference-resource-status",
"none"
],
"errorSurface": [
"route-boundary",
"feature-boundary",
"not-found"
]
},
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE-RUNTIME",
"targetField": "routeId"
},
{
"field": "paramsSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
},
{
"field": "searchSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
}
],
"consumers": [
{
"path": "src/presentation/routes/app-router.tsx",
"token": "ROUTE_REGISTRY"
}
],
"breakingFields": [
"routeId",
"path",
"layoutGroup",
"paramsSchema",
"searchSchema",
"access",
"chunkId"
]
},
{
"registryId": "FE-REG-ROUTE-RUNTIME",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "ROUTE_RUNTIME_CONTRACT",
"owner": "feature-frontend-routing-release-recovery-runtime",
"keyField": "routeId",
"requiredFields": [
"routeId",
"moduleId",
"paramsCodec",
"searchCodec"
],
"fieldTypes": {
"routeId": "string",
"moduleId": "string",
"paramsCodec": "string",
"searchCodec": "string"
},
"uniqueFields": ["routeId", "moduleId"],
"references": [
{
"field": "routeId",
"registryId": "FE-REG-ROUTE",
"targetField": "routeId"
},
{
"field": "paramsCodec",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
},
{
"field": "searchCodec",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
}
],
"consumers": [
{
"path": "src/presentation/routes/route-codecs.ts",
"token": "ROUTE_RUNTIME_CONTRACT"
}
],
"breakingFields": [
"routeId",
"moduleId",
"paramsCodec",
"searchCodec"
]
},
{
"registryId": "FE-REG-API",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "API_OPERATIONS",
"owner": "feature-frontend-api-client-response-envelope-contract",
"keyField": "operationId",
"requiredFields": [
"method",
"path",
"operationId",
"auth",
"timeoutMs",
"idempotency",
"retry",
"requestSource",
"requestSchema",
"responseSchema",
"owner"
],
"fieldTypes": {
"method": "string",
"path": "string",
"operationId": "string",
"auth": "string",
"timeoutMs": "integer|null",
"idempotency": "string",
"retry": "string",
"requestSource": "string",
"requestSchema": "string",
"responseSchema": "string",
"owner": "string"
},
"uniqueFields": ["operationId"],
"allowedValues": {
"method": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"auth": ["none", "external-session"],
"idempotency": ["safe", "keyed", "none"],
"retry": ["runtime", "never"],
"requestSource": ["none", "search", "body"]
},
"references": [
{
"field": "requestSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
},
{
"field": "responseSchema",
"registryId": "FE-REG-SCHEMA",
"targetField": "schemaId"
}
],
"consumerIdentityField": "operationId",
"consumerDirectories": [
"src/features/reference-feature/adapters",
"src/features/reference-feature/application"
],
"breakingFields": [
"method",
"path",
"operationId",
"auth",
"idempotency",
"requestSource",
"requestSchema",
"responseSchema"
]
},
{
"registryId": "FE-REG-SCHEMA",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "SCHEMA_REGISTRY",
"owner": "feature-frontend-contract-schema-registry",
"keyField": "schemaId",
"requiredFields": ["schemaId", "boundary", "owner", "runtime"],
"fieldTypes": {
"schemaId": "string",
"boundary": "string",
"owner": "string",
"runtime": "string"
},
"uniqueFields": ["schemaId"],
"allowedValues": {
"boundary": [
"route-params",
"route-search",
"route-search-api-request",
"api-request",
"api-response"
],
"runtime": ["zod"]
},
"consumerIdentityField": "schemaId",
"consumerDirectories": [
"src/presentation/routes",
"src/features/tech-log/presentation",
"src/features/reference-feature/contracts"
],
"breakingFields": ["schemaId", "boundary", "runtime"]
},
{
"registryId": "FE-REG-ENV",
"path": "src/contracts/env.ts",
"exportName": "ENV_REGISTRY",
"owner": "feature-frontend-env-runtime-config-contract",
"requiredFields": ["phase", "classification", "required", "defaultValue"],
"fieldTypes": {
"phase": "string",
"classification": "string",
"required": "boolean",
"defaultValue": "string|integer|boolean|null"
},
"allowedValues": {
"phase": ["build", "runtime"],
"classification": [
"public",
"public-sensitive",
"public-metadata",
"compile-time"
]
},
"consumers": [
{
"path": "src/bootstrap/runtime-config-schema.ts",
"token": "APP_ENV"
},
{
"path": "src/contracts/env.ts",
"token": "getBuildConfig"
}
],
"breakingFields": ["phase", "classification", "required"]
},
{
"registryId": "FE-REG-STORAGE",
"path": "src/contracts/storage-keys.ts",
"exportName": "STORAGE_REGISTRY",
"owner": "feature-frontend-storage-registry-contract",
"keyField": "logicalName",
"requiredFields": [
"logicalName",
"physicalKey",
"backend",
"classification",
"schemaVersion",
"valueCodec",
"ttl",
"migration",
"quotaFallback"
],
"fieldTypes": {
"logicalName": "string",
"physicalKey": "string",
"backend": "string",
"classification": "string",
"schemaVersion": "integer",
"valueCodec": "string",
"ttl": "integer|string|null",
"migration": "string",
"quotaFallback": "string"
},
"uniqueFields": ["logicalName", "physicalKey"],
"allowedValues": {
"backend": [
"memory",
"sessionStorage",
"localStorage",
"disabled",
"forbidden"
],
"classification": [
"public-preference",
"opaque-cache",
"sensitive-forbidden"
],
"valueCodec": [
"color-scheme-v1",
"opaque-string-v1",
"none"
],
"migration": ["discard"],
"quotaFallback": ["memory", "no-persist", "feature-disable"]
},
"consumerIdentityField": "logicalName",
"consumerDirectories": ["src", "tests"],
"orphanExemptRows": ["QUERY_PERSISTENCE", "AUTH_TOKEN"],
"breakingFields": [
"logicalName",
"physicalKey",
"backend",
"classification",
"schemaVersion",
"valueCodec",
"migration"
]
},
{
"registryId": "FE-REG-ERROR",
"path": "src/contracts/errors.ts",
"exportName": "ERROR_REGISTRY",
"owner": "feature-frontend-error-classification-boundary-contract",
"keyField": "kind",
"requiredFields": [
"kind",
"defaultRetryable",
"severity",
"userMessageKey",
"action",
"telemetryEvent",
"redaction"
],
"fieldTypes": {
"kind": "string",
"defaultRetryable": "boolean",
"severity": "string",
"userMessageKey": "string",
"action": "string",
"telemetryEvent": "string",
"redaction": "array"
},
"uniqueFields": ["kind"],
"allowedValues": {
"severity": ["info", "warning", "error"],
"action": [
"retry",
"reauth",
"navigate",
"reload-once",
"contact-support",
"none"
]
},
"consumers": [
{
"path": "src/adapters/http/client.ts",
"token": "failure("
}
],
"breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"]
},
{
"registryId": "FE-REG-QUERY-INVALIDATION",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "INVALIDATION_REGISTRY",
"rowsPath": "edges",
"rowKeyFields": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
],
"owner": "feature-frontend-server-state-caching-contract",
"requiredFields": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
],
"fieldTypes": {
"topicId": "string",
"namespace.namespaceId": "string",
"namespace.namespaceVersion": "integer"
},
"uniqueFieldSets": [
[
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
]
],
"snapshotProjection": {
"singletonRowKey": "invalidation-graph",
"canonicalArrayKeyFields": {
"topics": ["$value"],
"namespaces": ["namespaceId", "namespaceVersion"],
"edges": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
]
}
},
"consumers": [
{
"path": "src/bootstrap/runtime-adapters.ts",
"token": "indexInvalidationRegistry(INVALIDATION_REGISTRY)"
}
],
"breakingFields": ["topics", "namespaces", "edges"]
},
{
"registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "INVALIDATION_TOPIC_VERSIONS",
"rowKeyFields": ["topicId"],
"owner": "feature-frontend-server-state-caching-contract",
"requiredFields": ["topicId", "topicVersion"],
"fieldTypes": {
"topicId": "string",
"topicVersion": "integer"
},
"uniqueFields": ["topicId"],
"consumers": [
{
"path": "src/bootstrap/runtime-adapters.ts",
"token": "indexInvalidationTopicVersions("
}
],
"breakingFields": ["topicId", "topicVersion"]
},
{
"registryId": "FE-REG-TELEMETRY",
"path": "src/contracts/telemetry.ts",
"exportName": "TELEMETRY_REGISTRY",
"owner": "feature-frontend-diagnostics-telemetry-runtime",
"keyField": "eventName",
"requiredFields": [
"eventName",
"trigger",
"requiredAttributes",
"optionalAttributes",
"forbiddenAttributes",
"sampling",
"delivery"
],
"fieldTypes": {
"eventName": "string",
"trigger": "string",
"requiredAttributes": "array",
"optionalAttributes": "array",
"forbiddenAttributes": "array",
"sampling": "string",
"delivery": "string"
},
"uniqueFields": ["eventName"],
"allowedValues": {
"delivery": ["best-effort"]
},
"consumers": [
{
"path": "scripts/check-diagnostics.ts",
"token": "TELEMETRY_REGISTRY"
}
],
"breakingFields": [
"eventName",
"requiredAttributes",
"forbiddenAttributes",
"delivery"
]
},
{
"registryId": "FE-REG-RELEASE",
"path": "src/contracts/release-tokens.ts",
"exportName": "RELEASE_TOKEN_REGISTRY",
"owner": "feature-frontend-release-cache-rollback-contract",
"keyField": "token",
"requiredFields": ["token", "source", "compatibilityRole"],
"fieldTypes": {
"token": "string",
"source": "string",
"compatibilityRole": "string"
},
"uniqueFields": ["token"],
"consumers": [
{
"path": "src/bootstrap/load-release-manifest.ts",
"token": "assetManifestHash"
}
],
"breakingFields": ["token", "source", "compatibilityRole"]
}
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"surfaces": {
"index": {
"path": "/",
"cacheControl": "no-cache",
"contentTypes": ["text/html"],
"securityHeaders": true
},
"runtimeConfig": {
"path": "/config.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"releaseManifest": {
"path": "/release-manifest.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"hashedAsset": {
"pathPattern": "/assets/*",
"cacheControl": "public, max-age=31536000, immutable",
"contentTypes": ["text/javascript", "application/javascript"],
"securityHeaders": false
},
"sourceMap": {
"public": false
},
"serviceWorker": {
"enabled": false
}
}
}
@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"responses": {
"index": {
"cache-control": "no-cache",
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"runtimeConfig": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"releaseManifest": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"hashedAsset": {
"cache-control": "public, max-age=31536000, immutable",
"content-type": "text/javascript; charset=utf-8"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"headers": {
"Content-Security-Policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"bundle": {
"initialJsGzipBytes": 204800,
"lazyChunkGzipBytes": 122880
},
"lab": {
"lcpMs": 2500,
"cls": 0.1,
"namedInteractionMs": 200
},
"field": {
"p75LcpMs": 2500,
"p75Cls": 0.1,
"p75InpMs": 200,
"minimumEligibleSamples": null
}
}
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"releaseId": "local-release",
"environment": "replace-with-production",
"source": {
"system": "",
"exportId": ""
},
"privacy": {
"approved": false,
"approvalRef": ""
},
"window": {
"start": "2026-06-01T00:00:00Z",
"end": "2026-06-29T00:00:00Z"
},
"thresholdDecision": {
"status": "pending",
"minimumEligibleSamples": null,
"owner": "",
"reviewedAt": "",
"evidenceRef": ""
},
"samples": []
}
@@ -0,0 +1,340 @@
{
"$schema": "../../schemas/config/frontend-capability-recipes.schema.json",
"schemaVersion": 1,
"decisionId": "VD-10",
"defaultStatus": "NOT_INSTALLED",
"productionRuntimeDependencies": [],
"catalogOwner": "frontend-platform",
"reviewOn": "project-capability-selection",
"vendorPackagePatterns": [
"@launchdarkly/*",
"@sentry/*",
"@opentelemetry/*",
"@openapitools/openapi-generator-cli",
"@reduxjs/toolkit",
"@tanstack/react-virtual",
"@uppy/*",
"firebase",
"idb",
"react-window",
"redux",
"socket.io-client",
"tus-js-client",
"workbox-window",
"xstate",
"zustand"
],
"recipes": [
{
"id": "realtime",
"status": "RECIPE_AVAILABLE",
"referenceRuntime": {
"status": "AVAILABLE_NOT_COMPOSED",
"coveredCapabilities": [
"transport-independent event authority and recovery",
"bounded reconnect ownership",
"fetch-stream SSE",
"bounded polling",
"single-writer live and polling handoff",
"WebSocket closed protocol",
"Web Push window and Service Worker control"
],
"sourceRoots": [
"src/application/ports/realtime",
"src/application/ports/out/web-push-control.ts",
"src/application/policies/bounded-polling.ts",
"src/contracts/realtime-events.ts",
"src/contracts/realtime-streams.ts",
"src/contracts/web-push.ts",
"src/adapters/realtime",
"src/adapters/web-push"
],
"conformanceScripts": [
"test:unit",
"check:realtime-boundaries",
"check:realtime-boundaries:fixture",
"check:optional-recipes",
"test:realtime-removal"
],
"productionComposition": false
},
"trigger": "The backend exposes ordered push events with a documented resume and authorization protocol.",
"forbiddenWhen": ["Polling satisfies the measured freshness requirement.", "Event ordering and reconnect ownership are undefined."],
"boundary": "transport-independent event authority plus separately owned SSE, WebSocket, bounded polling and Web Push adapters",
"port": "RealtimeEventAuthority / WebPushControlPort / transport-specific connection and polling factories",
"fake": "Deterministic event authority, transport facade, clock, repository and Service Worker test doubles",
"failureKinds": ["abort", "disconnect-or-timeout", "protocol-or-mapping-mismatch", "duplicate-or-stale", "sequence-gap-or-cursor-expiry", "queue-overflow", "scope-fenced", "poll-budget-exhausted", "push-permission-or-subscription-failure"],
"lifecycleMethods": ["close-or-dispose", "unsubscribe", "cancel-via-AbortSignal", "bounded-poll-lease", "revoke-push-association"],
"owner": "project-owner-required",
"securityPrivacy": ["Validate every event envelope and closed transport frame before application effects.", "Bind stream state to the current opaque scope generation and advance checkpoints only after committed effects or authoritative recovery.", "Use fixed same-origin endpoints and an exact WebSocket subprotocol; never place credentials, cursors, subscription material or scope bindings in URLs or telemetry.", "Treat Web Push as a notification hint, fence registration and revocation with one durable compare-and-swap control record, and allow only registry-owned notification and route intents.", "Keep every queue, parser, reconnect, poll, notification and storage operation bounded and abortable."],
"bundleBudgetGzipBytes": 40000,
"fallback": "Bounded polling or explicitly stale UI.",
"removal": ["Disable admission and close active readers, sockets, poll leases and worker handlers.", "Revoke and purge only the owned Web Push association and notification state.", "Remove composition, registries, adapters and any selected vendor dependency.", "Run realtime boundary, runtime-removal and production-bundle gates."],
"serverStatePolicy": "query-cache-owned"
},
{
"id": "offline-indexeddb",
"status": "RECIPE_AVAILABLE",
"referenceRuntime": {
"status": "AVAILABLE_NOT_COMPOSED",
"coveredCapabilities": [
"IndexedDB",
"OPFS",
"StorageManager estimate/persistence"
],
"sourceRoots": [
"src/application/ports/browser-file-storage/indexeddb-port.ts",
"src/application/ports/browser-file-storage/opfs-ports.ts",
"src/application/ports/browser-file-storage/storage-durability-port.ts",
"src/adapters/browser-file-storage",
"src/adapters/storage/indexeddb",
"src/adapters/storage/opfs"
],
"conformanceScripts": [
"test:unit",
"test:browser-capabilities",
"verify:browser-capability-evidence",
"check:browser-file-storage-boundaries",
"check:optional-recipes",
"test:browser-file-storage-removal"
],
"productionComposition": false
},
"trigger": "A product requirement needs indexed offline records, an unsynced command queue, or a large local binary sidecar beyond small public preferences.",
"forbiddenWhen": ["The data contains credentials.", "The browser would connect directly to a server database or object store.", "A normal HTTP cache is sufficient.", "Partition, retention, quota and recovery ownership are undefined."],
"boundary": "feature-specific async repository with registry-issued opaque dataset scope and immutable full-policy binding, plus an OPFS large-object sidecar whose logical commit authority and bidirectional scope binding are owned by an IndexedDB journal",
"port": "IndexedDbRepositoryPort / IndexedDbMaintenancePort / DurableObjectStorePort / DurableObjectMaintenancePort / StorageDurabilityPort",
"fake": "MemoryStructuredOfflineStore / MemoryDurableObjectStore / MemoryStorageDurabilityAdapter",
"failureKinds": ["open-blocked", "versionchange", "quota", "corruption", "migration-rollback", "revision-conflict", "storage-eviction", "partial-object-write", "dataset-binding-mismatch", "dataset-budget-exceeded", "lifecycle-authorization-denied", "expired-resource"],
"lifecycleMethods": ["close", "cancel-via-AbortSignal", "enforce-bounded-lifecycle-batch", "prune-expired-receipts", "reconcile", "enforcePolicies-with-composition-authority"],
"owner": "project-owner-required",
"securityPrivacy": ["Classify every persisted field and binary namespace.", "Encrypting in the same client is not a credential protection boundary.", "Keep database schema and record codec versions separate.", "Derive the physical IndexedDB name only from registry-issued authority, namespace and partition tokens; readable namespace, business and account IDs are forbidden.", "Persist and revalidate an immutable scope plus full BrowserStoragePolicy binding during upgrade, post-open and maintenance; missing or mismatched existing bindings fail closed.", "Use the actual IndexedDB wire split: StoredRecord contains only key, codecVersion, revision and payload; writtenAtEpochMs, synchronization, measuredBytes and eligibleAtEpochMs belong to the retention sidecar, while idempotency receipts and governance binding/budget use separate stores.", "Include every store registered in lifecycleMetadataStores in bounded full-partition purge while retaining immutable governance identity.", "Measure conservative logical bytes through the codec and atomically enforce dataset usedBytes plus receiptCount with record, lifecycle and migration writes.", "Enforce TTL before sweep visibility, delete UNTIL_SYNCED only after explicit confirmation, and require a composition-authorized short-lived proof for every deleting IndexedDB lifecycle batch.", "Bound idempotency receipt retention to 31 days and configured count to the implementation ceiling of 1000000; bound migration to old-writer-drained batches no larger than 500 rows or 30000ms.", "Bind OPFS readable and physical scopes in both directions and use only /ca-frontend-opfs-v1/authorities/<authorityToken>/<namespaceToken>/<partitionToken>/ for physical dataset layout.", "For OPFS LOGOUT, UNTIL_SYNCED and ACCOUNT_DELETION maintenance, composition must provide both requestMaintenanceAuthority and consumeMaintenanceAuthority; issue a fresh proof bound to the exact frozen reason, scope and policy for at most five minutes, then atomically consume it to reject replay.", "Never expose an OPFS authority proof through the application request, persistence, diagnostics or telemetry.", "Never place user file names or identifiers in OPFS paths or diagnostics."],
"bundleBudgetGzipBytes": 36000,
"fallback": "Read-only or online-only query path; OPFS may degrade to a size-capped IndexedDB Blob only when the product policy approves it.",
"removal": ["Stop writes and background migration.", "Reconcile or export unsynced data, then purge only governance-bound owned partitions and OPFS namespaces through authorized bounded lifecycle operations.", "Close all database, channel, worker and file handles.", "Remove repository composition and dependency."],
"serverStatePolicy": "reference-or-command-only"
},
{
"id": "service-worker-pwa",
"status": "RECIPE_AVAILABLE",
"referenceRuntime": {
"status": "AVAILABLE_NOT_COMPOSED",
"coveredCapabilities": [
"Cache Storage public-response administration"
],
"sourceRoots": [
"src/application/ports/browser-file-storage/cache-storage-ports.ts",
"src/adapters/browser-file-storage",
"src/adapters/cache-storage"
],
"conformanceScripts": [
"test:unit",
"test:browser-capabilities",
"verify:browser-capability-evidence",
"check:browser-file-storage-boundaries",
"check:optional-recipes",
"test:browser-file-storage-removal"
],
"productionComposition": false
},
"trigger": "Installability, a measured offline-shell requirement, or an explicitly owned public HTTP representation cache is approved.",
"forbiddenWhen": ["Hosting cache and worker cache ownership conflict.", "Update and rollback UX is undefined.", "Authenticated, private, opaque or personal responses would be cached.", "Cache freshness, byte and entry limits are undefined."],
"boundary": "bootstrap update controller plus platform-local public Request/Response cache administration",
"port": "ServiceWorkerUpdatePort / PublicResponseCacheAdmin recipe / PublicResponseCachePort / PublicResponseCacheAdminPort reference runtime",
"fake": "FakeServiceWorkerUpdateAdapter / MemoryPublicResponseCache",
"failureKinds": ["stale-worker", "update-loop", "offline-fallback", "incomplete-candidate", "integrity-mismatch", "cache-policy-rejection", "quota"],
"lifecycleMethods": ["unregister", "rollback", "delete-owned-caches"],
"owner": "project-owner-required",
"securityPrivacy": ["Cache only explicit same-origin public GET representations.", "Never cache authenticated, cookie-dependent, private, no-store, opaque or personal responses.", "Bind candidate cache names to release identity and a canonical manifest digest that includes normalized expectedContentType, exact request identity, expected byte length and integrity digest.", "Reject a response whose normalized Content-Type differs from manifest expectedContentType even when body integrity matches.", "Derive cleanup retention only from the verified active pointer and composition retainedPreviousReleaseCount; cleanup callers cannot submit cache names, release registry IDs or any retain set.", "Read active-pointer and release-marker control JSON through a strict UTF-8 stream capped at exactly 2 MiB (2097152 bytes), cancel on overflow and fail closed before parsing oversized metadata.", "Keep exact query and Vary semantics; ignoreSearch and ignoreVary are forbidden.", "Fail closed on malformed update metadata or integrity mismatch."],
"bundleBudgetGzipBytes": 10000,
"fallback": "Normal network application with hosting cache headers.",
"removal": ["Deploy an unregister migration.", "Delete only parsed, owned cache namespaces after old controlled clients drain.", "Remove worker registration, cache metadata and manifest."],
"serverStatePolicy": "network-cache-policy-only"
},
{
"id": "file-transfer",
"status": "RECIPE_AVAILABLE",
"referenceRuntime": {
"status": "AVAILABLE_NOT_COMPOSED",
"coveredCapabilities": [
"File",
"Blob",
"native file input",
"system file picker",
"object URL preview",
"download delivery",
"presigned URL capability",
"bounded streaming download",
"multipart/resumable upload",
"durable non-secret upload checkpoint",
"Image CDN responsive delivery"
],
"sourceRoots": [
"src/application/ports/browser-file-storage/file.ts",
"src/application/ports/browser-transfer",
"src/adapters/browser-file-storage",
"src/adapters/browser-files",
"src/adapters/browser-transfer"
],
"conformanceScripts": [
"test:unit",
"test:browser-capabilities",
"verify:browser-capability-evidence",
"check:browser-file-storage-boundaries",
"check:optional-recipes",
"test:browser-file-storage-removal"
],
"productionComposition": false
},
"trigger": "The product selects, inspects, previews, uploads, downloads or delivers image renditions with bounded memory, resumability, cancellation, expiry and integrity requirements.",
"forbiddenWhen": ["Allowed count, byte, extension, MIME and content-signature policy is missing.", "The BFF does not own authorization, short-lived capability issuance, upload session reconciliation, quarantine and orphan cleanup.", "Long-lived credentials, presigned URLs or signed headers would enter persistence, application state or telemetry.", "Native File, Blob, object URL or file-system handles would cross into domain state or persistence.", "Large downloads would be returned as one in-memory byte array or Blob.", "Image callers could submit arbitrary CDN source URLs or transform parameters."],
"boundary": "BFF-owned transfer control plane plus adapter-owned browser/object-storage data plane; presentation receives opaque file/image references, registered policies and bounded result streams only",
"port": "FilePickerPort / FileContentPort / TransientPreviewPort / DownloadDeliveryPort / PresignedDownloadSourcePort / PresignedUploadPartPort / ResumableUploadPort / ImageCdnPresentationPort",
"fake": "Memory file/preview/download adapters plus injected deterministic capability, upload-control-plane, part-executor and image-verifier test doubles",
"failureKinds": ["dismissed", "permission-denied", "count-or-size-rejection", "type-or-signature-rejection", "file-changed", "abort", "integrity-failure", "partial-save", "expired-or-revoked-capability", "part-or-session-conflict", "checkpoint-conflict", "quarantined", "image-policy-rejection"],
"lifecycleMethods": ["release-file-ref", "release-or-dispose-preview-leases", "cancel-via-AbortSignal", "reconcile-or-explicitly-abort-upload", "close-checkpoint-store", "dispose-capability-and-image-runtime"],
"owner": "project-owner-required",
"securityPrivacy": ["Treat file name, extension, MIME and lastModified as untrusted metadata.", "Resolve only exact composition-issued file and image policy object identities; callers cannot raise byte, candidate, pixel, quality, format, lifetime or origin ceilings.", "Use opaque file references and verification receipts bound to an inspected immutable file snapshot and the exact registered profile; reject replay through another profile even when an inspection rule ID matches.", "Treat presigned URLs as bearer capabilities; bind exact method, resource or upload part, offset, length, media type, checksum, origin, path, query, headers and expiry in an in-memory identity vault.", "Use credentials omit, redirect error, no-referrer and no-store for direct data-plane fetch; never persist or observe URL, query, signed header, capability, file name or raw backend message, and never emit digest, raw ETag or receipt values to diagnostics or telemetry.", "A strict account-partitioned upload checkpoint may persist only the protocol-defined SHA-256 file fingerprint, per-part checksum and bounded opaque non-authorizing part receipt token required for server reconciliation; no bearer token or raw signed capability is allowed.", "Persist only strict non-authorizing upload checkpoints and reconcile them with server-authoritative status and re-hashed local parts before completion.", "Require a synchronous server-issued browser-managed download capability whose receipt exactly equals the caller's branded capability receipt and whose resource, media type, safe extension, maximum bytes, optional digest and expiry all match before handoff.", "Expose File, OPFS, Cache and transfer byte streams only as chunk-level closed Results; stop after the first failure, cancel native readers and never throw a raw native exception across the port.", "Accept Image CDN assets only through immutable allowlisted or signature-verified descriptors and registered preset identities; reject active formats, arbitrary transforms, pixel/decode-budget overflow and unsafe cache policy.", "Upload completion remains QUARANTINED until backend scan and promotion; client capability checks are not an authorization boundary.", "Active content preview requires isolation or download-only treatment."],
"bundleBudgetGzipBytes": 54600,
"fallback": "Accessible native file input, same-origin authorized server upload/download and a single bounded server-selected image rendition; generated artifacts above the buffer budget move to server-side generation.",
"removal": ["Stop new capability and upload-session issuance, then cancel active reads and transfers.", "Reconcile or explicitly abort active multipart sessions and let backend TTL cleanup remove ambiguous orphans.", "Remove non-secret checkpoints according to account and retention policy.", "Release file references, revoke preview object-URL leases and dispose file, capability and image runtimes.", "Remove transfer/image feature facades and composition, then prove browser-transfer sources are absent from the production module inventory."],
"serverStatePolicy": "query-cache-metadata-only"
},
{
"id": "generated-api",
"status": "RECIPE_AVAILABLE",
"trigger": "A versioned backend contract justifies generated transport code.",
"forbiddenWhen": ["Generated DTOs would escape into domain or presentation.", "Contract drift cannot block CI."],
"boundary": "generated client wrapped by a feature gateway facade and mapper",
"port": "GeneratedApiFacade",
"fake": "FakeGeneratedApiAdapter",
"failureKinds": ["contract-drift", "unsupported-field"],
"lifecycleMethods": ["cancel-via-AbortSignal"],
"owner": "project-owner-required",
"securityPrivacy": ["Generate from an authenticated source.", "Review generator execution and output.", "Do not log request bodies."],
"bundleBudgetGzipBytes": 16000,
"fallback": "Existing typed request builder and runtime response schema.",
"removal": ["Restore handwritten gateway.", "Remove generated output and generator.", "Verify DTOs do not remain in public types."],
"serverStatePolicy": "query-cache-owned"
},
{
"id": "feature-flag",
"status": "RECIPE_AVAILABLE",
"trigger": "A staged rollout or kill switch has a named owner, default and stale policy.",
"forbiddenWhen": ["A flag is used as authorization.", "Unknown and unavailable behavior is undefined."],
"boundary": "application feature policy output port",
"port": "FeatureFlagPort",
"fake": "FakeFeatureFlagAdapter",
"failureKinds": ["provider-unavailable", "unknown-flag", "stale-value"],
"lifecycleMethods": ["dispose-provider-if-installed"],
"owner": "project-owner-required",
"securityPrivacy": ["Flags are hints, never access control.", "Minimize targeting attributes.", "Apply consent rules to personal attributes."],
"bundleBudgetGzipBytes": 10000,
"fallback": "Typed local default with an explicit stale decision.",
"removal": ["Resolve the rollout permanently.", "Delete flag key and branches.", "Remove provider composition and dependency."],
"serverStatePolicy": "policy-cache-only"
},
{
"id": "web-worker",
"status": "RECIPE_AVAILABLE",
"trigger": "Profiling shows CPU work blocking the main thread beyond the performance budget.",
"forbiddenWhen": ["The task is primarily network I/O.", "Cancellation and stale-result ownership are undefined."],
"boundary": "request/result/cancel output port with a validated message adapter",
"port": "WorkerTaskPort",
"fake": "FakeWorkerTaskAdapter",
"failureKinds": ["crash", "stale-result", "transfer-failure"],
"lifecycleMethods": ["cancel", "dispose"],
"owner": "project-owner-required",
"securityPrivacy": ["Validate worker messages.", "Do not send credentials.", "Bound transferred data and worker count."],
"bundleBudgetGzipBytes": 14000,
"fallback": "Chunked or deferred main-thread execution within a measured limit.",
"removal": ["Stop and dispose workers.", "Restore synchronous facade implementation.", "Remove worker entry and chunk."],
"serverStatePolicy": "no-server-state"
},
{
"id": "multi-tab",
"status": "RECIPE_AVAILABLE",
"trigger": "A documented workflow must synchronize non-sensitive events across tabs.",
"forbiddenWhen": ["The server is the correct conflict authority.", "Event version and source identity are undefined."],
"boundary": "versioned browser event output/input adapter",
"port": "MultiTabPort",
"fake": "FakeMultiTabAdapter",
"failureKinds": ["self-echo", "duplicate", "conflict"],
"lifecycleMethods": ["unsubscribe", "close"],
"owner": "project-owner-required",
"securityPrivacy": ["Broadcast no credentials or personal payload.", "Validate versions.", "Treat events as hints rather than authorization."],
"bundleBudgetGzipBytes": 4000,
"fallback": "Refresh from the authoritative server on focus.",
"removal": ["Close channels.", "Remove event registry entries.", "Restore focus-based refresh."],
"serverStatePolicy": "invalidation-only"
},
{
"id": "browser-permission",
"status": "RECIPE_AVAILABLE",
"trigger": "A user-initiated flow requires clipboard, notification or media access.",
"forbiddenWhen": ["Permission would be requested at boot.", "Denied, dismissed and unsupported UX are not designed."],
"boundary": "presentation input action through a browser capability output port",
"port": "BrowserPermissionPort",
"fake": "FakeBrowserPermissionAdapter",
"failureKinds": ["denied", "dismissed", "unsupported"],
"lifecycleMethods": ["stop-media-tracks-if-opened"],
"owner": "project-owner-required",
"securityPrivacy": ["Require an explicit user gesture.", "Minimize requested scope.", "Do not persist permission as authorization."],
"bundleBudgetGzipBytes": 3000,
"fallback": "Manual input or copy/download instruction.",
"removal": ["Stop acquired resources.", "Remove permission action and adapter.", "Retest denied-path accessibility."],
"serverStatePolicy": "no-server-state"
},
{
"id": "client-workflow",
"status": "RECIPE_AVAILABLE",
"trigger": "A measured cross-page client-only workflow cannot be represented by URL, local state, context or query cache.",
"forbiddenWhen": ["The store would duplicate server response collections.", "A library is selected before state ownership is documented.", "Zustand and Redux Toolkit would both be installed."],
"boundary": "workflow-specific local facade; vendor types remain in its adapter",
"port": "ClientWorkflowPort",
"fake": "FakeClientWorkflowAdapter",
"failureKinds": ["reset", "version-mismatch", "server-state-duplication"],
"lifecycleMethods": ["unsubscribe", "reset"],
"owner": "project-owner-required",
"securityPrivacy": ["Persist only explicitly classified workflow fields.", "Never persist credentials.", "Define logout and version reset."],
"bundleBudgetGzipBytes": 9000,
"fallback": "URL, component state, context and TanStack Query ownership.",
"removal": ["Move remaining state to its natural owner.", "Remove facade and one selected store dependency.", "Verify logout/reset."],
"serverStatePolicy": "reference-only"
},
{
"id": "large-data-ui",
"status": "RECIPE_AVAILABLE",
"trigger": "Production-like profiling proves a list or grid exceeds interaction and rendering budgets.",
"forbiddenWhen": ["Pagination solves the scale requirement.", "Keyboard and screen-reader focus behavior is undefined."],
"boundary": "presentation facade around virtualizer or data-grid behavior",
"port": "LargeDataUiFacade",
"fake": "FakeLargeDataUiAdapter",
"failureKinds": ["focus-loss", "stale-row", "scale-limit"],
"lifecycleMethods": ["dispose-observers-if-installed"],
"owner": "project-owner-required",
"securityPrivacy": ["Render only authorized rows.", "Do not expose hidden row data to telemetry.", "Preserve accessible row identity."],
"bundleBudgetGzipBytes": 30000,
"fallback": "Accessible pagination and bounded result sets.",
"removal": ["Restore paginated primitive.", "Remove facade adapter and dependency.", "Run keyboard and performance evidence."],
"serverStatePolicy": "query-cache-owned"
},
{
"id": "analytics-error-sink",
"status": "RECIPE_AVAILABLE",
"trigger": "A production provider, consent policy, retention owner and event registry are approved.",
"forbiddenWhen": ["Consent and essential diagnostics are not separated.", "Arbitrary message or attribute keys can bypass redaction."],
"boundary": "closed diagnostics/analytics port with provider adapter",
"port": "AnalyticsErrorSink",
"fake": "RecordingAnalyticsAdapter",
"failureKinds": ["consent-denied", "queue-full", "provider-unavailable"],
"lifecycleMethods": ["flush", "dispose"],
"owner": "project-owner-required",
"securityPrivacy": ["Allowlist events and attributes.", "Redact before queueing.", "Apply consent, sampling and retention policy."],
"bundleBudgetGzipBytes": 25000,
"fallback": "Existing bounded local diagnostics and best-effort telemetry port.",
"removal": ["Disable provider delivery.", "Flush or discard by policy.", "Remove adapter, runtime config and dependency."],
"serverStatePolicy": "no-server-state"
}
]
}
+77
View File
@@ -0,0 +1,77 @@
{
"schemaVersion": 1,
"fixtures": [
{
"name": "coherent-release",
"expectedCompatible": true,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "1.1",
"apiContractVersion": "1.2",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
},
{
"name": "mixed-html-and-assets",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-b",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-b",
"releaseId": "release-b"
}
},
{
"name": "incompatible-runtime-config",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "2.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
},
{
"name": "incompatible-api-contract",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "2.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"schemaVersion": 1,
"runbooks": {
"FE-RB-001": {
"title": "Boot configuration failure",
"gateId": "FE-GATE-021",
"triggerKinds": ["BOOT_CONFIG_FAILURE"],
"containment": "stop product route mount, show the safe support shell, and refetch at most once",
"window": "owner triage planned-default 5m",
"escalation": ["env-config owner", "release owner"],
"recoveryEvidence": [
"clean-session boot",
"product root mount",
"config validation",
"no repeated boot error"
],
"negativeFixture": "a valid config followed by an injected mount failure must fail recovery"
},
"FE-RB-002": {
"title": "Chunk, manifest, or deployment mismatch",
"gateId": "FE-GATE-022",
"triggerKinds": [
"CHUNK_LOAD_FAILURE",
"RELEASE_MANIFEST_FAILURE",
"DEPLOY_MISMATCH"
],
"containment": "warn for dirty state, fetch manifest no-store once, and allow one guarded reload",
"window": "release owner triage planned-default 5m",
"escalation": ["release-cache owner", "hosting/CDN owner"],
"recoveryEvidence": [
"entry and lazy assets reachable",
"release tuple coherent",
"second reload blocked",
"critical route smoke"
],
"negativeFixture": "a second failure for the same release pair must not reload"
},
"FE-RB-003": {
"title": "Backend API degradation",
"gateId": "FE-GATE-023",
"triggerKinds": [
"TERMINAL_NETWORK_RATE",
"REQUEST_TIMEOUT_RATE",
"SERVER_FAILURE_RATE",
"SCHEMA_MISMATCH"
],
"containment": "do not expand retry caps, serve safe stale reads, and never retry an unkeyed mutation",
"window": "rolling 5m trigger; first classification planned-default 10m",
"escalation": [
"api-client owner",
"backend operation owner",
"release compatibility owner"
],
"recoveryEvidence": [
"terminal failure rate at baseline",
"no retry amplification",
"critical read/write smoke",
"schema fixtures"
],
"negativeFixture": "an unkeyed POST receiving 503 must not retry"
},
"FE-RB-004": {
"title": "Telemetry sink failure",
"gateId": "FE-GATE-024",
"triggerKinds": ["TELEMETRY_FAILURE"],
"containment": "keep product flow available, bound the queue, and never report recursively to the failing sink",
"window": "platform triage planned-default 15m",
"escalation": ["observability owner", "telemetry platform owner"],
"recoveryEvidence": [
"product flow unaffected",
"delivery self-check",
"queue drained within bound",
"forbidden attributes absent"
],
"negativeFixture": "raw URL and query data must be removed from telemetry"
},
"FE-RB-005": {
"title": "Coherent release rollback",
"gateId": "FE-GATE-025",
"triggerKinds": ["RELEASE_BLOCKING_DEFECT"],
"containment": "select a prior immutable tuple, verify asset/config/API compatibility, atomically switch, and smoke",
"window": "provider recovery target TBD",
"escalation": ["release-cache owner", "release approver/hosting owner"],
"recoveryEvidence": [
"compatibility gate",
"release coherence gate",
"critical smoke",
"release ID in incident timeline"
],
"negativeFixture": "HTML build A with asset manifest B must be rejected"
}
}
}
+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"
}
}
@@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-FIELD-WEB-VITALS@1",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"window",
"context",
"metrics",
"thresholds",
"eligibility",
"status",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"window": { "type": "object", "required": ["days", "start", "end"] },
"context": {
"type": "object",
"required": [
"source",
"sourceSystem",
"exportId",
"network",
"routeAggregation",
"releaseId",
"privacyApprovalRef",
"thresholdDecisionRef",
"validationFailures"
],
"properties": {
"source": { "type": "string" },
"sourceSystem": { "type": ["string", "null"] },
"exportId": { "type": ["string", "null"] },
"network": { "const": "production-real-user" },
"routeAggregation": { "const": "route-id-only" },
"releaseId": { "type": ["string", "null"] },
"privacyApprovalRef": { "type": ["string", "null"] },
"thresholdDecisionRef": { "type": ["string", "null"] },
"validationFailures": {
"type": "array",
"items": { "type": "string" }
}
},
"additionalProperties": false
},
"thresholds": {
"type": "object",
"required": [
"p75LcpMs",
"p75Cls",
"p75InpMs",
"minimumEligibleSamples"
]
},
"metrics": {
"type": "object",
"required": ["p75LcpMs", "p75Cls", "p75InpMs"]
},
"eligibility": {
"type": "object",
"required": [
"consentRequired",
"totalSamples",
"eligibleSamples",
"minimumEligibleSamples",
"routeSamples"
]
},
"status": {
"enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]
},
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-LAB@1",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"context",
"metrics",
"thresholds",
"fixtures",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"context": {
"type": "object",
"required": ["runner", "browser", "viewport", "network", "cpu", "cache", "build"]
},
"metrics": {
"type": "object",
"required": ["lcpMs", "cls", "namedInteractionMs"]
},
"thresholds": { "type": "object" },
"fixtures": { "type": "array", "minItems": 2 },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-003@1",
"type": "object",
"required": ["schemaVersion", "generatedAt", "artifact", "fixtures", "passed"],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"artifact": {
"type": "object",
"required": ["checked", "compatible", "mismatches"]
},
"fixtures": { "type": "array", "minItems": 2 },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-RUNBOOK-DRILL@1",
"type": "object",
"required": [
"schemaVersion",
"runbookId",
"releaseId",
"drillTimestamp",
"triggerInjected",
"triggerAsserted",
"containmentAsserted",
"escalationPathAsserted",
"recoveryAssertions",
"negativeFixtureFailedAsExpected",
"windowObservedBucket",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"runbookId": { "pattern": "^FE-RB-00[1-5]$" },
"releaseId": { "type": "string", "minLength": 1 },
"drillTimestamp": { "type": "string", "format": "date-time" },
"triggerInjected": { "type": "string" },
"triggerAsserted": { "type": "boolean" },
"containmentAsserted": { "type": "boolean" },
"escalationPathAsserted": { "type": "boolean" },
"recoveryAssertions": {
"type": "array",
"minItems": 4,
"items": {
"type": "object",
"required": ["assertion", "evidence", "passed"]
}
},
"negativeFixtureFailedAsExpected": { "type": "boolean" },
"windowObservedBucket": { "type": "string" },
"providerVerificationRequired": { "type": "boolean" },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"snapshotDigest": "ce4fa9b7944f27553067228bd6c9e73e7dc05875c283255b50d7eb3ad2923f6d",
"owner": "frontend-platform",
"reason": "RP-11-initial-transitive-inventory",
"approvedAt": "2026-07-26T08:27:17.874Z"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
{
"schemaVersion": 1,
"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."
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"allowedLicenses": [
"(MIT OR CC0-1.0)",
"0BSD",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BlueOak-1.0.0",
"CC-BY-4.0",
"CC0-1.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"OFL-1.1"
],
"deniedLicensePatterns": [
"(^|\\s)AGPL",
"(^|\\s)GPL",
"SSPL",
"BUSL"
],
"unknownLicensePolicy": "allow-only-unmaterialized-platform-optional"
}
+52
View File
@@ -0,0 +1,52 @@
{
"schemaVersion": 1,
"trackedRoots": [
"src",
"recipes",
"scripts",
"tests",
"config",
"public",
"schemas",
".storybook",
".gitea/workflows/quality-gates.yml",
".dependency-cruiser.json",
".nvmrc",
".npmrc",
"eslint.config.ts",
"index.html",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.base.json",
"tsconfig.node.json",
"tsconfig.recipes.json",
"tsconfig.service-worker.json",
"tsconfig.test.json",
"tsconfig.web-worker.json",
"vite.config.ts",
"vite.service-worker.config.ts",
"vitest.config.ts",
"playwright.config.ts",
"playwright.capabilities.config.ts",
"playwright.dev.config.ts",
"playwright.storybook.config.ts",
"playwright.visual.config.ts"
],
"generatedRoots": ["dist", "artifacts/release"],
"optionalRoots": ["artifacts/release"],
"excludedPaths": [
"tests/fixtures/security/secret-detection/forbidden"
],
"allowlist": [
{
"path": "tests/fixtures/security/secret-detection/allowed/test-credentials.ts",
"ruleId": "assigned-secret",
"owner": "frontend-platform",
"reason": "Synthetic credential verifies the scoped test-only allowlist.",
"expiresAt": "2027-07-26T00:00:00.000Z"
}
]
}
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"exceptions": []
}
@@ -0,0 +1,8 @@
{
"schemaVersion": 1,
"providerMode": "external-file",
"inputEnvironment": "VULNERABILITY_REPORT_PATH",
"blockAtSeverity": "high",
"allowedSeverities": ["unknown", "low", "moderate", "high", "critical"],
"missingProviderStatus": "FAIL_UNVERIFIED"
}
+130
View File
@@ -0,0 +1,130 @@
{
"schemaVersion": 2,
"repositoryBaseline": 285,
"generatedPaths": [],
"summary": {
"lines": 75,
"statements": 73,
"functions": 80,
"branches": 68
},
"criticalModules": [
{
"path": "src/adapters/http/bounded-body-reader.ts",
"owner": "http-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 }
},
{
"path": "src/adapters/http/bounded-json.ts",
"owner": "http-runtime",
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 }
},
{
"path": "src/adapters/http/http-execution-v3.ts",
"owner": "http-runtime",
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 }
},
{
"path": "src/adapters/http/request-builder.ts",
"owner": "http-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 }
},
{
"path": "src/adapters/http/retry-policy.ts",
"owner": "http-runtime",
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 }
},
{
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
"owner": "server-state-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 }
},
{
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
"owner": "service-worker-runtime",
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 }
},
{
"path": "src/adapters/storage/browser-storage-adapter.ts",
"owner": "storage-runtime",
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 }
},
{
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
"owner": "telemetry-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 }
},
{
"path": "src/application/create-application.ts",
"owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 }
},
{
"path": "src/application/policies/compatibility.ts",
"owner": "application-policy",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
},
{
"path": "src/application/policies/performance-budgets.ts",
"owner": "application-policy",
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 }
},
{
"path": "src/application/policies/promotion-readiness.ts",
"owner": "release-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 }
},
{
"path": "src/application/use-cases/decide-chunk-recovery.ts",
"owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 }
},
{
"path": "src/bootstrap/load-release-manifest.ts",
"owner": "release-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
},
{
"path": "src/bootstrap/read-bounded-boot-json.ts",
"owner": "bootstrap-runtime",
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 }
},
{
"path": "src/contracts/diagnostics.ts",
"owner": "diagnostics-contracts",
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 }
},
{
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
"owner": "reference-feature",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 }
},
{
"path": "src/presentation/adapters/query/application-query.ts",
"owner": "presentation-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
}
],
"highRiskPaths": [
"src/adapters/http/bounded-body-reader.ts",
"src/adapters/http/bounded-json.ts",
"src/adapters/http/http-execution-v3.ts",
"src/adapters/http/request-builder.ts",
"src/adapters/http/retry-policy.ts",
"src/adapters/query-cache/server-state-scope-runtime.ts",
"src/adapters/service-worker/service-worker-lifecycle.ts",
"src/adapters/storage/browser-storage-adapter.ts",
"src/adapters/telemetry/best-effort-telemetry.ts",
"src/application/create-application.ts",
"src/application/policies/compatibility.ts",
"src/application/policies/performance-budgets.ts",
"src/application/policies/promotion-readiness.ts",
"src/application/use-cases/decide-chunk-recovery.ts",
"src/bootstrap/load-release-manifest.ts",
"src/bootstrap/read-bounded-boot-json.ts",
"src/contracts/diagnostics.ts",
"src/features/reference-feature/adapters/reference-http-gateway.ts",
"src/presentation/adapters/query/application-query.ts"
],
"waivers": []
}
+22
View File
@@ -0,0 +1,22 @@
{
"schemaVersion": 2,
"scenarioCatalogs": [
{
"owner": "reference-feature",
"path": "tests/mocks/scenarios/catalog.ts",
"expectationExport": "HTTP_SCENARIO_EXPECTATIONS",
"receiptPath": "artifacts/tests/http-scenario-executions.json",
"receiptSchemaVersion": 1
}
],
"sourceContracts": [
{
"owner": "reference-feature",
"path": "tests/mocks/handlers/reference-resources.ts",
"requiredTokens": [
"assertOperationScenario",
"../scenarios/catalog.ts"
]
}
]
}
+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:
+67
View File
@@ -0,0 +1,67 @@
# Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must
review all 27 route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. The required TechLog Public and Studio scope is
derived from the installed route registry, so the gate rejects stale, missing,
or additional route records as well as blank identity/timestamp/signature
fields, pending verdicts, and mismatched release IDs. The scope is:
`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:
- `pass`
- `not-applicable (<specific reason>)`
Required record:
```text
Status: reviewed
Route ID: <exact installed route ID>
Release ID: <immutable release ID>
Reviewer: <human reviewer identity>
Reviewed at: <RFC 3339 timestamp>
Signature: <reviewer identity or approved signature reference>
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: <observations and linked defect IDs>
```
The reviewer must verify:
- M1: every action works without a pointing device
- M2: every focused element has a visible indicator
- M3: route transitions move focus to a deterministic target
- M4: modal focus is trapped and restored, when a modal exists
- M5: errors are programmatically associated with their controls, when present
- M6: state never relies on color alone
- M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once
Routes with dialogs or form errors require real M4 modal-focus or M5
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
recorded browser runs.
@@ -0,0 +1,973 @@
# API contract, Schema, Mapper와 Server State platform
> **정본 안내 (non-authoritative for runtime capability decisions)**
>
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
> Service Worker, offline command와 Background Sync의 구현 결정은
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
- 상태: capability별 current/target 분리, production design accepted
- 기준일: 2026-07-28
- 범위: REST, GraphQL over HTTP, Connect-Web/Connect, gRPC-Web,
Protobuf/REST Gateway, runtime Schema, boundary Mapper, TanStack Query 기반
Server State Cache
- 관련 결정:
- [VD-23 API transport selection과 REST execution](./decisions/VD-23-api-transport-selection-and-rest-execution.md)
- [VD-24 Runtime schema와 boundary mapper](./decisions/VD-24-runtime-schema-and-boundary-mapper.md)
- [VD-25 Server state cache lifecycle](./decisions/VD-25-server-state-cache-lifecycle.md)
- [VD-26 Persisted GraphQL operation](./decisions/VD-26-persisted-graphql-operation.md)
- [VD-27 gRPC-Web unary와 server stream](./decisions/VD-27-grpc-web-unary-and-server-stream.md)
- [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md)
- [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md)
- browser Protobuf/gateway 상세 설계:
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
- backend handoff:
[Backend API와 Server State contract](./backend-api-and-server-state-contract.md)
- 운영 절차:
[API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md)
## 1. 목적
이 문서는 다음 질문을 하나의 production 계약으로 닫는다.
- REST, GraphQL, Connect와 gRPC-Web 중 무엇을 어디에 사용하는가
- Protobuf contract와 REST Gateway가 transport/runtime과 어떻게 분리되는가
- request/response가 어느 지점까지 untrusted wire data인가
- TypeScript type, generated code와 runtime validation의 역할은 무엇인가
- DTO를 domain/application projection으로 누가 변환하는가
- server response를 어떤 query identity와 lifecycle로 cache하는가
- schema, mapper, transport와 cache가 바뀔 때 어떻게 배포·관측·rollback하는가
각 protocol은 서로 대체 가능한 URL 호출 문법이 아니다. transport-specific
codec, proxy와 failure semantics는 adapter가 소유한다. application은 transport
종류, URL, GraphQL document, protobuf message나 TanStack Query를 직접 알지 않고
feature-owned gateway와 application input만 호출한다.
## 2. 상태 모델
이 문서는 browser data 설계와 동일한 primary current-status literal을 사용한다.
| primary status | 의미 |
| --- | --- |
| `COMPOSED` | production bootstrap 또는 설치된 feature 호출 경로에 concrete runtime이 실제 연결돼 있다. |
| `AVAILABLE_NOT_COMPOSED` | 실행 가능한 reference runtime과 test가 있지만 production graph에는 연결하지 않았다. |
| `DESIGNED_NOT_IMPLEMENTED` | 계약·불변조건·failure와 promotion 기준은 승인됐지만 해당 runtime 또는 필수 orchestration이 없다. |
| `NOT_SELECTED` | 제품 요구, owner와 비용이 승인되지 않아 의도적으로 선택하지 않았다. |
| `PLATFORM_LIMITED` | target browser/protocol이 요구 semantics를 공통으로 보장하지 못한다. |
primary status와 다음 readiness 축을 섞지 않는다.
```text
Selection
NOT_SELECTED | SELECTED | REMOVING
TrafficAdmission
DISABLED | SHADOW | CANARY | ENABLED
RuntimeHealth
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
PromotionEvidence
MISSING | PARTIAL | COMPLETE | EXPIRED
```
`COMPOSED`는 traffic이 켜졌거나 provider가 conformant라는 뜻이 아니다.
`AVAILABLE_NOT_COMPOSED`도 제품 bundle에 dependency가 들어갔다는 뜻이 아니다.
## 3. 현재 capability ledger
| capability | primary current status | 현재 증거 | 목표 또는 잔여 |
| --- | --- | --- | --- |
| installed REST reference vertical | `COMPOSED` | operation registry → request schema → HTTP → envelope/payload schema → mapper → application input → Query 화면 경로 | 아래 REST hardening delta와 실제 제품 provider 계약 |
| shared REST JSON executor | `COMPOSED` | path/search/body codec projection, shared deadline/retry-sleep budget, AbortSignal, bounded auth recovery, exact envelope/media/status, safe failure와 diagnostics | 204/304/412 execution join과 actual provider conformance |
| REST v2 security/execution baseline | `COMPOSED` | collision-aware operation composition, path placeholder↔codec key exact join, prefix-preserving HTTPS/loopback provider, named bearer/CSRF profile와 credential-mode ceiling, auth fail-before-fetch, bounded JSON, outbound correlation/status·physical-attempt 관측 | cookie-CSRF/CORS provider evidence, 204/304/412 conditional execution과 compatibility artifact |
| GraphQL provider-neutral reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source/dependency/codegen/runtime 없음 | persisted-operation-only transport, GraphQL response decoder, mapper binding과 contract harness |
| product GraphQL composition | `NOT_SELECTED` | endpoint/schema/persisted manifest/owner 없음 | 제품 query가 REST보다 GraphQL aggregation을 정당화할 때 선택 |
| GraphQL batching, subscription, `@defer`/`@stream` | `NOT_SELECTED` | 없음 | 각각 독립 ADR, proxy/browser lifecycle과 cache semantics 필요 |
| provider-neutral Browser RPC V3 contract/runtime | `AVAILABLE_NOT_COMPOSED` | operation/profile/schema/mapper/encoder/transport exact join, typed application port, bounded unary retry/deadline/abort, server-stream idle/total/message/terminal/generation fence와 fail-closed unavailable adapter test | selected descriptor/generated client와 protocol-specific bounded transport를 붙이고 actual provider/browser conformance |
| gRPC-Web unary reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source/dependency/generated message 없음 | fixed method registry, protobuf codec, trailers/status/deadline와 proxy conformance |
| gRPC-Web server-stream reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source 없음 | bounded frame/idle/total budget, sequence/resume application protocol과 stream port |
| product gRPC-Web composition | `NOT_SELECTED` | service descriptor/proxy/owner 없음 | browser-facing gRPC-Web gateway가 실제 이점을 줄 때 선택 |
| gRPC-Web client-streaming/bidi guarantee | `PLATFORM_LIMITED` | gRPC-Web browser baseline이 해당 semantics를 제공하지 않음 | REST upload, WebSocket/WebTransport 또는 별도 protocol을 선택 |
| Protobuf schema/codegen governance | `DESIGNED_NOT_IMPLEMENTED` | `.proto`, Buf config, descriptor와 generated output 없음 | authenticated source, immutable descriptor, deterministic codegen과 compatibility evidence |
| Connect-Web unary/server-stream reference adapter | `DESIGNED_NOT_IMPLEMENTED` | `@connectrpc/*`, `@bufbuild/protobuf`, generated service와 provider 없음 | exact Connect/gRPC-Web transport row, bounded decode와 actual browser/provider conformance |
| product Connect protocol composition | `NOT_SELECTED` | service/provider/owner 없음 | Protobuf-first backend의 selected browser operation이 있을 때만 선택 |
| Connect browser client-streaming/bidi guarantee | `PLATFORM_LIMITED` | Connect protocol 기능과 browser request-stream 지원은 다름 | 별도 duplex/application protocol 선택 |
| Protobuf REST Gateway reference contract/harness | `DESIGNED_NOT_IMPLEMENTED` | HttpRule/transcoder/OpenAPI/provider fixture 없음 | selected kind의 deterministic/provider conformance |
| product Protobuf REST Gateway composition | `NOT_SELECTED` | route/provider/owner 없음 | curated BFF, grpc-gateway 또는 Envoy transcoder 중 하나와 public HTTP contract 승인 |
| feature runtime request/response Schema | `COMPOSED` | Zod request/payload schemas와 installed schema registry가 reference feature에 연결 | byte/depth/node ceiling, unknown-field profile, schema artifact/digest와 multi-protocol source governance |
| schema/mapper v2 reference baseline | `COMPOSED` | collision-aware schema codec/mapper contribution install, operation schema/mapper reference resolution, request reject/response strip 방향, bounded collection, typed no-throw mapping result와 operation별 cast-free result guard | actual codec fingerprint, source provenance와 multi-protocol compatibility policy |
| generated contract artifact governance | `DESIGNED_NOT_IMPLEMENTED` | `generated-api` recipe만 있고 generator/provider 선택 없음 | OpenAPI/GraphQL/proto source authentication, pinned generation, drift/breaking gate와 N/N-1 |
| feature boundary Mapper | `COMPOSED` | installed mapper metadata composer와 response-schema exact join 뒤 typed no-throw MappingResult → immutable domain/application view 실행 | numeric/date/null/enum canonical rules와 generated-artifact join |
| TanStack Query memory Server State | `COMPOSED` | QueryClient, cancellation, stale-degraded UI, session-generation cancel/clear fence와 invalidation coordinator | account identity projection과 bounded topic↔namespace many-to-many registry |
| reference bound-query/server-state profile | `COMPOSED` | bound definition, strict canonical input, scope-private opaque identity, active lease/LRU/collision/entry-byte ceiling, per-profile policy와 result admission | account projection, conditional HTTP execution join과 pagination composition |
| mutation duplicate coordinator baseline | `COMPOSED` | QueryClient runtime/scope 단위 exact semantic input identity로 identical만 join하고 distinct input을 합치지 않으며 late scope result를 폐기 | logical-key serialization과 effect certainty/reconcile |
| optimistic ordered-layer runtime | `AVAILABLE_NOT_COMPOSED` | out-of-order commit/rollback, authoritative external update 재적용과 expired-scope 제거 test | 제품 mutation의 deterministic membership/revision contract 승인 뒤 definition에 연결 |
| conditional validator CAS sidecar | `AVAILABLE_NOT_COMPOSED` | scope/representation/cache revision exact binding, ETag validation과 bounded capacity test; session 전환 clear는 production infrastructure에 연결 | HTTP If-None-Match/304 query transaction과 query removal lifecycle join |
| bounded cursor chain runtime | `AVAILABLE_NOT_COMPOSED` | page invariant, cursor loop, snapshot drift, page/item/byte/cursor ceiling과 abort test | backend CursorPage DTO/next cursor 계약 후 reference/infinite-query binding |
| cross-context server-state invalidation | `COMPOSED` | singular opaque topic 기반 invalidate-only coordinator와 session-generation local reset | account projection과 bounded topic↔namespace many-to-many registry |
| normalized GraphQL entity cache | `NOT_SELECTED` | 없음 | TanStack operation-result cache로 해결되지 않는 측정된 요구가 있을 때 별도 선택 |
| persisted query cache | reference `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` | Web Storage persistence는 금지, IndexedDB persister 없음 | VD-13의 scope/retention/restore gate를 별도 통과 |
| offline mutation queue | `NOT_SELECTED` | foreground mutation만 존재 | backend idempotency/cursor/conflict protocol과 durable command owner 필요 |
현재 REST reference는 `Response.json()`이 아니라 byte-bounded reader를 사용하고,
external auth owner는 allowlisted header patch만 반환한다. 인증 통합 실패는 fetch
전에 닫히며 correlation, success status와 physical attempt가 terminal observation에
반영된다. 다만 이 baseline을 conditional response, complete pagination,
provider conformance나 GraphQL/gRPC runtime의 증거로 재사용하지 않는다.
마찬가지로 Browser RPC V3 공통 coordinator의 `AVAILABLE_NOT_COMPOSED` 판정은
vendor wire adapter의 구현 판정이 아니다. `@connectrpc/*`, official grpc-web,
generated message와 descriptor가 없는 현재 상태에서 Connect/gRPC-Web 각 row는
계속 `DESIGNED_NOT_IMPLEMENTED`다.
## 4. 최상위 경계
```text
presentation
-> feature application input
-> feature use case
-> feature gateway port
-> operation registry
-> REST adapter
-> GraphQL adapter
-> Connect adapter
-> gRPC-Web adapter
-> bounded wire decoder
-> runtime schema / semantic validation
-> boundary mapper
-> immutable application projection
-> server-state query adapter
-> registry-owned query identity and policy
-> mapped application result only
```
금지 경로:
```text
page -> fetch / GraphQL SDK / generated Connect/gRPC client
page -> raw URL / query document / protobuf message
transport DTO -> domain or presentation public type
Response / GraphQL response / generated message -> Query cache
Query cache -> authorization or business conflict authority
```
application port는 use-case 의미를 표현한다. 예를 들어
`listResources(filters)`, `createResource(command)`는 허용하지만
`executeGraphql(document, variables)`, `grpcCall(service, method, bytes)`
`request(url, options)`는 허용하지 않는다.
## 5. Protocol-neutral operation contract
각 외부 호출은 build-time registry의 discriminated row 하나로 고정한다.
application caller는 `operationId`와 schema가 허용한 input만 제출한다.
```text
ApiOperationContractV3
registryVersion
operationId
owner
protocol = REST | GRAPHQL_HTTP | CONNECT_HTTP | GRPC_WEB
semantics = QUERY | COMMAND | SERVER_STREAM
authProfileId
csrfProfileId
replayPolicy = SAFE | IDEMPOTENT | KEYED_COMMAND | NON_REPLAYABLE
idempotencyKeyPolicy = NONE | REQUIRED
requestSchemaId
responseSchemaId
mapperId
errorProfileId
deadlineProfileId
retryProfileId
serverStateProfileId | null
invalidationTopicRefs[] = { topicId, topicVersion }
dataClassification
compatibility
globalApiContractVersion
protocolArtifactId
minimumServerVersion
retirementEpoch | null
protocolBinding
```
`protocolBinding`은 transport별 closed union이다.
```text
REST
method
relativePathTemplate
requestProjection
requestMediaProfile
responseMediaProfile
conditionalProfile
GRAPHQL_HTTP
endpointId
graphqlHttpProfileRevision
persistedEnvelopeProfileId
responseStatusMediaProfileId
persistedOperationId
persistedOperationSha256
operationType
partialDataPolicy
GRPC_WEB
endpointId
clientRuntimeId
grpcWebWireSpecRevision
transportProfile
responseHttpStatusProfileId
fullyQualifiedService
method
rpcKind = UNARY | SERVER_STREAM
requestMessageId
responseMessageId
CONNECT_HTTP
endpointId
clientRuntimeId
connectProtocolRevision
encoding = PROTO_JSON | PROTO_BINARY
requestMethod = POST | GET
fullyQualifiedService
method
rpcKind = UNARY | SERVER_STREAM
descriptorArtifactId
descriptorDigest
```
registry validation은 다음을 build/boot 전에 거절한다.
- 중복 operation/mapper/profile ID
- protocol과 맞지 않는 binding field
- 등록되지 않은 schema, mapper, auth, deadline, retry와 cache profile
- `QUERY`인데 replay policy가 `SAFE | IDEMPOTENT`가 아님
- `KEYED_COMMAND`인데 idempotency key policy가 `REQUIRED`가 아니거나 backend
dedupe/reconcile profile이 없음
- `NON_REPLAYABLE`인데 network/401 replay가 enabled
- `COMMAND`인데 cache profile이 query data owner로 지정됨
- `SERVER_STREAM`인데 ordinary query cache profile을 사용
- opening retry가 enabled인데 replay policy가 `SAFE | IDEMPOTENT`가 아님
- `KEYED_COMMAND | NON_REPLAYABLE` server stream인데 opening retry가 enabled거나
explicit resume/reconcile/dedupe profile이 없음
- `SERVER_STREAM`인데 `protocol=CONNECT_HTTP | GRPC_WEB`
`rpcKind=SERVER_STREAM` 조합이 아님. REST SSE와 GraphQL subscription을 이
registry 의미로 암묵 등록하지 않음
- unsafe REST method 또는 GraphQL mutation인데 CSRF/replay/key 결정이 없음
- gRPC-Web client/bidi method
- Connect browser client/bidi method 또는 descriptor의 `NO_SIDE_EFFECTS`가 없는
Connect GET
- absolute URL, runtime GraphQL document 또는 caller-provided service/method
- implementation hard ceiling보다 큰 timeout, byte, frame, page와 retry 값
- GraphQL operation↔provider의 HTTP revision/envelope/status-media profile 또는
Connect/gRPC-Web operation↔provider의 runtime/wire/status/capability tuple
mismatch
현재 installed reference operation은 REST v2 metadata를 사용한다. operation,
runtime schema codec과 mapper contribution은 object spread가 아니라 각각의
collision-aware composer로 설치되며 duplicate ID를 덮어쓰기 전에 거절한다.
boot-time binding 검증은 path placeholder와 codec key, provider/auth/CSRF profile,
path/request/response schema와 mapper input schema를 exact resolve한 뒤 immutable
registry를 발행한다. GraphQL/Connect/gRPC-Web discriminant와 protocol-specific
binding은 해당 reference adapter가 아직 없으므로 source에 구현됐다고 표현하지
않는다.
현재 `API_CONTRACT_VERSION`은 runtime config와 release manifest의 문자열 일치
gate다. 목표 contract set은 REST/OpenAPI artifact, GraphQL schema/persisted
manifest, protobuf descriptor, runtime schema/mapper registry digest를 포함한
bounded manifest를 만들고 global compatibility version과 함께 release tuple에
binding한다. 문자열 일치만 actual backend compatibility 증거로 사용하지 않는다.
## 6. 공통 실행 lifecycle
```text
lookup exact operation
-> freeze session/account/runtime generation
-> validate and canonicalize application input
-> derive exact query/command identity
-> allocate total operation deadline
-> encode transport request from registry binding
-> attach credential/CSRF through approved owner
-> execute bounded attempt
-> bounded response/frame decode
-> validate transport envelope/status
-> validate operation DTO/message semantics
-> map to immutable application projection
-> re-check scope/generation
-> return Result
-> query adapter may admit mapped value to memory cache
```
현재 v2 auth owner는 `Request`를 반환하지 않고 transport가 만든 immutable
request binding에 대해 allowlisted credential patch만 제공한다. 최소한
transport는 attach 뒤에도 URL, origin, method, body digest, content headers,
idempotency와 conditional binding이 바뀌지 않았음을 다시 검증한다.
auth-required operation은 session state가 unauthenticated/integration-failed이거나
credential attachment가 실패하면 **fetch 0회**로 닫는다. 동시 401 recovery는
session owner의 single-flight 한 번만 공유하며 replay-safe operation만 동일
logical deadline/idempotency binding으로 한 번 재실행한다.
모든 async boundary와 terminal cache write 전에 captured generation을 확인한다.
logout/account switch 뒤 끝난 response, mapper와 stream frame은 old runtime
결과로 폐기한다.
deadline은 attempt마다 새로 시작하지 않는다.
```text
total budget
= credential attach
+ network attempts
+ retry delay
+ body/frame read
+ schema validation
+ mapper
```
각 phase에 별도 하위 ceiling을 둘 수 있지만 전체 deadline을 늘릴 수 없다.
caller abort, runtime teardown, timeout과 provider cancellation은 서로 다른 safe
failure로 정규화한다.
## 7. Transport 선택 기준
| 요구 | 기본 선택 | 이유 |
| --- | --- | --- |
| resource/command, HTTP cache/conditional semantics, 파일 handoff | REST | Web/BFF·CDN·운영 도구와 자연스럽고 failure/status가 명확함 |
| 여러 aggregate를 한 화면 shape로 읽고 client별 selection이 유의미 | persisted GraphQL query | allowlisted operation으로 over/under-fetch를 줄일 수 있음 |
| Protobuf-first backend의 내부 web UI, unary 또는 bounded server stream | Connect-Web/Connect 우선 평가 | generated descriptor와 Fetch 기반 browser RPC를 재사용 |
| 기존 gRPC-Web proxy/conformance 자산 | selected gRPC-Web runtime | runtime별 binary/text/stream capability를 exact profile로 고정 |
| ProtoJSON/HttpRule 자체가 승인된 public HTTP contract | generated REST Gateway 검토 | envelope/status/cache/idempotency를 별도 증명 |
| 현재 REST envelope·ETag·Range·제품 DTO가 중요 | curated REST BFF 유지 | generated transcoder가 제품 HTTP 의미를 자동 제공하지 않음 |
| browser client/bidi streaming | Connect/gRPC-Web 사용 금지 | protocol 자체 기능과 browser 공통 지원을 혼동하지 않음 |
| arbitrary ad-hoc query | GraphQL 사용 금지 | cost, authorization, cache identity와 operation governance를 우회 |
| 단순 CRUD인데 GraphQL/gRPC dependency만 추가 | REST 유지 | 복잡도와 bundle/proxy 비용을 정당화하지 못함 |
한 feature가 여러 protocol을 사용할 수 있지만 한 `operationId`는 한 protocol에만
binding한다. query read를 shadow 비교하는 경우에도 secondary 결과는 사용자와
cache에 반영하지 않는다. command는 protocol 장애를 이유로 자동 failover/replay
하지 않는다.
## 8. REST 설계 요약
REST 상세 결정은 VD-23이 소유한다. 공통 baseline은 다음과 같다.
- base origin과 relative path template은 composition/registry가 소유한다.
- provider base URL은 HTTPS와 exact origin/path-prefix를 고정하고 userinfo,
query와 fragment를 금지한다. path join은 선택한 base prefix를 보존하며
leading slash가 prefix를 조용히 제거하지 않는다.
- method는 closed union이며 path/search/header/body는 각 runtime schema를 지난다.
- replay semantics는 `SAFE`, `IDEMPOTENT`, `KEYED_COMMAND`,
`NON_REPLAYABLE`로 method와 교차 검증한다.
- caller-provided URL, header, `credentials`, redirect와 cache option을 금지한다.
- cookie session이면 unsafe method에 approved CSRF owner가 필요하다.
- `SAFE | IDEMPOTENT | KEYED_COMMAND` 중 exact retry profile과 실제 provider
replay evidence가 있는 operation만 network retry한다.
- keyed retry와 401 recovery는 같은 logical idempotency key를 유지한다.
- total deadline, attempts, backoff와 `Retry-After`는 implementation ceiling 안이다.
- JSON/error body는 present/valid `Content-Length` advisory preflight와 actual
decoded-byte bounded stream reader 뒤 parse한다. encoded transfer cap은
BFF/proxy/CDN가 집행한다.
- status, content type, response media profile과 envelope 조합을 exact하게 검증한다.
- 204, 304, 412, 422와 problem/envelope profile은 operation이 명시한 경우만 허용한다.
- strong ETag는 cache identity가 아니라 exact representation revalidation
metadata다. validator를 diagnostics에 기록하지 않는다. `Last-Modified`
별도 weak/time validator profile이 승인되기 전 이번 target에 포함하지 않는다.
- cursor는 opaque하며 filter/sort/scope와 binding한다. arbitrary URL을
`next` link로 따라가지 않는다.
- browser HTTP cache와 application ETag/TanStack revalidation owner 중 하나를
operation별로 선택한다. 두 cache의 freshness를 서로 추측해 합치지 않는다.
- request/response body, URL query, authorization, CSRF, idempotency key와 raw
backend copy를 log/telemetry에 넣지 않는다.
## 9. GraphQL 설계 요약
GraphQL 상세 결정은 VD-26이 소유한다. reference target은
**persisted operation only**다.
- production bundle은 arbitrary GraphQL document string을 runtime에 받지 않는다.
- build artifact가 operation name, stable ID, SHA-256, variables/result schema,
schema digest와 owner를 manifest로 만든다.
- BFF/router는 allowlist에 없는 ID/hash와 cost/depth limit 초과를 거절한다.
- endpoint는 fixed HTTPS registry ID이고 POST가 기본이다.
- selected provider가 지원하는 GraphQL-over-HTTP revision과 persisted-envelope
extension을 profile에 고정한다. ID/hash-only request를 generic 표준 envelope로
가장하지 않는다.
- `Accept: application/graphql-response+json`을 우선하고 final URL/media/body cap
뒤에는 허용 HTTP status의 GraphQL envelope를 bounded decode한 다음 status/body
matrix를 교차 검증한다. legacy `application/json`은 별도 profile이다.
- public cacheable query의 GET은 별도 threat/cache review 뒤에만 허용한다.
- variables는 request schema와 byte/depth/node ceiling을 통과한다.
- HTTP status와 GraphQL `data/errors/extensions`를 두 단계로 검증한다.
- default `partialDataPolicy=REJECT`; 승인 operation만 typed completeness metadata와
함께 partial을 application으로 투영할 수 있다.
- error message, path value와 arbitrary extensions를 노출하지 않고 registered
safe code/category만 `AppFailure`로 mapping한다.
- APQ miss에서 full document를 자동 전송하지 않는다. manifest/version mismatch로
fail-closed하고 coherent frontend/router artifact를 복구한다.
- batching은 auth, deadline, cancel, observation과 partial failure owner가
별도 승인되기 전 `NOT_SELECTED`다.
- subscription, `@defer`, `@stream`은 ordinary query adapter에 암묵적으로 넣지
않는다.
- Apollo/urql normalized cache는 기본 dependency가 아니다. mapped operation
result의 memory owner는 TanStack Query다.
## 10. Browser Protobuf RPC와 REST Gateway 설계 요약
축과 선택 기준의 상세 계약은
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)가
소유한다. Protobuf는 IDL/serialization, Connect와 gRPC-Web은 browser wire
protocol, Connect-Web/official grpc-web은 client runtime, REST Gateway는 HTTP
노출 방식이다. 네 이름을 하나의 대안 목록이나 하나의 auto-negotiating
executor로 합치지 않는다.
### 10.1 gRPC-Web
gRPC-Web 상세 결정은 VD-27이 소유한다. reference target은 unary와 bounded
server-stream만 다룬다.
- checked-in/generated artifact는 pinned proto descriptor/module digest에 묶는다.
- vendor generated client/message는 feature adapter 내부에만 존재한다.
- fully-qualified service/method와 endpoint는 registry가 고정한다.
- client runtime과 gRPC-Web wire-spec revision을 operation/provider에 고정한다.
official grpc-web runtime 기준 binary profile은 unary에만 사용하고 server
stream은 `grpcwebtext`에 binding한다.
- Connect-Web의 `createGrpcWebTransport()`는 Fetch 기반 binary/JSON unary와
server stream profile이며 `grpcwebtext` profile이 아니다. official XHR runtime의
capability matrix를 이 runtime에 적용하지 않는다. platform-authored custom
binary streaming도 세 browser와 actual proxy의 incremental evidence가 있는
별도 runtime profile만 허용한다.
- frame header, message length, compression flag, total bytes와 frame count를
bounded decoder가 검증한다.
- HTTP status와 terminal status source를 함께 검사한다. terminal status source는
body trailer frame 또는 zero-body trailers-only response header 중 정확히
하나이며 중복/충돌을 거절한다.
- `grpc-message`, binary error details와 metadata는 allowlist projection 없이
application에 반환하지 않는다.
- deadline은 `grpc-timeout`과 local total deadline의 더 짧은 값이며
AbortSignal이 fetch/stream reader를 cancel한다.
- unary `SAFE | IDEMPOTENT | KEYED_COMMAND` 중 provider evidence가 있는
operation만 retry한다. stream reconnect는 retry가 아니라
server-owned sequence/resume-token을 가진 별도 application protocol이다.
- idle deadline, total deadline, max frame/message/count/buffer를 모두 둔다.
- client-streaming/bidi는 지원한다고 가장하지 않는다.
- Envoy/BFF/Connect/gRPC-Web proxy의 CORS, exposed trailers, content type,
auth와 maximum message 설정을 actual provider conformance로 검증한다.
- int64/uint64는 JavaScript number로 변환하지 않고 safe integer 범위를
증명하거나 decimal string/adapter-private bigint로 mapping한다.
### 10.2 Connect-Web과 Connect protocol
Connect 상세 결정은 VD-29가 소유한다.
- `createConnectTransport()``createGrpcWebTransport()`는 같은 package의 서로
다른 wire protocol이다. decoder/status/terminal profile을 공유하지 않는다.
- Connect unary의 JSON/binary와 POST를 operation row에 고정한다.
- GET은 unary + `NO_SIDE_EFFECTS` descriptor + non-sensitive bounded input +
exact URL/cache/CORS profile에서만 별도 승인한다.
- Connect server stream은 final EndStream envelope를 확인하기 전 성공이 아니다.
- stock runtime의 whole-body decode와 streaming compression 한계를 exact package
version evidence로 확인한다. proxy cap이나 custom bounded transport가 없으면
production raw-byte ceiling을 완료로 표시하지 않는다.
- interceptor의 resolved onion order, auth 이후 final invariant, total deadline,
exactly-one retry owner와 cancel handle을 manifest에 고정한다.
- `@connectrpc/connect-query`를 기본 도입하지 않는다. generated service/message는
adapter-private이고 mapped application value만 기존 TanStack Query에 들어간다.
### 10.3 Protobuf contract와 REST Gateway
Protobuf/REST Gateway 상세 결정은 VD-30이 소유한다.
- authenticated proto/Buf source, descriptor, generator/runtime/plugin version과
generated digest를 coherent release artifact로 고정한다.
- JSON 노출은 최소 `WIRE_JSON` compatibility를 요구하고 canonical HttpRule
route manifest와 generated OpenAPI를 별도 semantic diff한다.
- current reference REST envelope에는 curated BFF를 유지한다.
- direct grpc-gateway/Envoy transcoder는 ProtoJSON, method/path/query/body,
status/error/CORS/cache contract가 그대로 제품 API로 승인된 unary operation에만
적용한다.
- gateway는 idempotency store, CursorPage snapshot, ETag/304/412, file Range나
안전한 domain error vocabulary를 자동 제공하지 않는다.
- REST server streaming은 generated gateway의 부수 동작으로 활성화하지 않고
framing/terminal/cache를 소유하는 별도 ADR 없이는 `NOT_SELECTED`다.
## 11. Schema trust boundary
TypeScript type과 generated code는 compile-time convenience이지 runtime proof가
아니다. trust transition은 다음 순서를 지킨다.
```text
untrusted bytes/frames
-> bounded transport decoder
-> transport envelope/status proof
-> operation DTO/message runtime or semantic proof
-> ValidatedWireValue (adapter-private)
-> boundary mapper
-> immutable application projection
```
schema profile은 최소 다음을 고정한다. browser가 직접 집행하는 decoded ceiling과
provider/BFF/proxy가 집행하는 wire/encoded ceiling의 owner를 분리한다.
```text
schemaId
schemaVersion
boundary
protocol
sourceArtifactId + sourceDigest
unknownFieldPolicy
providerMaxEncodedBytes
maxDecodedBytes
maxDepth
maxNodes
maxStringBytes
maxCollectionItems
compatibilityPolicy
owner
```
unknown-field 기본 정책:
| 경계 | 정책 |
| --- | --- |
| request, config, capability, control envelope | `REJECT_UNKNOWN` |
| evolvable ordinary response DTO | `STRIP_UNKNOWN` 후 mapper에 전달 |
| discriminant/security/authorization 의미를 가진 union | unknown variant 거절 |
| unknown data 보존 | adapter 내부 forward proxy가 아닌 한 금지 |
현재 reference request/path DTO는 `.strict()`로 unknown field를 거절하고,
ordinary response DTO는 `.strip()` projection으로 additive server field를
cache/domain 경계 밖에 버린다. discriminant/security union을 포함한 다른
operation은 각 compatibility profile에 따라 별도로 결정한다.
## 12. Mapper 경계
mapper는 transport가 아니라 feature contract가 소유한다.
```text
MapperDefinition
mapperId
inputSchemaId
outputContractId
mapperVersion
collectionPolicy
temporalPolicy
numericPolicy
nullabilityPolicy
owner
```
mapper는 pure, deterministic, side-effect-free이며 다음 union을 반환한다.
```text
MappingResult<T>
= { ok: true, value: T }
| { ok: false, error: MAPPING_CONTRACT_VIOLATION }
```
현재 reference mapper는 예상 가능한 drift를 throw하지 않고 closed
`MappingResult` failure로 반환한다. registry 실행 경계는 예상하지 못한 mapper
throw도 fail-closed mapping failure로 바꾸며 raw DTO, value, path와 backend
message를 버린다.
공통 scalar 규칙:
- opaque ID는 trim/재해석하지 않는 bounded branded string이다.
- ISO timestamp는 offset/precision 정책을 검증한 뒤 application instant로
변환한다. locale date string과 invalid date normalization을 금지한다.
- `int64`, decimal money와 high-precision value는 JSON number로 받지 않는다.
- `null`, absent와 empty string/list는 schema와 domain에서 별도 의미로 결정한다.
- unknown enum은 domain이 explicit `UNKNOWN`을 소유한 경우만 mapping한다.
- collection mapping은 count/byte ceiling 안에서 fail-fast하고 partial array를
cache하지 않는다.
- mapper는 network, clock, storage, QueryClient, locale formatter와 telemetry를
호출하지 않는다.
## 13. Server State Cache 경계
TanStack Query는 transport response cache가 아니라 mapped application projection의
memory lifecycle owner다.
cache에 허용:
- immutable plain application projection
- registered query identity로 찾을 수 있는 bounded collection/page
- UI가 stale/refresh 상태를 계산하는 library metadata
cache에 금지:
- `Response`, raw JSON/GraphQL envelope, generated protobuf message
- auth/CSRF/idempotency token, request header와 arbitrary URL
- raw ETag, trace/span, backend error/details
- File/Blob/stream/native handle
- domain service, class instance, function, Promise와 `AbortSignal`
query profile은 caller가 raw option을 전달하는 대신 registry에서 선택한다.
```text
ServerStateProfileV1
profileId
queryKeyCodecId
scopePersistencePolicyId
staleTimeMs
gcTimeMs
refetchOnFocus
refetchOnReconnect
networkMode
maxResultBytes
maxCollectionItems
paginationProfileId | null
revalidationProfileId | null
invalidationTopicRefs[] = { topicId, topicVersion }
placeholderPolicy
owner
```
query key는 validated/canonical application input과 scope projection으로 만든다.
REST URL, GraphQL document/hash, protobuf bytes와 generated message serialization은
query key가 아니다. transport 교체가 use-case identity를 바꾸지 않으면 같은
application query family를 유지할 수 있지만, old/new representation을 한 cache
entry에 shadow write하지 않는다.
network retry는 transport adapter가 소유하고 Query retry는 기본 `false`다.
refresh failure에서 유효한 previous data는 stale-degraded로 유지한다. schema,
mapper, scope, authorization와 contract mismatch는 stale data를 계속 노출해도
되는지 query profile이 명시해야 하며 기본은 security-sensitive scope에서
즉시 숨김/clear다.
VD-13이 scope/persistence profile, normative key layout, account/session
generation과 late-result fence를 소유한다. 이 문서는 그 profile을 exact join하고
operation/cache policy, pagination, revalidation과 mutation coherence를 소유한다.
## 14. Pagination과 conditional revalidation
cursor pagination은 다음 binding을 갖는다.
```text
PaginationBinding
query family fingerprint
canonical filters/sort
scope fingerprint
server snapshot/revision policy
page size ceiling
opaque next cursor
```
- cursor를 decode하거나 URL로 취급하지 않는다.
- `hasMore === (nextCursor !== null)`을 codec에서 강제한다.
- single-page cache는 runtime-scoped cursor fingerprint를 semantic key에 포함하고,
infinite query만 cursor를 root key에서 제외해 bounded `pageParam`으로 둔다.
- max pages/items/estimated bytes를 넘으면 더 불러오지 않는다.
- 동일 cursor 반복, loop와 non-progress page를 contract failure로 닫는다.
- offset pagination의 insert/delete drift를 자동 deduplicate로 숨기지 않는다.
- page merge는 mapper가 보장한 stable identity가 있을 때만 deterministic하다.
- previous filters의 page를 새 filter key에 재사용하지 않는다.
REST `304`는 cached data가 있다는 뜻이 아니라 representation이 바뀌지 않았다는
transport 결과다. exact query fingerprint, scope/generation과 cached mapped
value에 binding된 validator record가 모두 있을 때만 freshness를 갱신한다.
cached value가 없거나 binding이 다르면 unconditional request를 한 번 수행하거나
closed failure로 끝낸다.
GraphQL persisted operation과 gRPC-Web unary는 기본적으로 application-level
validator가 없다. backend가 revision을 제공하면 response schema/mapper가 opaque
revision을 application revalidation policy로 투영해야 하며 HTTP/gRPC metadata를
임의로 ETag처럼 해석하지 않는다.
## 15. Mutation coherence
mutation과 query cache는 server commit authority가 아니다.
```text
validate command
-> derive logical key + exact command equality/opaque identity token
-> runtime/scope coordinator applies concurrency + duplicate admission
-> separately acquire invalidation-topic hint-coalescing lease
-> cancel exact affected query reads
-> capture bounded base cache revision and inverse patch
-> install own ordered optimistic layer with revision CAS
-> transport derives backend idempotency binding from operation policy
-> execute command once with transport-owned retry policy
-> success: registered exact seed/compare-and-apply + list/aggregate invalidation
-> rejection: remove or invert only own layer with revision CAS
-> uncertainty/CAS miss: preserve other commits + invalidate/authoritative refetch
-> release invalidation lease + concurrency admission
```
- duplicate submit 정책은 `JOIN_IDENTICAL`, `REJECT_DUPLICATE`,
`ALLOW_INDEPENDENT` 중 operation별로 고정하고, 동일성은 전체 validated semantic
input의 runtime-private exact equality guard와 opaque identity token으로
판정한다.
- 같은 hook instance의 Promise dedupe를 server idempotency로 간주하지 않는다.
- logical key/identity admission은 local ordering이고 invalidation-topic lease는
remote hint coalescing일 뿐이다. 둘 다 backend idempotency authority가 아니다.
- optimistic patch는 raw DTO나 generated message를 만들지 않는다.
- snapshot item/byte ceiling을 넘으면 optimistic update를 하지 않고 pending UX만
제공한다.
- 409/GraphQL conflict code/gRPC `ABORTED`는 동일한 safe conflict vocabulary로
mapping하되 server revision과 merge policy는 feature use case가 소유한다.
- server가 commit한 뒤 local invalidation 실패를 command 실패로 되돌리지 않는다.
cache health를 degraded로 기록하고 bounded refetch/recovery를 예약한다.
- command의 자동 protocol failover는 중복 side effect 위험 때문에 금지한다.
## 16. Server streaming과 cache
gRPC-Web server stream, GraphQL subscription과 incremental delivery는 ordinary
queryFn과 다르다.
현재 installed API operation registry는 terminal REST operation만 소유한다.
GraphQL HTTP와 gRPC-Web unary/server-stream은 각 reference adapter가 구현될 때
protocol discriminant와 전용 binding으로 확장한다. SSE, WebSocket과 Web Push는
VD-28 realtime registry가 소유하며 bounded polling은 registered terminal REST
`QUERY`의 scheduling policy이지 새 protocol이나 automatic failover가 아니다.
GraphQL `@defer`/`@stream`은 선택될 경우에도 한 HTTP operation의 finite
incremental response이며 subscription과 같은 장기 realtime stream이 아니다.
```text
ServerStreamPort
open(frozen request, signal)
-> AsyncIterable<Result<MappedEvent>>
-> close()
```
- frame/event마다 schema, mapper, scope와 generation을 재검증한다.
- sequence, duplicate, gap과 resume token은 backend application protocol이다.
- bounded queue, high-water mark, overflow, idle/total deadline을 선언한다.
- stream event는 registered reducer로 immutable snapshot을 만들거나 query
invalidation hint만 발행한다.
- partial event를 ordinary query success로 cache하지 않는다.
- stream 종료/재connect를 TanStack Query retry로 처리하지 않는다.
GraphQL subscription은 현재 `NOT_SELECTED`이고, gRPC-Web server-stream은
`DESIGNED_NOT_IMPLEMENTED`다.
gRPC-Web `ServerStreamPort`는 operation-bound outbound stream result일 수 있다.
API adapter가 protobuf frame/message decode, semantic schema와 boundary mapper를
끝낸 뒤 제품이 runtime-wide notification projection을 명시적으로 선택한
branch에서만 mapped event를 VD-28 common coordinator/`FeatureEventInput`
전달한다. protobuf를 `REALTIME_EVENT_V1` JSON으로 감싸지 않고, 첫 event 전
opening replay와 이후 resume 규칙은 VD-27이 계속 소유한다.
## 17. Backend/provider 계약
구현 owner, 권장 topology, 현재 reference endpoint/envelope, idempotency store,
Cursor/ETag/revision과 protocol별 handoff checklist는
[Backend API와 Server State contract](./backend-api-and-server-state-contract.md)가
소유한다. 아래 표는 frontend 설계가 요구하는 경계 요약이다.
| 경계 | backend/provider가 제공할 계약 |
| --- | --- |
| 공통 | authorization, contract version/artifact compatibility, encoded transfer와 decoded payload의 bounded owner, stable error code, correlation/trace projection, idempotency와 rate-limit semantics |
| REST | exact method/path/media/status/envelope, CSRF strategy, idempotency retention, cursor binding, ETag/If-None-Match 또는 revision, retry-safe status와 CORS/cache policy |
| GraphQL | schema registry, persisted-operation manifest, allowlist/cost/depth enforcement, safe error extension vocabulary, operation retirement과 N/N-1 router rollout |
| Connect | proto/descriptor/codegen source, exact Connect-Web runtime/encoding/method, EndStream/status/error, timeout/cancel/compression/CORS와 browser/server conformance |
| gRPC-Web | proto/descriptor source, Buf/protoc compatibility policy, gRPC-Web proxy, exact service/method, message/frame ceiling, status/trailer/CORS exposure와 stream resume protocol |
| Protobuf REST Gateway | selected gateway kind/version, HttpRule/ProtoJSON/OpenAPI artifact, path/query/body/status/error/header mapping, edge→upstream cancellation과 N/N-1 conformance |
| Schema | authenticated source artifact, additive/breaking classification, deprecation window, fixtures and source digest |
| Mapper | domain meaning, temporal/numeric/null/enum semantics와 stable identity |
| Cache | revision/conflict/idempotency/invalidation semantics; frontend TTL은 authorization 대체가 아님 |
frontend가 제공하는 Zod schema, generated type과 cache invalidation은 backend
authorization, validation, idempotency와 conflict resolution을 대체하지 않는다.
## 18. Security와 privacy
- API base/GraphQL/Connect/gRPC-Web endpoint는 HTTPS registry ID로 고정한다.
- caller가 URL, header, GraphQL document, service/method와 metadata를 제출하지
못한다.
- auth owner가 credential을 붙이고 application/query/cache에는 token을 노출하지
않는다.
- credential attachment 뒤 URL/method/origin/body digest와 registry-owned header
binding을 재검증한다. auth integration unavailable 상태에서 request를 보내지
않는다.
- cookie session의 unsafe request는 CSRF token/header 또는 same-site BFF 정책을
operation profile과 provider conformance로 증명한다.
- GET/GraphQL variables에 sensitive filter를 넣는 operation은 별도 review 없이
만들지 않는다.
- response byte/depth/node/string/collection/frame cap으로 resource exhaustion을
막는다.
- GraphQL cost/depth와 gRPC message cap은 server/proxy에서도 강제한다.
- mapper와 cache는 prototype/accessor/class/native object를 받아들이지 않는다.
- PII/business ID를 query key, diagnostics label, persisted cache physical key에
직접 넣지 않는다. 필요한 identity는 opaque partition/token policy를 쓴다.
- command identity token/logical mutation key는 runtime-local control data이며
diagnostics, cross-context wire와 persistence에 넣지 않는다.
- raw request/response, GraphQL variables/errors, protobuf bytes/metadata, ETag,
cursor, idempotency key와 validation value를 관측 데이터에 넣지 않는다.
## 19. Observability
허용된 bounded aggregate:
- operation registry ID, protocol, semantics
- outcome/error kind, HTTP status group 또는 gRPC status code allowlist
- GraphQL full/partial/rejected outcome
- attempt/deadline/duration/encoded-byte/result-item/frame bucket
- schema/mapper profile ID와 compatibility outcome
- query hit/miss/stale/refetch/eviction bucket
- mutation optimistic/rollback/conflict/invalidation outcome
- provider/browser/runtime version의 low-cardinality bucket
금지:
- URL/path parameter/search/body/header
- GraphQL document, variables, response path와 raw error message
- protobuf message/metadata/trailer raw value
- resource/account/tenant ID, cursor, validator, digest와 cache value
하나의 logical operation은 terminal observation 하나를 만든다. attempt span은
sampling된 내부 detail로만 남기며 terminal success/failure count를 중복시키지
않는다.
## 20. Failure와 fallback
| 실패 | 기본 결과 |
| --- | --- |
| registry/schema/mapper 누락 | network 전 fail-closed, operation traffic disable |
| incompatible contract artifact | product mount 또는 해당 capability admission 차단 |
| response cap/decode/schema mismatch | body/reader cancel, cache write 금지, provider incompatibility |
| mapper violation | cache write 금지, safe contract failure |
| REST retry exhaustion | stale 허용 profile만 previous data 유지 |
| GraphQL persisted operation missing | full document fallback 금지, coherent artifact rollback |
| GraphQL partial data | default reject; explicit profile만 completeness와 함께 사용 |
| Connect missing/duplicate EndStream 또는 oversize whole body | call cancel, cache write 금지, provider/runtime incompatible |
| gRPC-Web proxy/trailer mismatch | reader cancel, provider unavailable/incompatible |
| REST Gateway HttpRule/OpenAPI/runtime drift | affected route admission 차단, coherent gateway artifact rollback |
| server stream gap/overflow | snapshot 폐기 또는 authoritative refetch |
| account/generation mismatch | late result 폐기, old-scope cache write 금지 |
| invalidation failure after commit | command 성공 유지, cache degraded + recovery refetch |
GraphQL, Connect 또는 gRPC-Web failure를 REST로 자동 전환하지 않는다. 사전에 등록된
read-only shadow/fallback operation이 있고 동일 authorization/mapper/result
contract를 conformance suite로 증명한 경우만 selector가 새 logical query를
시작할 수 있다.
## 21. Rollout과 removal
```text
ADR + registry schema accepted
-> deterministic codec/schema/mapper fixture
-> provider-neutral adapter and fake
-> negative boundary/removal gate
-> AVAILABLE_NOT_COMPOSED
-> product/provider/operation selection
-> bootstrap composition behind TrafficAdmission=DISABLED
-> COMPOSED
-> shadow/read-only conformance
-> browser/provider/operations evidence
-> PromotionEvidence=COMPLETE
-> CANARY
-> ENABLED
```
REST v2 local baseline은 installed reference operation에 연결됐다. 실제 provider
traffic은 operation/profile 단위의 conformance와 canary를 거쳐야 하며,
conditional/pagination은 backend 계약 없이 enabled하지 않는다.
GraphQL/Connect/gRPC-Web/codegen/gateway dependency는 실제 selected operation이
없으면 production inventory에 없어야 한다.
removal:
1. 신규 operation admission을 닫는다.
2. query는 cancel하고 command/stream은 bounded drain 또는 explicit abort한다.
3. 해당 invalidation listener, auth attachment와 provider를 close한다.
4. current scope의 mapped memory cache를 clear한다.
5. operation/schema/mapper/query profile과 generated artifact를 제거한다.
6. dependency, config, proxy route, test fixture와 production module inventory가
함께 제거됐음을 증명한다.
7. backend persisted-operation/method retirement은 N/N-1 client window 뒤에 한다.
## 22. Test와 promotion evidence
### Deterministic
- operation registry closed union/reference/orphan/duplicate
- request canonicalization과 query-key identity
- timeout/total deadline/retry/idempotency/auth recovery
- response byte/depth/node/collection cap
- schema unknown-field, scalar, null/enum/numeric/date matrix
- mapper success/failure/no raw value leakage
- query stale/gc/refetch/pagination/mutation/rollback/generation fence
### Contract
- REST OpenAPI/envelope/status/media/cursor/conditional/idempotency
- GraphQL schema + persisted manifest + variables/result/error/partial policy
- proto descriptor + breaking check + gRPC status/trailer/frame fixture
- Connect unary/stream JSON/binary/GET/EndStream/CORS/deadline fixture
- HttpRule route manifest + ProtoJSON/OpenAPI/status/error/header mapping fixture
- 같은 fixture를 fake, emulator/staging과 actual provider에 실행
### Browser/integration
- bootstrap → feature → transport → schema → mapper → Query → UI
- AbortSignal/navigation/logout/account switch
- CORS/cookie/CSRF/redirect/content-encoding
- HTTP/2/proxy/CDN/Connect EndStream/gRPC-Web trailer behavior
- offline/reconnect/focus와 stale-degraded UI
### Fault
- truncated/oversize/malformed response
- slow credential/network/body/schema/mapper phase
- 401 recovery, 429, retry exhaustion과 total deadline
- GraphQL partial/error/persisted-operation drift
- Connect missing/early/duplicate EndStream, whole-body overflow와 compression mismatch
- gRPC missing/conflicting terminal status source, corrupt/compressed/oversize
frame와 stream gap
- REST Gateway route/OpenAPI/runtime rewrite drift와 abort propagation loss
- late response, duplicate mutation, optimistic rollback과 invalidation failure
### Operations
- operation kill switch
- contract artifact N/N-1 rollout과 rollback
- provider incompatibility containment
- cache scope reset와 stale-data decision
- generated client/GraphQL/Connect/gRPC-Web/REST Gateway removal drill
fake와 generated compile success만으로 actual provider, browser나 operations
evidence를 `COMPLETE`로 표시하지 않는다.
## 23. 설계 우선 work package
| package | 목표 |
| --- | --- |
| API-01 | REST v2 operation registry, total deadline, bounded decoder와 conditional/pagination contract |
| API-02 | multi-protocol schema artifact/digest governance와 typed Mapper result |
| API-03 | strict ServerStateProfile, query-key codec, pagination/revalidation와 mutation policy |
| API-04 | persisted-operation-only GraphQL reference adapter와 conformance harness |
| API-05 | gRPC-Web unary/server-stream reference adapter와 proxy harness |
| API-06 | Connect-Web unary/server-stream reference adapter와 provider harness |
| API-07 | Protobuf governance와 selected REST Gateway conformance |
| API-08 | atomic composition, readiness, kill switch, runbook와 provider/browser evidence |
권장 순서는 API-01 → API-02 → API-03이다. API-04~07은 제품 선택과
backend/provider 계약이 생긴 branch만 독립적으로 시작한다. GraphQL, Connect,
gRPC-Web과 REST Gateway를 “미래 대비” 목적으로 모두 기본 bundle에 설치하지
않는다.
## 24. 완료 기준
- [ ] 모든 installed operation은 protocol/schema/mapper/cache/error/deadline owner가 있다.
- [ ] application/presentation public type에 DTO, GraphQL SDK와 generated protobuf가 없다.
- [ ] untrusted byte부터 mapped projection까지 모든 ceiling과 trust transition이 닫혀 있다.
- [ ] query key와 실제 request input이 동일 canonical source에서 파생된다.
- [ ] transport retry와 Query retry가 중복되지 않는다.
- [ ] command idempotency, optimistic patch와 conflict/invalidation owner가 명시돼 있다.
- [ ] account/logout/release generation 뒤 late result가 cache에 들어가지 않는다.
- [ ] actual REST/GraphQL/Connect/gRPC-Web/Gateway provider에 같은 semantic
conformance fixture를 실행한다.
- [ ] contract drift, kill switch, rollback과 optional dependency removal drill이 통과한다.
- [ ] raw payload/URL/document/message/metadata/validator가 log와 cache에 없다.
- [ ] `COMPOSED`와 production-ready/provider-conformant를 같은 의미로 쓰지 않는다.
## 25. 관련 문서
- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
- [Client cache and browser storage](./client-cache-and-storage.md)
- [VD-13 Client cache scope와 persistence](./decisions/VD-13-client-cache-scope-and-persistence.md)
- [Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
- [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md)
- [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md)
- [Contract compatibility](../contracts/compatibility.md)
- [Frontend platform testing strategy](../testing/frontend-platform-testing-strategy.md)
@@ -0,0 +1,718 @@
# Backend API와 Server State handoff contract
> **정본 안내 (non-authoritative for runtime capability decisions)**
>
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
> Service Worker, offline command와 Background Sync의 구현 결정은
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
- 상태: frontend handoff design accepted, backend implementation/evidence pending
- 기준일: 2026-07-28
- 대상: Web API/BFF, application service, persistence, identity, GraphQL router,
Connect/gRPC-Web gateway, Protobuf REST Gateway와 운영 owner
- frontend 기준:
[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)
- browser Protobuf/gateway 기준:
[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)
- 파일 전송 backend 기준:
[Server file capability infrastructure](./server-file-capability-infrastructure.md)
- 복구 절차:
[API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md)
## 1. 문서의 경계
이 문서는 이 frontend template이 실제 제품 backend와 연결될 때 backend가
제공해야 하는 구조, wire contract, 상태 의미와 운영 증거를 정의한다. 특정
언어·framework·cloud 제품을 강제하지 않는다. Spring, Nest/Fastify, Go,
.NET 또는 다른 stack을 사용해도 아래 불변조건은 동일하다.
이 repository에는 backend source, database migration, identity provider,
GraphQL schema/router, protobuf descriptor, Connect/gRPC-Web runtime/proxy,
REST transcoder와 실제 provider evidence가 없다. 따라서 이 문서는 backend 구현
완료 증거가 아니다.
파일 업로드·다운로드, object storage, presigned URL, multipart와 Image CDN은
별도 server file 문서가 소유한다. 이 문서는 ordinary REST/GraphQL/Connect/
gRPC-Web application API, Protobuf REST Gateway와 frontend Server State 계약만
소유한다.
## 2. 권장 논리 구조
```text
Browser
-> CDN / reverse proxy / WAF
-> Browser-facing Web API or BFF
-> authentication + authorization
-> exact operation registry
-> request schema / byte / rate limit
-> REST controller
-> optional persisted GraphQL router
-> optional Connect browser RPC gateway
-> optional gRPC-Web gateway
-> optional Protobuf REST transcoder
-> application service
-> command transaction
-> query/read-model service
-> idempotency coordinator
-> revision/validator owner
-> outbox/event owner
-> primary database
-> idempotency store
-> read replica/read model
-> event broker when selected
```
Browser-facing contract owner와 내부 service contract owner를 분리한다.
- Browser API/BFF는 CORS, cookie/CSRF 또는 bearer, public DTO, envelope,
body ceiling, status/media와 redaction을 소유한다.
- Application service는 authorization 재검사, transaction, idempotency,
conflict, revision과 domain invariant를 소유한다.
- Persistence adapter는 SQL/NoSQL/Redis vendor type, row version과 cursor
implementation을 외부 DTO에 노출하지 않는다.
- GraphQL router, Connect/gRPC-Web gateway와 REST transcoder는 선택 adapter다.
다른 protocol로 임의 fallback하거나 frontend에 내부 service address를
노출하지 않는다.
작은 제품은 이 논리 모듈을 하나의 deployable로 구현할 수 있다. deployable을
나누는 것보다 transaction/idempotency/authorization owner가 하나로 명확한지가
우선이다.
## 3. 공통 contract artifact
Backend와 frontend release는 다음 bounded contract set을 공유한다.
```text
ApiContractSetV1
globalApiContractVersion
restArtifactId + digest
runtimeSchemaManifestId + digest
mapperSemanticManifestId + digest
errorVocabularyVersion
minimumFrontendVersion
minimumBackendVersion
effectiveAt
retirementEpoch | null
optional:
graphqlSchemaId + digest
persistedGraphqlManifestId + digest
protobufDescriptorId + digest
protobufSourceOrModuleId + digest
protobufCodegenProfileId
connectProviderProfileId
grpcWebProviderProfileId
protobufRestGatewayProfileId
httpRuleArtifactId + digest
protoJsonProfileId
gatewayOpenApiArtifactId + digest
```
최소 산출물:
- authenticated OpenAPI 또는 동등한 REST schema source
- exact status/media/envelope fixture
- stable error code vocabulary
- request/response byte와 collection ceiling
- scalar/date/null/enum 의미
- N/N-1 compatibility 결과
- backend build와 frontend release가 참조하는 immutable digest
runtime config의 version 문자열 일치만 compatibility 증거로 사용하지 않는다.
artifact digest가 없는 동안에는 실제 staging conformance fixture와 수동 승인
evidence가 필요하다.
## 4. 현재 reference REST 계약
현재 frontend에 실제 조립된 operation은 다음 세 개다.
| operation | request | success |
| --- | --- | --- |
| `LIST_REFERENCE_RESOURCES` | `GET /api/reference-resources?cursor&limit&tags` | `200 application/json` |
| `GET_REFERENCE_RESOURCE` | `GET /api/reference-resources/{resourceId}` | `200 application/json` |
| `CREATE_REFERENCE_RESOURCE` | `POST /api/reference-resources` | `200` 또는 `201 application/json` |
현재 list payload는 `ReferenceResource[]`다. `cursor` 입력이 존재하더라도
`CursorPage` 출력 계약은 아직 아니다. backend가 같은 operation에서 배열을
page object로 조용히 바꾸면 schema mismatch로 실패한다.
resource DTO:
```text
ReferenceResourceDtoV1
id: non-empty string, maximum 120 characters
name: non-empty string, maximum 240 characters
createdAt?: RFC 3339 date-time
```
create command:
```text
CreateReferenceResourceCommandV1
name: trimmed string, 1..120
note?: trimmed string, 0..500
```
Backend는 frontend validation을 신뢰하지 않고 동일하거나 더 좁은 validation과
authorization을 다시 수행한다.
### 4.1 JSON envelope
모든 현재 JSON success/failure는 다음 envelope를 사용한다.
```json
{
"success": true,
"data": {},
"meta": {
"requestId": "server-request-id",
"traceId": "server-trace-id",
"correlationId": "client-correlation-id"
}
}
```
```json
{
"success": false,
"error": {
"code": "STABLE_MACHINE_CODE",
"category": "optional-safe-category",
"message": "optional non-sensitive copy",
"retryable": false,
"details": {}
},
"meta": {
"requestId": "server-request-id",
"traceId": "server-trace-id",
"correlationId": "client-correlation-id"
}
}
```
Envelope 최상위 unknown field는 현재 거절된다. ordinary resource DTO의 unknown
field는 frontend schema에서 strip되지만 additive compatibility는 contract
review와 fixture를 먼저 통과해야 한다.
`requestId`, `traceId`, `correlationId`는 각각 1..128 범위의 안전한 opaque
identifier다. credential, user data, cursor, validator와 database key를
identifier에 encode하지 않는다.
### 4.2 Status와 error
| HTTP status | 의미 |
| --- | --- |
| `400` | malformed request 또는 closed request contract 위반 |
| `401` | 인증 없음/만료. 이미 적용된 command를 401로 반환하지 않음 |
| `403` | authenticated principal에게 권한 없음 |
| `404` | authorization 정책상 공개 가능한 not-found |
| `409` | idempotency fingerprint, domain revision 또는 semantic conflict |
| `412` | selected conditional write의 `If-Match` precondition 실패 |
| `422` | field validation. bounded `details.issues[]`만 허용 |
| `429` | rate limit. 유효한 `Retry-After`와 operation 정책 제공 |
| `500/502/503/504` | server/provider failure. command effect certainty 별도 |
현재 frontend의 ordinary status mapper는 `412` 전용 처리를 아직 연결하지
않았다. conditional mutation을 선택할 때 frontend failure vocabulary와
transaction을 함께 승격해야 한다.
Backend `error.code`는 machine-readable stable code다. stack, SQL/vendor error,
raw validation value, authorization reason과 내부 service address를 반환하지
않는다.
## 5. 인증, CSRF와 CORS
현재 reference operation은 다음 profile로 조립돼 있다.
```text
auth = external bearer
Authorization: Bearer <credential>
fetch credentials = omit
CSRF profile = none
redirect = error
referrer policy = no-referrer
```
Backend/BFF는 bearer의 issuer, audience, signature algorithm, time claims와
revocation/session policy를 검증하고 operation별 authorization을 적용한다.
401과 403을 구분하며 frontend cache를 authorization authority로 사용하지 않는다.
쿠키 session으로 전환할 경우 같은 profile로 간주하지 않는다. 별도
`SAME_ORIGIN_COOKIE` profile에 다음을 함께 승인한다.
- `Secure`, `HttpOnly`, 명시적 `SameSite`와 host/path scope
- unsafe method의 CSRF token/header와 Origin/Sec-Fetch-Site 검증
- credentialed CORS에서 wildcard origin 금지
- login/logout/session rotation과 cache generation 전환
- session fixation, token rotation과 concurrent tab 동작
Cross-origin bearer provider 최소 CORS:
- exact allow-origin 목록과 bounded preflight cache
- `Authorization`, `Content-Type`, `Idempotency-Key`,
`X-Correlation-ID`, 향후 `If-None-Match`, `If-Match` 허용
- 필요한 경우 `ETag`, `Retry-After`, request/trace header만 expose
- redirect login page, HTML error body와 wildcard credential 금지
## 6. Command와 idempotency
`CREATE_REFERENCE_RESOURCE`는 keyed command다. frontend memory single-flight는
backend idempotency를 대체하지 않는다.
idempotency identity:
```text
principal/tenant
+ semantic operation ID and contract version
+ Idempotency-Key
+ canonical request fingerprint
```
권장 record:
```text
IdempotencyRecord
principalFingerprint
operationId
contractVersion
idempotencyKeyHash
requestFingerprint
state = IN_PROGRESS | COMMITTED | FAILED_SAFE | EFFECT_UNKNOWN
responseStatus
responseEnvelopeReference
resourceRevision | null
leaseOwner + leaseExpiry
retentionExpiry
createdAt + completedAt
```
불변조건:
- claim과 command transaction의 관계가 원자적이거나 crash reconciliation
가능해야 한다.
- 같은 key와 같은 fingerprint replay는 같은 authoritative receipt를 반환한다.
- 같은 key와 다른 fingerprint는 `409 IDEMPOTENCY_KEY_REUSED`다.
- concurrent replay는 하나만 실행하고 나머지는 같은 result를 기다리거나
bounded `IN_PROGRESS` 결과를 받는다.
- commit 뒤 response 유실은 새 resource를 만들지 않는다.
- `EFFECT_UNKNOWN`은 새 key로 자동 재시도하지 않고 status/reconcile endpoint로
확인한다.
- retention은 frontend retry/recovery 최대 window보다 길고 quota/abuse limit이
있다.
- key 원문과 request body를 log/metric label에 넣지 않는다.
Database unique constraint 또는 durable compare-and-set이 최종 중복 방지
authority여야 한다. process-local map/lock만 사용하지 않는다.
## 7. Cursor pagination과 snapshot
Backend가 pagination을 선택할 때 새 response schema/operation version으로 다음
contract를 제공한다.
```text
CursorPage<T>
items: T[]
nextCursor: opaque string | null
hasMore: boolean
snapshotToken: opaque string | null
```
필수 불변조건:
- `hasMore === (nextCursor !== null)`
- 동일 chain의 `snapshotToken`은 모든 page에서 동일
- cursor는 principal/tenant, filter, sort, contract version과 snapshot에 binding
- cursor는 opaque, 무결성 보호, 만료와 key rotation 정책 보유
- offset이 아니라 stable keyset ordering 사용
- total order의 마지막 tie-breaker는 immutable unique ID
- deleted/inserted row가 duplicate/gap을 만드는 의미를 snapshot 정책으로 결정
- empty page인데 `hasMore=true`인 sparse page 허용 여부를 operation profile에 고정
- cursor 최대 encoded byte, page size와 total scan/cost ceiling을 server도 강제
- invalid, expired, wrong-principal, wrong-filter cursor의 safe error code를 고정
권장 query ordering 예:
```text
ORDER BY created_at DESC, resource_id DESC
cursor payload = version + snapshot watermark + last(created_at, resource_id)
+ filter digest + principal/tenant binding + expiry
```
Cursor 원문은 log, trace, analytics와 frontend persistent storage에 넣지 않는다.
### 7.1 배열에서 page로의 migration
1. `ReferenceResourceListPagePayloadV2` schema와 새 operation/version을 추가한다.
2. backend가 N/N-1 동안 기존 배열과 page contract를 동시에 제공한다.
3. frontend가 cursor runtime을 새 bound query/infinite query에 연결한다.
4. loop/snapshot/ceiling/abort conformance를 staging에서 검증한다.
5. 새 operation을 canary한 뒤 기존 배열 operation을 retirement한다.
동일 media/status에서 payload shape만 바꾸는 in-place migration은 금지한다.
## 8. Conditional read와 revision/CAS
### 8.1 Read validator
Backend가 application-managed revalidation을 선택하면 exact mapped
representation마다 ETag를 제공한다.
```text
GET without validator
-> 200 + JSON envelope + ETag
GET with If-None-Match
-> representation unchanged: 304 + empty body
-> changed: 200 + JSON envelope + new ETag
```
불변조건:
- validator는 principal/tenant, authorization-visible representation,
response schema/mapper semantics와 encoding variant에 binding
- weak/strong 선택을 operation profile에 고정
- user-private response를 shared CDN/public cache에 저장하지 않음
- cross-origin이면 `ETag`를 expose하고 `If-None-Match`를 preflight 허용
- 304에는 JSON success envelope를 넣지 않음
- validator 원문을 log/metric/diagnostics에 넣지 않음
- `Vary``Cache-Control` owner를 명확히 하고 browser HTTP cache와
TanStack/application revalidation이 서로 다른 value owner가 되지 않게 함
Frontend는 validator와 mapped cache value의 scope, query identity,
representation version과 cache revision이 모두 일치할 때만 304를 success로
받는다. cache value가 없으면 unconditional refetch 또는 safe failure로 닫는다.
### 8.2 Conditional command
수정/삭제 command가 선택되면 DTO에 opaque domain `revision`을 추가하고:
```text
If-Match: "<revision validator>"
```
를 요구한다. 일치하지 않으면 `412` 또는 승인된 `409` contract 하나만
사용한다. frontend optimistic layer의 commit/rollback은 backend revision
authority를 대체하지 않는다.
## 9. Optimistic mutation을 위한 backend 의미
Frontend ordered optimistic layer runtime은 구현돼 있지만 제품 operation에
연결하려면 backend가 다음을 결정해야 한다.
- resource/list membership을 결정하는 canonical filter와 sort
- command가 생성/수정/삭제하는 stable identity
- server-assigned ID와 client correlation의 reconcile 방법
- authoritative resource/list revision
- conflict status와 stable error code
- commit response가 complete resource인지 receipt인지
- effect certainty와 idempotency status/reconcile endpoint
- event/outbox가 있을 때 sequence/gap/snapshot reset 의미
Create가 server-assigned ID를 사용하는 경우 temporary UI ID를 backend ID로
원자적으로 교체하고 관련 detail/list key를 reconcile하는 정책이 필요하다.
이 의미 없이 generic optimistic append를 기본 활성화하지 않는다.
## 10. Database와 application service baseline
구현 예시는 다음 논리 table/constraint를 만족해야 한다.
```text
reference_resource
tenant_id
resource_id
display_name
note
revision
created_at
updated_at
deleted_at | null
unique(tenant_id, resource_id)
idempotency_record
principal/tenant fingerprint
operation + contract version
key hash
request fingerprint
state + receipt
lease/retention timestamps
unique(principal/tenant, operation, contract version, key hash)
outbox_event when selected
aggregate identity + revision
event type/version
sequence
payload reference or bounded safe projection
publication state
```
Application service transaction은 authorization scope와 tenant predicate를
모든 read/write에 적용하고, resource mutation과 revision/outbox 기록을 같은
transaction boundary에 둔다. cache/replica lag를 고려해 command 직후 read
consistency와 invalidation owner를 선언한다.
## 11. GraphQL 선택 시 추가 구조
GraphQL은 제품 operation이 REST보다 aggregation 이점을 실제로 가질 때만
선택한다.
```text
Browser
-> persisted-operation endpoint
-> manifest allowlist
-> auth/CSRF/rate/cost/depth/alias enforcement
-> GraphQL router
-> application services/loaders
```
Backend handoff:
- authenticated immutable schema artifact와 digest
- named operation source와 persisted ID/hash manifest
- variables/result runtime fixtures
- selected GraphQL-over-HTTP revision과 exact media/status profile
- partial data policy와 safe error extension vocabulary
- field/row authorization, cost/depth/alias/list ceiling
- N/N-1 router/frontend manifest rollout과 retirement
Production endpoint는 arbitrary document와 persisted miss 후 full-document
fallback을 받지 않는다. normalized frontend entity cache는 별도 제품 선택이다.
## 12. gRPC-Web 선택 시 추가 구조
gRPC-Web은 browser-facing gateway/proxy가 실제 선택된 unary 또는 bounded
server-stream operation에만 사용한다.
```text
Browser
-> same-origin BFF/Envoy/gRPC-Web gateway
-> exact service/method allowlist
-> frame/message/deadline/status/trailer enforcement
-> internal gRPC application service
```
Backend handoff:
- authenticated proto source와 immutable descriptor digest
- Buf/protoc lint/breaking 및 deterministic generation evidence
- exact service/method/rpc-kind allowlist
- selected gRPC-Web runtime kind, client API와 binary/JSON/text/wire revision;
official XHR와 Connect-Web Fetch profile을 분리
- proxy CORS, content-type, terminal status/trailer behavior
- Envoy를 선택하면 exact version/config digest, filter order, upstream HTTP/2,
route/idle/max-stream timeout, timeout offset와 buffering/flush
- message/frame/count/queue/idle/total budget
- server-stream sequence, gap, resume와 snapshot reset protocol
- actual browser/proxy conformance
client streaming과 bidirectional streaming은 common gRPC-Web browser contract로
간주하지 않는다. upload는 REST transfer, duplex는 별도 protocol을 선택한다.
## 13. Connect-Web/Connect 선택 시 추가 구조
Connect는 Protobuf-first backend의 selected unary 또는 bounded server-stream
operation에만 사용한다. Connect protocol과 Connect-Web의 gRPC-Web transport는
서로 다른 provider row다.
```text
Browser Connect-Web adapter
-> same-origin BFF 또는 exact cross-origin Connect endpoint
-> auth/CSRF/CORS + service/method allowlist
-> Connect protocol handler
-> application service
```
Backend handoff:
- authenticated proto/Buf source, descriptor와 generated-service digest
- exact Connect-Web/client runtime과 server/gateway version
- protocol revision, JSON/binary encoding, POST 또는 approved GET
- unary HTTP/error profile 또는 stream EndStream terminal profile
- request/response/envelope/message/count/queue byte ceiling
- unary/stream compression capability; stock browser stream은 identity-only
- total/idle timeout, browser abort→server context→downstream cancellation 전파
- exact CORS allow/expose/preflight와 auth/CSRF profile
- actual Chromium/Firefox/WebKit와 selected proxy/server conformance
GET은 descriptor `NO_SIDE_EFFECTS`, non-sensitive bounded input, URL/cache key,
`Vary`와 credential policy가 모두 승인된 unary에만 허용한다. browser
client-streaming/bidi는 Connect protocol 자체 기능과 별개로 `PLATFORM_LIMITED`다.
## 14. Protobuf REST Gateway 선택 시 추가 구조
한 route는 `CURATED_BFF | GRPC_GATEWAY | ENVOY_TRANSCODER` 중 하나만 소유한다.
현재 reference REST의 envelope와 `200|201`, 향후 `204/304/412` 의미를 유지하는
기본 선택은 curated BFF다.
Direct gateway는 ProtoJSON/HttpRule/status/error 자체를 새 public contract로
승인한 unary operation에서만 선택한다. Backend handoff:
- `.proto` annotation 또는 precedence가 고정된 service config의 immutable source
- descriptor/Buf image, canonical HttpRule route manifest와 digest
- pinned gateway/runtime/generator/plugin과 generated OpenAPI artifact
- ProtoJSON name/default/enum/int64/bytes/null/presence/unknown-field profile
- exact method/path/query/body/response-body/additional-binding와 path escaping
- safe status/error/header mapping과 raw `google.rpc.Status` detail redaction
- CORS/auth/CSRF, body/header/query ceiling와 rate limit
- browser abort/deadline의 upstream gRPC/application work 전파
- N/N-1 route/OpenAPI/runtime conformance와 coherent rollback
Gateway는 durable idempotency, pagination snapshot, ETag/HTTP conditional,
product envelope, authorization와 file transfer semantics를 자동 구현하지 않는다.
필요한 operation은 application service와 BFF가 계속 소유한다. generated
REST streaming은 별도 framing/terminal/cache ADR 없이는 `NOT_SELECTED`다.
## 15. Invalidation과 realtime
현재 frontend cross-tab invalidation은 같은 browser origin 안의 opaque
invalidate-only hint다. backend event delivery를 의미하지 않는다.
Backend-driven invalidation/realtime을 선택하면:
- transactional outbox 또는 동등한 durable publication
- principal/tenant authorization을 통과한 event projection
- event type/version, aggregate revision, sequence와 dedupe identity
- reconnect cursor, gap detection과 snapshot reset
- retention, replay ceiling과 slow-consumer policy
를 제공해야 한다. event payload를 authoritative resource snapshot으로 쓸지
query invalidate hint로만 쓸지 operation별 reducer contract가 필요하다.
## 16. Rate limit, deadline와 retry
- backend deadline은 frontend total deadline보다 짧거나 cancellation을 전파할 수
있어야 한다.
- disconnect/cancel 뒤 불필요한 query 작업은 중단한다.
- keyed command는 disconnect가 transaction rollback을 보장하지 않으므로
idempotency receipt로 effect를 판정한다.
- `Retry-After`는 selected status에서만 bounded delta/date 형식으로 제공한다.
- retry-safe read와 keyed command를 구분한다.
- proxy, BFF와 service retry가 겹쳐 retry amplification을 만들지 않게 한 owner만
재시도한다.
- rate limit key는 principal/tenant/operation과 abuse policy에 binding하며 raw
credential/IP를 metric label에 넣지 않는다.
## 17. Observability와 privacy
허용되는 공통 dimension:
```text
operation ID
contract/profile version
status group / safe error code
attempt bucket
duration bucket
provider/runtime health
traffic admission stage
```
금지:
- Authorization, cookie, CSRF와 idempotency key
- request/response body와 validation value
- URL query, cursor, snapshot, ETag/revision
- GraphQL variables/path/raw error/extensions
- protobuf bytes, metadata와 trailer 원문
- user ID/email/file name을 metric label이나 trace attribute로 사용
Request ID와 trace ID는 browser에 반환할 수 있지만 credential 역할을 하지 않으며
추측 가능한 database primary key를 포함하지 않는다.
필수 SLO/alert 후보:
- operation availability와 latency
- 401/403/409/412/422/429 및 5xx rate
- schema/mapper/contract mismatch
- idempotency in-progress age, collision과 unknown effect
- cursor invalid/expired/loop-equivalent server detection
- conditional hit/miss와 invalid 304
- GraphQL persisted miss/cost reject
- Connect missing/duplicate EndStream, whole-body/queue overflow와 compression mismatch
- gRPC-Web missing terminal status, frame/idle/queue overflow
- REST Gateway route/OpenAPI/runtime rewrite drift와 cancel propagation loss
## 18. 배포, compatibility와 rollback
권장 순서:
1. contract artifact와 compatibility diff를 생성한다.
2. backend가 N/N-1 fixture를 통과한 상태로 먼저 배포한다.
3. frontend operation은 traffic disabled 상태에서 staging conformance를 실행한다.
4. read-only shadow/canary 뒤 query traffic을 올린다.
5. keyed command는 idempotency/reconcile fault injection 뒤 별도 canary한다.
6. pagination, conditional, optimistic, GraphQL, Connect, gRPC-Web과 REST
Gateway는 각각 독립 gate로 승격한다.
7. provider/browser/operations evidence가 완료된 operation만 enabled한다.
Rollback은 frontend/backend/contract artifact를 coherent set으로 되돌린다.
unknown-effect command를 다른 protocol이나 새 idempotency key로 replay하지 않는다.
Backend가 old contract를 제거하는 시점은 실제 frontend support window와 cache/CDN
retention 뒤다.
## 19. Conformance와 fault-injection matrix
Backend 완료 판정에는 unit test 외에 actual staging provider evidence가 필요하다.
| 범위 | 필수 증거 |
| --- | --- |
| REST | exact path/query/body, media/status/envelope, max body, malformed/truncated JSON |
| Auth | missing/expired credential, 401/403, rotation, cross-origin preflight |
| Command | concurrent same-key replay, fingerprint mismatch, commit 뒤 response loss |
| Cursor | filter/sort binding, expiry, snapshot stability, loop/gap/duplicate 방지 |
| Conditional | 200→304, cache-missing 304 방지, representation change, 412 |
| Schema | additive/breaking/null/enum/time/number fixtures와 N/N-1 |
| GraphQL | persisted hit/miss/hash mismatch, partial, cost/depth, router rollout |
| Connect | JSON/binary unary, GET restriction, EndStream, body/message cap, compression, cancel/deadline와 CORS |
| gRPC-Web | proxy media/status/trailer, oversized frame, cancel, idle, gap/resume |
| REST Gateway | HttpRule path/query/body, ProtoJSON, OpenAPI/status/error rewrite, abort propagation과 N/N-1 |
| Operations | deadline/retry amplification, rate limit, kill switch, coherent rollback |
## 20. Backend handoff checklist
- [ ] Browser-facing API/BFF owner와 on-call이 정해졌다.
- [ ] reference REST exact endpoint/envelope/status/media fixture가 있다.
- [ ] bearer 또는 cookie+CSRF 중 하나의 실제 profile과 CORS evidence가 있다.
- [ ] stable error vocabulary와 redaction contract가 있다.
- [ ] keyed command idempotency store, TTL, receipt와 reconcile이 있다.
- [ ] CursorPage를 선택했다면 opaque cursor/snapshot contract가 있다.
- [ ] conditional을 선택했다면 ETag/304/412와 cache owner가 있다.
- [ ] optimistic을 선택했다면 identity/membership/revision/conflict 의미가 있다.
- [ ] OpenAPI/runtime schema/mapper semantic artifact와 digest가 release에 binding됐다.
- [ ] GraphQL을 선택했다면 schema/persisted manifest/router evidence가 있다.
- [ ] Connect를 선택했다면 descriptor/runtime/server/browser evidence가 있다.
- [ ] gRPC-Web을 선택했다면 descriptor/proxy/browser evidence가 있다.
- [ ] REST Gateway를 선택했다면 kind/HttpRule/ProtoJSON/OpenAPI와 runtime
conformance evidence가 있다.
- [ ] staging conformance, fault injection, canary, kill switch와 rollback drill이
통과했다.
## 21. Frontend 완료 경계
Backend 구현과 별개로 현재 frontend 상태를 다음처럼 해석한다.
| 범위 | 현재 상태 | 남은 owner |
| --- | --- | --- |
| REST path/provider/auth/deadline/bounded JSON | `COMPOSED` | actual provider conformance는 backend/operations |
| runtime schema와 mapper registry | `COMPOSED` | artifact digest/source provenance는 backend contract source + frontend/platform |
| session generation과 query identity | `COMPOSED` | account identity projection은 identity integration + frontend |
| Cursor runtime | `AVAILABLE_NOT_COMPOSED` | CursorPage backend 계약 후 frontend query binding |
| conditional validator store | `AVAILABLE_NOT_COMPOSED` | ETag/304/412 backend 계약 후 frontend HTTP/cache transaction |
| ordered optimistic layer | `AVAILABLE_NOT_COMPOSED` | product membership/revision 승인 후 frontend mutation definition |
| GraphQL adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend 선택 뒤 frontend adapter/codegen |
| Browser RPC V3 공통 계약/coordinator | `AVAILABLE_NOT_COMPOSED` | selected descriptor/generated client와 protocol transport 확정 뒤 frontend provider adapter |
| Protobuf schema/codegen | `DESIGNED_NOT_IMPLEMENTED` | authenticated backend contract source 선택 뒤 pinned frontend generation |
| Connect-Web adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend/server 선택 뒤 frontend adapter/codegen |
| gRPC-Web adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend/proxy 선택 뒤 frontend adapter/codegen |
| Protobuf REST Gateway | `NOT_SELECTED` | gateway kind와 public HTTP contract 승인 뒤 REST adapter binding |
| persisted query/offline command | `NOT_SELECTED` | 별도 product ADR와 backend durability 계약 |
따라서 “backend만 구현하면 frontend가 아무 변경 없이 모든 capability를 자동
사용한다”는 의미는 아니다. 현재 선택된 REST reference vertical의 공통 frontend
기반은 완료됐지만, backend contract가 확정되면 Cursor/conditional/optimistic의
마지막 composition과 schema/mapper 변경이 frontend에 남는다. GraphQL,
Connect/gRPC-Web과 Protobuf REST Gateway는 제품이 선택되지 않았다. 공통 Browser
RPC operation/profile registry, application port와 lifecycle coordinator는
구현했지만, wire별 generated client/decoder/provider binding은 아직 구현하지
않았다. 따라서 backend contract가 정해져도 frontend provider adapter와
composition 작업은 명시적으로 남는다.
@@ -0,0 +1,385 @@
# Browser data capability completion ledger
## 1. 목적
이 문서는 다음 browser data capability의 **현재 구현 상태, 목표 상태, 남은
공통 구현, 제품 조합 책임, backend/provider 계약과 promotion 조건**을 한곳에서
관리하는 기준 문서다.
- File, Blob, 파일 선택기, preview와 다운로드
- Local/Session Storage, IndexedDB, OPFS와 Cache Storage
- TanStack Query memory cache와 탭 간 무효화
- Presigned URL, multipart/resumable upload와 streaming download
- Range resumable download와 background upload/download
- Image CDN descriptor, 검증, delivery와 presentation
각 상세 문서는 메커니즘과 불변조건을 설명한다. 이 ledger는 상세 문서를
대체하지 않으며, 서로 다른 문서의 "구현됨", "사용 가능", "설계됨" 표현이
production readiness로 잘못 합쳐지는 것을 막는 상태 단일 기준이다.
이 문서가 정한 상태만으로 실제 제품의 `PRODUCTION_READY`를 주장할 수 없다.
제품 owner, backend/provider conformance와 세 browser promotion evidence가 모두
별도 gate를 통과해야 한다.
## 2. 상태 체계
### 2.1 Primary current status
각 capability는 다음 다섯 상태 중 정확히 하나를 갖는다.
| 상태 | 의미 | 허용되는 주장 |
| --- | --- | --- |
| `COMPOSED` | production bootstrap 또는 설치된 feature 호출 경로에 concrete runtime이 연결돼 있다. | 저장소의 현재 제품 경로에서 실행된다. |
| `AVAILABLE_NOT_COMPOSED` | port, policy와 reference runtime이 있으나 기본 production graph에서는 제거돼 있다. | opt-in 조합 후보가 존재한다. |
| `DESIGNED_NOT_IMPLEMENTED` | 불변조건과 계약은 승인됐지만 해당 runtime 또는 필수 orchestration이 없다. | 설계/계약 backlog가 닫혔고 구현 backlog는 열려 있다. |
| `NOT_SELECTED` | 가치, 비용, 보안과 운영 owner가 승인되지 않아 의도적으로 선택하지 않았다. | 누락이 아니라 미선택이다. |
| `PLATFORM_LIMITED` | 브라우저 공통 보장이 불가능하거나 지원 범위가 제한된다. | capability probe와 fallback 안에서만 제공할 수 있다. |
`AVAILABLE_NOT_COMPOSED``COMPOSED`로 표시하거나,
`DESIGNED_NOT_IMPLEMENTED`를 테스트 fixture만으로 구현 완료 처리하지 않는다.
`NOT_SELECTED` capability를 인접 runtime의 "미완성"으로 계산하지 않는다.
### 2.2 독립적인 canonical readiness 축
Primary status와 다음 네 canonical 축을 섞지 않는다. VD-15와 이 ledger를
참조하는 운영 runbook도 축 이름과 literal을 정확히 이 표에 맞춘다.
| canonical 축 | 값 | 의미 |
| --- | --- | --- |
| `Selection` | `NOT_SELECTED`, `SELECTED`, `REMOVING` | 특정 제품이 capability를 채택했는지 여부 |
| `TrafficAdmission` | `DISABLED`, `SHADOW`, `CANARY`, `ENABLED` | 조합된 runtime의 신규 작업 admission |
| `RuntimeHealth` | `UNKNOWN`, `AVAILABLE`, `DEGRADED`, `UNAVAILABLE`, `INCOMPATIBLE` | 현재 runtime/provider 관측 상태 |
| `PromotionEvidence` | `MISSING`, `PARTIAL`, `COMPLETE`, `EXPIRED` | 필요한 contract/provider/browser/operations 증거의 합성 결과 |
`PromotionEvidence`의 입력은 다음 component gate다. 이 값들은 새로운 readiness
축이 아니라 합성 근거이며 evidence record와 함께 보존한다.
| component gate | 값 | 의미 |
| --- | --- | --- |
| contract | `MISSING`, `DRAFT`, `ACCEPTED` | frontend와 provider가 맞출 wire/behavior 계약 상태 |
| provider | `NOT_REQUIRED`, `PENDING`, `CONFORMANT` | 실제 BFF, object storage, CDN 또는 hosting 증거 |
| browser | `MISSING`, `PARTIAL`, `PROMOTABLE` | 승인 browser/device matrix의 native 증거 |
| operations | `MISSING`, `DOCUMENTED`, `DRILLED` | 관측, kill switch, recovery와 rollback 실행 증거 |
projection은 다음처럼 고정한다.
- 필수 component artifact가 없으면 `MISSING`이다.
- 유효한 일부 증거만 있거나 component가 terminal gate 전이면 `PARTIAL`이다.
- contract가 `ACCEPTED`, provider가 `NOT_REQUIRED` 또는 `CONFORMANT`, browser가
`PROMOTABLE`, operations가 `DRILLED`이고 모든 required artifact가 유효할 때만
`COMPLETE`다.
- 한 번 유효했던 required artifact가 정책의 freshness/expiry를 넘으면 다른
component 값과 무관하게 `EXPIRED`다.
예를 들어 Image CDN reference runtime은
`AVAILABLE_NOT_COMPOSED / Selection=NOT_SELECTED /
TrafficAdmission=DISABLED / RuntimeHealth=UNKNOWN /
PromotionEvidence=PARTIAL`이고 그 근거가
`contract=ACCEPTED / provider=PENDING / browser=PARTIAL /
operations=DOCUMENTED`일 수 있다. 이 행을 `COMPOSED`
`PRODUCTION_READY`로 줄여 쓰지 않는다.
### 2.3 가능한 구현 경로
다음은 제품이 아직 선택하지 않았고 reference source도 없는 capability가 거칠 수
있는 **일반적인 경로 예시**다. 다섯 primary status를 선형 maturity로 정의하지
않으며 모든 capability가 이 경로를 밟는 것도 아니다. 이미 reference runtime이
있는 capability는 `AVAILABLE_NOT_COMPOSED`에서 시작할 수 있고, cross-browser
의미가 불가능한 capability는 구현량과 무관하게 `PLATFORM_LIMITED`다.
```text
NOT_SELECTED
-> decision + owner + data classification
-> DESIGNED_NOT_IMPLEMENTED
-> implementation + deterministic evidence + removal evidence
-> AVAILABLE_NOT_COMPOSED
-> product policy + provider contract + bootstrap composition
-> COMPOSED
-> provider/browser/operations promotion gates
-> product-local production approval
```
`PLATFORM_LIMITED`는 위 흐름과 별도 제약이다. 지원 가능한 browser에서는
지원 browser용 runtime 행을 별도 상태로 기록할 수 있지만, cross-browser 보장
행의 primary status는 계속 `PLATFORM_LIMITED`다. 제품 계약은 지원 불가능한
browser의 fallback을 동시에 선언해야 한다.
rollback은 상태를 거꾸로 가장하지 않는다. 신규 진입을 kill switch로 닫고,
active operation을 drain 또는 abort하고, durable state를 정책대로 정리한 뒤
composition과 production module을 제거한다.
## 3. 구현 책임 분류
남은 항목은 다음 네 분류 중 하나 이상을 갖는다.
| 분류 | owner | 설명 |
| --- | --- | --- |
| `COMMON_REQUIRED` | frontend platform | 제품 API 주소 없이도 구현할 수 있고 선택 capability의 안전성에 필수인 port, state machine, policy와 lifecycle |
| `PRODUCT_COMPOSITION` | product/feature owner | dataset, account partition, UX, retention, quota priority, query/preset profile과 use-case facade |
| `PROVIDER_CONTRACT` | backend/storage/CDN/infra owner | authorization, signing, server ledger, storage constraint, CDN preset와 conformance |
| `OPTIONAL_CAPABILITY` | architecture + product approval | 필요성이 확인될 때 별도 threat model과 비용 승인을 거쳐 설치할 기능 |
`COMMON_REQUIRED`는 범용 mega-service를 뜻하지 않는다. 메커니즘은 공통이지만
정책 값은 immutable composition snapshot으로 주입한다.
## 4. 현재 capability snapshot
### 4.1 File, Blob, picker와 download
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
| --- | --- | --- | --- | --- |
| File/Blob intake | `AVAILABLE_NOT_COMPOSED` | opaque file ref, transient vault, metadata normalization, byte/type/signature policy, bounded range read, closed-result stream | native chunk가 hard maximum을 넘지 않도록 재분할하는 ceiling과 제품 profile | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
| native input picker | `AVAILABLE_NOT_COMPOSED` | keyboard/focus 가능한 input baseline, multiple, same-file reselection, dismissal outcome | 제품별 copy와 workflow | `PRODUCT_COMPOSITION` |
| enhanced open picker | `AVAILABLE_NOT_COMPOSED` | user activation과 conditional enhancement | browser matrix와 native input fallback 유지 | `PRODUCT_COMPOSITION` |
| directory selection | `NOT_SELECTED` | 없음 | bounded traversal, relative-path policy, symlink/entry ceiling | `OPTIONAL_CAPABILITY` |
| persistent file handle | `NOT_SELECTED` | native handle은 transient vault 밖으로 나가지 않음 | permission recovery, handle registry, retention/logout | `OPTIONAL_CAPABILITY` |
| drag/drop·paste·capture | `NOT_SELECTED` | 공통 file capture primitive 일부만 재사용 가능 | 별도 adapter와 접근 가능한 UX | `OPTIONAL_CAPABILITY` |
| object URL preview lease | `AVAILABLE_NOT_COMPOSED` | receipt binding, active-content denylist, byte cap, lease/revoke | 제품이 preview를 선택할 때 safety probe와 함께 조합 | `PRODUCT_COMPOSITION` |
| local preview decode-safety probe | `DESIGNED_NOT_IMPLEMENTED` | 현재 dimension/pixel/decoded-memory/animation preflight 없음 | object URL 발급 전 static header/decode budget 검증 | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
| browser-managed download | `AVAILABLE_NOT_COMPOSED` | synchronous resolver/vault seam과 `BROWSER_HANDOFF` outcome을 saved와 구분 | concrete BFF issuer/strict response와 제품 open/share/save UX | `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` |
| picker streaming save | `AVAILABLE_NOT_COMPOSED` | bounded stream, backpressure, integrity, close/abort truth | capability/size 기반 strategy selector | `COMMON_REQUIRED` |
| bounded Blob download | `AVAILABLE_NOT_COMPOSED` | small generated artifact hard cap | browser별 상한과 server-generation fallback | `PRODUCT_COMPOSITION` |
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | 현재 one-shot download와 명시적으로 분리 | Range/If-Range/206, validator, checkpoint, seek/truncate, final integrity | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` |
| app-managed background download | `NOT_SELECTED` | browser-managed handoff만 존재 | 지원 browser의 progressive enhancement로만 평가 | `OPTIONAL_CAPABILITY` |
| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | 장시간 worker/picker/file permission 유지가 공통 보장되지 않음 | browser-managed handoff 또는 explicit unsupported fallback | 플랫폼 제약 |
### 4.2 Query, Web Storage와 cross-context
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
| --- | --- | --- | --- | --- |
| TanStack Query memory cache | `COMPOSED` | concrete QueryClient, cancellation, stale UI, optimistic rollback, invalidate | session/account scope lifecycle, late-result fence, strict query policy/key codec | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
| Local Storage registry | `COMPOSED` | 등록 key, closed codec/envelope, TTL, global hard cap, memory fallback | key별 cap, partition/logout, migration, explicit outcome, bounded sweep | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
| Session Storage registry | `COMPOSED` | tab-scoped 등록 control record와 동일 codec | key별 cap과 explicit durability/outcome | `COMMON_REQUIRED` |
| cross-tab invalidation | `COMPOSED` | invalidate-only, versioned envelope, duplicate/stale/gap 처리, BroadcastChannel→localStorage→local-only | account epoch, exact storage source, production coordinator browser E2E | `COMMON_REQUIRED` |
| IndexedDB query persistence reference runtime | `DESIGNED_NOT_IMPLEMENTED` | persistence key는 disabled로 강제되고 persister source는 없음 | 승인 query만 dehydrate/hydrate하는 facade | 선택 시 `COMMON_REQUIRED` |
| product query persistence | `NOT_SELECTED` | persist 대상 query, owner와 retention 승인이 없음 | reference runtime 구현 뒤 별도 opt-in | `OPTIONAL_CAPABILITY` |
| durable cache namespace epoch | `DESIGNED_NOT_IMPLEMENTED` | release epoch만 존재 | persisted resurrection 방지 transaction ledger | persistence 선택 시 `COMMON_REQUIRED` |
| offline mutation command queue | `NOT_SELECTED` | foreground optimistic mutation만 존재 | idempotent durable command/sync protocol | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` |
| SSR hydration | `NOT_SELECTED` | 현재 client SPA | request-scoped QueryClient와 precedence | SSR 선택 시 `PRODUCT_COMPOSITION` |
### 4.3 IndexedDB, OPFS와 Cache Storage
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
| --- | --- | --- | --- | --- |
| generic IndexedDB runtime | `AVAILABLE_NOT_COMPOSED` | transaction-complete, CAS, idempotency, logical budget, TTL, lifecycle authority, additive DDL, resumable codec migration, blocked/versionchange | feature dataset repository/schema/codec/query와 production composition | `PRODUCT_COMPOSITION` |
| OPFS byte runtime | `AVAILABLE_NOT_COMPOSED` | DedicatedWorker SyncAccessHandle, async fallback, Web Locks, hash tree, IDB journal saga, budget/GC/reconcile | 제품 namespace/dataset policy와 production composition | `PRODUCT_COMPOSITION` |
| OPFS real readiness preflight | `DESIGNED_NOT_IMPLEMENTED` | 없음; API property probe와 별도 native conformance test만 존재 | worker/lock/journal/small write-read-delete-cleanup을 한 readiness operation으로 검증 | `COMMON_REQUIRED` |
| OPFS physical/journal forward migration | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 v1 layout/journal과 reconciliation만 존재 | copy-on-write generation, checkpoint, publish authority와 N-1 rollback | `COMMON_REQUIRED` |
| public static Cache release runtime | `AVAILABLE_NOT_COMPOSED` | same-origin public GET, exact Vary/URL/type/size/digest, stage/activate/previous rollback | 제품 release/hosting policy와 production composition | `PRODUCT_COMPOSITION` |
| bounded Cache inspect/cleanup | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 ownership 검사는 지키지만 cache-count scan은 unbounded | policy/epoch-bound cursor, count/deadline과 partial-success resume | `COMMON_REQUIRED` |
| Cache control/prefix forward migration | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 v1 control/prefix parser와 release primitive만 존재 | 새 schema candidate, verify/activate, N-1 retain과 bounded cleanup | `COMMON_REQUIRED` |
| cross-store quota lifecycle | `DESIGNED_NOT_IMPLEMENTED` | store별 logical budget과 StorageManager signal은 존재 | write admission, pressure hysteresis, GC priority, one retry, scheduled maintenance | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
| Service Worker offline fetch | `NOT_SELECTED` | Cache Storage runtime은 window에서도 독립 사용 가능 | registration, install/waiting/activation, client drain, navigation strategy | `OPTIONAL_CAPABILITY` |
| private response cache | `NOT_SELECTED` | 현재 public cache가 명시적으로 거절 | 별도 partition/encryption 오해 방지/retention threat model | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` |
| sparse Range cache | `NOT_SELECTED` | `Range` request와 206 response를 거절 | validator-bound sparse segment merge | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` |
### 4.4 Presigned transfer, upload와 Image CDN
| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 |
| --- | --- | --- | --- | --- |
| presigned capability | `AVAILABLE_NOT_COMPOSED` | fixed endpoint provider, strict binding, in-memory single-use vault, safe data-plane fetch | explicit download wire version, browser-handoff provider, actual signer conformance | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` |
| multipart/resumable upload | `AVAILABLE_NOT_COMPOSED` | part hash/retry, IDB checkpoint, server reconcile, cross-tab cancel, complete/abort | pause, checkpoint inventory/retention sweep, unsupported lock decision와 실제 server/session provider | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` |
| one-shot streaming download | `AVAILABLE_NOT_COMPOSED` | bounded whole-object stream, length/media/integrity, picker/Blob/handoff delivery | strategy selector와 Range capability 분리 | `COMMON_REQUIRED` |
| top-level transfer composition | `DESIGNED_NOT_IMPLEMENTED` | 개별 factory와 dispose는 존재 | strict config, readiness, atomic account teardown, drain, kill switch | `COMMON_REQUIRED` |
| Image CDN verification engine | `AVAILABLE_NOT_COMPOSED` | opaque asset/preset, signed descriptor verification, responsive candidate, static metadata/decode budget | 제품 preset/presentation policy 조합과 실제 provider/private delivery E2E | `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` |
| image descriptor HTTP provider | `DESIGNED_NOT_IMPLEMENTED` | caller가 decoded descriptor를 직접 제공 | fixed BFF endpoint, bounded schema, refresh single-flight, expiry/logout fence | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` |
| safe image presentation primitive | `DESIGNED_NOT_IMPLEMENTED` | descriptor 결과만 제공 | URL 재조립 없는 picture/source/img projection | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` |
| app-managed background upload | `NOT_SELECTED` | checkpoint 기반 foreground resume만 존재 | worker lifetime/staging/permission 모델 별도 설계 | `OPTIONAL_CAPABILITY` |
| cross-browser app-managed background upload guarantee | `PLATFORM_LIMITED` | page/worker lifetime과 local source permission이 공통 보장되지 않음 | foreground resume 또는 explicit unsupported fallback | 플랫폼 제약 |
## 5. 중요한 경계
### 5.1 같은 이름처럼 보이지만 다른 capability
- streaming download는 메모리 상한을 지키며 **이번 응답을 끝까지** 저장한다.
Range resumable download는 새로운 요청에서 validator와 destination offset을
검증해 **이전 partial state를 이어 간다**.
- multipart resume는 upload session protocol이다. Background upload는 page
lifecycle이 끝난 뒤에도 실행 주체가 살아 있다는 별도 보장이다.
- Cache Storage release runtime은 public response를 검증·활성화한다. Service
Worker는 navigation/fetch interception과 controlled-client lifecycle을 소유한다.
- generic IndexedDB runtime은 query persistence가 아니다. Query persistence는
query classification, dehydration, scope epoch와 restore precedence를 추가로
요구한다.
- browser-managed handoff는 브라우저에 전달했다는 결과다. application이
저장 완료, 진행률 또는 background retry를 증명한 결과가 아니다.
- Image CDN engine은 descriptor를 검증한다. BFF descriptor 발급, 실제 CDN,
`<picture>` UX를 자동으로 제공하지 않는다.
### 5.2 정책과 도메인
byte ceiling, retry 상한, schema version, state transition과 fail-closed fallback은
공통 메커니즘이다. 다음 값은 도메인 코드가 아니라 **제품 composition policy**다.
- 어떤 file purpose와 MIME/signature profile을 허용하는가
- 어떤 query/dataset을 어느 account partition에 얼마나 오래 저장하는가
- quota pressure에서 무엇을 먼저 제거하는가
- 어떤 upload purpose와 CDN preset을 설치하는가
- save/open/share와 conflict/recovery UX를 어떻게 보여 주는가
업무 entity와 권한 결과는 backend/domain이 소유한다. frontend policy는 이를
추측하거나 대체하지 않는다.
## 6. External authority·backend·provider 계약
server/session/CDN 경계를 넘는 capability는 해당되는 external 계약 없이 실제
제품에 조합하지 않는다. local-only/reconstructable dataset에 backend를
일괄 요구하지 않는다.
| 경계 | 맞춰야 하는 owner/authority/provider 계약 |
| --- | --- |
| File upload | Web/BFF authorization, upload session API, file server 또는 object-storage data plane, quarantine/scanner/promotion |
| Presigned URL | BFF signer, cloud object storage, CORS/CSP, method/header/length/checksum/expiry 강제 |
| Multipart resume | server session ledger, idempotency, authoritative part status, completion receipt와 orphan janitor |
| Range download | immutable object generation 또는 strong validator, exact Range/If-Range semantics, full-object digest |
| account cache scope | frontend common runtime은 scope snapshot 검증, local generation/fence/teardown을 소유한다. product composition은 account/tenant 의미를 opaque partition policy에 mapping하고, auth/session owner는 sign-in/revoke/switch 사실을 제공한다. backend-issued epoch를 선택한 경우에만 그것이 wire 계약이다. |
| offline mutation | idempotency key, entity revision/ETag, cursor/delta, conflict/merge protocol |
| Image CDN | BFF descriptor endpoint, asset revision/preset registry, signing key rotation, CDN cache/CORS/CSP/no-store |
| eviction recovery | server-authoritative projection의 재구성 또는 all-marker-loss 구분이 필요한 제품에만 re-sync cursor/opaque installation epoch 계약 |
브라우저 native `File`, `FileSystemHandle`, IndexedDB physical store, OPFS path,
Cache name과 local checkpoint revision은 backend wire 계약이 아니다.
## 7. 설계 우선 work package
### WP-01. Scope-safe client cache
- session/account/release scope snapshot과 generation
- old QueryClient cancel, fence, clear, dispose와 remount
- late-result rejection
- strict query registry/key codec와 per-query ceiling
- Web Storage per-key policy, partition/logout/migration/outcome
- production coordinator까지 연결한 multi-page browser evidence
Exit: account A의 cache, storage event와 늦은 async result가 account B runtime에
관측되거나 기록될 수 없음을 deterministic fault와 native browser test로 증명한다.
### WP-02. Range resumable download
- 별도 `ResumableDownloadPort`
- validator-bound checkpoint와 non-authorizing persistence
- 200/206/412/416 state machine
- seek/truncate 또는 OPFS staging destination
- capability renewal와 final whole-object integrity
- browser strategy selector와 fail-closed fallback
Exit: crash, capability expiry, object replacement, malformed Content-Range,
destination mismatch와 integrity failure에서 corrupt saved outcome이 0건이다.
### WP-03. Origin storage lifecycle
- StorageManager signal + actual quota failure 기반 pressure controller
- policy-owned eviction priority와 hysteresis
- bounded maintenance cursor/deadline
- IDB/OPFS/Cache forward migration과 N-1 rollback
- OPFS native preflight와 clear/eviction recovery
- local preview bounded header parser/decode probe, pixel/decoded-byte/animation ceiling과
object URL 발급 전 fail-closed rejection
Exit: quota/migration/crash fault에서 unbounded scan, destructive auto-reset 또는
cross-scope read 없이 read-only/online-only/recovery outcome으로 닫힌다. hostile,
oversize 또는 animated preview fixture는 object URL 발급 전에 거절되고 decode
resource와 lease가 남지 않는다.
### WP-04. Transfer operational composition
- strict config schema와 protocol/version registry
- file/presigned/upload/download/image runtime atomic assembly
- readiness, kill switch, active-operation drain과 idempotent close
- logout/account switch fence
- checkpoint inventory/retention owner와 safe observations
- frontend provider contract harness
Exit: partially configured runtime이 시작되지 않고, teardown 뒤 capability나
late refresh가 새 scope에서 재사용되지 않는다.
### WP-05. Image descriptor delivery
- fixed BFF provider와 bounded closed decoder
- descriptor refresh single-flight와 expiry budget
- logout/account/runtime-generation fence
- static safe picture projection
- actual private/public CDN conformance and browser evidence
Exit: caller-provided URL/transform이 DOM에 도달하지 않고, 만료·회전·logout·decode
failure가 placeholder 또는 closed failure로 복구된다.
### WP-06. Optional capability decisions
directory/persistent handle, Query persistence, offline mutation, Service Worker,
private/range cache와 app-managed background upload/download는 각각 독립 ADR,
threat model, owner, budget과 removal plan을 승인한 뒤에만 시작한다.
## 8. 문서 우선 gate
runtime 구현을 시작하기 전에 해당 work package 문서에 다음이 모두 있어야 한다.
- current/target status와 out-of-scope
- application port와 adapter/provider owner
- immutable policy/config schema와 implementation ceiling
- state machine, concurrency와 cancellation owner
- durable record 분류, scope, TTL, purge와 migration
- backend/provider wire version과 compatibility
- browser capability matrix와 fallback
- observability allowlist와 금지 값
- rollout, kill switch, rollback과 removal
- deterministic, contract, native browser, fault와 operational drill
- 완료 조건과 promotion evidence 위치
문서가 없는 편의 API, fallback, persistence field 또는 retry owner를 구현 중에
추가하지 않는다. 새 요구는 ledger와 해당 ADR을 먼저 변경한다.
## 9. 구현 및 promotion 순서
```text
ledger/ADR accepted
-> port + closed policy/schema
-> deterministic fake/contract harness
-> reference runtime + negative boundary gate
-> fault/migration/removal evidence
-> AVAILABLE_NOT_COMPOSED
-> product owner + 필요한 external provider/config 선택
-> bootstrap composition behind kill switch
-> COMPOSED + TrafficAdmission=DISABLED
-> native Chromium/Firefox/WebKit + device drill
-> provider/browser/operations promotion gates
-> TrafficAdmission=CANARY/ENABLED
-> project-local production promotion
```
추천 구현 순서는 WP-01 → WP-02 → WP-03 → WP-04 → WP-05다. WP-02와 WP-03의
seekable/staging 정책, WP-04와 WP-05의 lifecycle/config 계약은 설계 단계에서
서로 검토하되 한 변경에서 모든 runtime을 동시에 조합하지 않는다.
## 10. 공통 완료 기준
- [ ] 모든 capability가 이 문서의 primary status 하나를 가진다.
- [ ] `AVAILABLE_NOT_COMPOSED` source가 기본 production module inventory에 없다.
- [ ] `COMPOSED` capability는 bootstrap부터 실제 consumer까지 호출 증거가 있다.
- [ ] account/session 전환이 broadcast delivery나 브라우저 종료에 의존하지 않는다.
- [ ] byte, record, queue, candidate, retry, deadline과 scan에 hard ceiling이 있다.
- [ ] durable state는 schema/codec/scope/epoch/retention/migration을 함께 선언한다.
- [ ] raw URL, query, signed header, file name/path, account ID, storage value,
digest/ETag/receipt가 diagnostics나 telemetry에 노출되지 않는다.
- [ ] backend/provider contract는 fake, emulator와 실제 provider에 재사용 가능한
conformance suite를 가진다.
- [ ] Chromium/Firefox/WebKit과 승인 device fallback 증거가 보존된다.
- [ ] kill switch, N-1 rollback, recovery와 optional runtime removal drill이
통과한다.
- [ ] 외부 증거가 없는 항목을 `PRODUCTION_READY`로 표시하지 않는다.
## 11. 상세 문서
- [Browser file and origin storage](./browser-file-and-origin-storage.md)
- [Client cache and storage](./client-cache-and-storage.md)
- [Presigned transfer and Image CDN](./presigned-transfer-and-image-cdn.md)
- [Server file capability infrastructure](./server-file-capability-infrastructure.md)
- [VD-11 Browser file and origin-storage](./decisions/VD-11-browser-file-and-origin-storage.md)
- [VD-12 Presigned transfer and Image CDN](./decisions/VD-12-presigned-transfer-and-image-cdn.md)
- [VD-13 Client cache scope and persistence](./decisions/VD-13-client-cache-scope-and-persistence.md)
- [VD-14 Resumable download and background download](./decisions/VD-14-resumable-download-and-background-transfer.md)
- [VD-15 Origin storage lifecycle and migration](./decisions/VD-15-origin-storage-lifecycle-and-migration.md)
- [VD-16 Browser transfer composition and image delivery](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md)
- [Browser file/storage recovery](../operations/browser-file-storage-recovery.md)
- [Client cache/storage recovery](../operations/client-cache-and-storage-recovery.md)
- [Browser transfer recovery](../operations/browser-transfer-recovery.md)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
# VD-01: TypeScript 7과 ESLint 10의 점진적 전환 도구
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-typescript-tooling-foundation`
## 배경
저장소는 TypeScript `7.0.2`와 ESLint `10.8.0`을 고정하고 있다. 첫 전환
브랜치는 compiler를 변경하거나 production source를 일괄 변환하지 않고
JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다.
결정 시점의 package peer contract는 다음과 같다.
- `typescript-eslint@8.65.0`과 canary는 TypeScript `<6.1.0`을 요구한다.
- `eslint-plugin-jsx-a11y@6.10.2`는 ESLint `<=9`를 요구한다.
- `eslint-plugin-react-hooks@7.1.1`은 ESLint 10을 지원한다.
- Babel 8 ESLint parser는 ESLint 10을 지원하고 Node `>=24.11.0`을 요구한다.
호환되지 않는 peer dependency를 강제 설치하면 lockfile 검증은 통과하더라도
지원되지 않는 parser와 rule 조합을 플랫폼 계약으로 만들게 된다.
## 결정
1. TypeScript `7.0.2`와 ESLint `10.8.0`을 유지한다.
2. TypeScript/TSX의 ESLint syntax parsing에는
`@babel/eslint-parser`와 TypeScript/JSX syntax plugin을 사용한다.
3. TypeScript의 이름 해석, unused 진단과 type semantics는 `tsc`가 소유한다.
Babel parser가 TypeScript scope manager를 제공하지 않으므로 TS 파일의
core `no-undef``no-unused-vars`는 끄고 분리된 app/node/test TypeScript
project를 필수 게이트로 실행한다.
4. React Hook 규칙은 호환되는 `eslint-plugin-react-hooks`로 즉시 적용한다.
5. JSX 접근성은 현재의 semantic component contract, Testing Library,
axe 기반 cross-browser gate와 수동 검토 계약이 계속 담당한다. 호환되지 않는
`eslint-plugin-jsx-a11y`는 설치하지 않는다.
6. Babel 8의 지원 범위에 맞춰 Node engine 하한을 `24.11.0`으로 명시한다.
7. production source의 대량 rename은 이 결정에 포함하지 않는다.
## 적용 후 상태 (2026-07-27)
후속 migration에서 production source, 비-fixture tests, Node scripts와 지원되는
tool config를 모두 TS/TSX로 전환했다. `allowJs`는 껐고 runtime/source 영역의
JavaScript 재유입은 architecture gate가 거절한다. Node scripts는 pinned Node
24에서 `.ts`로 직접 실행되며 NodeNext, `verbatimModuleSyntax`
`erasableSyntaxOnly`로 별도 typecheck한다. `tests/fixtures/**`도 TS/TSX
architecture/security/type negative input으로 전환했다. 이는 7번 결정의 범위를
변경한 것이 아니라 그 기반 위에서 완료한 별도 후속 작업이다.
## 검증
- `check:types`는 app, Node scripts/config, tests project를 모두 검사한다.
- TS invalid-call, invalid port, discriminated-union fixture는 실패해야 한다.
- ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다.
- registry scanner는 TS registry의 required field, uniqueness와 reference를
검증한다.
- browser security gate는 TSX의 금지된 raw HTML fixture를 거절한다.
## 후속 검토와 제거
`typescript-eslint`가 TypeScript 7을, JSX 접근성 plugin이 ESLint 10을 공식
지원하면 별도 dependency 브랜치에서 peer metadata와 전체 negative fixture를
재검증한다. 교체할 때는 Babel parser package와 TS 전용 ESLint override를
함께 제거한다. compiler downgrade나 `--force` 설치는 이 ADR의 rollback
방법이 아니다.
@@ -0,0 +1,58 @@
# VD-03: React Router Data Mode와 서버 상태 소유권
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime`
## 배경
기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한
route registry에 params/search schema, loading/error surface, access, title,
navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이
어긋날 수 있었다.
이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가
소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지
않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할
필요가 있다.
## 결정
1. 고정된 React Router `7.18.1``createBrowserRouter``RouterProvider`
사용하는 Data Mode를 기본값으로 채택한다.
2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다.
3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서
route 목록을 다시 열거하지 않는다.
4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical
URL을 생성한다.
5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면
application input 또는 query adapter 한 경로를 호출한다.
6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack
Query가 계속 소유한다.
7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는
route/feature boundary가 소유한다.
8. Framework Mode, SSR, static generation과 router version upgrade는 별도
dependency/architecture 브랜치에서 결정한다.
## 검증
- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와
registry gate가 모두 거절한다.
- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry
fixture로 검증한다.
- params/search parse/build round-trip, canonical redirect, 최대 redirect hop,
access rejection, title/focus와 boundary reset을 unit/component test로 검증한다.
- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를
build/release 검증기가 확인한다.
- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만
reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다.
## 결과와 rollback
Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다.
이 구분을 지키면 React Router를 교체해도 application input과 output port는
유지된다.
rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure
surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset
cache의 purge는 저장소 rollback 범위에 포함하지 않는다.
@@ -0,0 +1,55 @@
# VD-04: Native form controller와 local facade
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-form-page-platform`
## 배경
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
## 결정
1. RP-06은 React native form event와 controlled value를 사용하는 local
`useAppForm` facade를 기본 엔진으로 채택한다.
2. Zod presentation schema, application command mapper와 domain invariant는 서로
다른 소유물로 유지한다.
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
4. 422 details는 승인된 `path``code`만 HTTP 경계에서 투영한다. backend
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
error로 이동한다.
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
dirty 상태를 해제한다.
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
## React Hook Form 도입 조건
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
React Hook Form과 Zod resolver를 평가한다.
- 동적 field array와 중첩 object를 함께 다루는 복합 form
- field 단위 비동기 validation 취소와 의존 validation
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
- uncontrolled input 또는 vendor extension이 필요한 경우
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
## 검증과 rollback
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
reset, dirty guard와 focus를 component test로 검증한다.
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
- architecture gate가 template의 application/HTTP/query vendor import를
거절한다.
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
application input과 outbound gateway 계약은 유지되며, form facade와 template
commit은 독립적으로 되돌릴 수 있다.
@@ -0,0 +1,57 @@
# VD-05: Semantic icon facade와 native-first interaction
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-design-system-platform`
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
## 배경
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
의존성과 중복 interaction 모델이 생긴다.
## 결정
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
고정한다.
2. `lucide-react`의 static named import는
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
노출한다. vendor component type, icon name, stroke API와 dynamic icon
registry는 노출하지 않는다.
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
Tabs는 manual/automatic activation, Drawer는 modal/background
비활성화/focus restore 계약을 가진다.
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
collision, nested overlay, virtualized collection 또는 복합 select 요구를
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
평가한다.
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
주장하지 않는다.
## 경계와 검증
- 제품 코드는 `presentation/design-system/index`만 import한다.
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
undefined token과 tooltip-only required information fixture를 거절한다.
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
검증한다.
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
restore, gallery keyboard interaction과 axe를 검증한다.
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
## Rollback
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
도입해도 public props와 interaction test를 유지한다.

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