Compare commits

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

After

Width:  |  Height:  |  Size: 712 B

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

After

Width:  |  Height:  |  Size: 3.2 KiB

+37 -12
View File
@@ -8,20 +8,45 @@
"releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z",
"routeChunks": {
"route-home": "src/presentation/pages/home-page.tsx",
"route-examples-platform": "src/presentation/examples/platform-overview-page.tsx",
"route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx",
"route-examples-states": "src/presentation/examples/state-gallery-page.tsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.tsx",
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
"route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.tsx"
"route-tech-log-home": "src/features/tech-log/presentation/public/pages/home-page.tsx",
"route-tech-log-explore": "src/features/tech-log/presentation/public/pages/explore-page.tsx",
"route-tech-log-explore-kind": "src/features/tech-log/presentation/public/pages/explore-kind-page.tsx",
"route-tech-log-case": "src/features/tech-log/presentation/public/pages/case-page.tsx",
"route-tech-log-reference": "src/features/tech-log/presentation/public/pages/reference-page.tsx",
"route-tech-log-question": "src/features/tech-log/presentation/public/pages/question-page.tsx",
"route-tech-log-topic": "src/features/tech-log/presentation/public/pages/topic-page.tsx",
"route-tech-log-projects": "src/features/tech-log/presentation/public/pages/projects-page.tsx",
"route-tech-log-project": "src/features/tech-log/presentation/public/pages/project-overview-page.tsx",
"route-tech-log-project-records": "src/features/tech-log/presentation/public/pages/project-records-page.tsx",
"route-tech-log-project-decisions": "src/features/tech-log/presentation/public/pages/project-decisions-page.tsx",
"route-tech-log-project-activity": "src/features/tech-log/presentation/public/pages/project-activity-page.tsx",
"route-tech-log-releases": "src/features/tech-log/presentation/public/pages/releases-page.tsx",
"route-tech-log-release": "src/features/tech-log/presentation/public/pages/release-page.tsx",
"route-tech-log-profile": "src/features/tech-log/presentation/public/pages/profile-page.tsx",
"route-tech-log-search": "src/features/tech-log/presentation/public/pages/search-page.tsx",
"route-tech-log-studio-home": "src/features/tech-log/presentation/studio/pages/studio-home-page.tsx",
"route-tech-log-studio-documents": "src/features/tech-log/presentation/studio/pages/documents-page.tsx",
"route-tech-log-studio-document-new": "src/features/tech-log/presentation/studio/pages/new-document-page.tsx",
"route-tech-log-studio-document-edit": "src/features/tech-log/presentation/studio/pages/document-edit-page.tsx",
"route-tech-log-studio-document-validation": "src/features/tech-log/presentation/studio/pages/document-validation-page.tsx",
"route-tech-log-studio-document-preview": "src/features/tech-log/presentation/studio/pages/document-preview-page.tsx",
"route-tech-log-studio-document-publish": "src/features/tech-log/presentation/studio/pages/document-publish-page.tsx",
"route-tech-log-studio-publications": "src/features/tech-log/presentation/studio/pages/publications-page.tsx",
"route-tech-log-studio-publication-preview": "src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx",
"route-tech-log-studio-not-found": "src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx",
"route-not-found": "src/features/tech-log/presentation/public/pages/public-not-found-page.tsx"
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2",
"packages": []
"setDigest": "sha256:e0da77655f51592ece583826d5fc6b092f57dd2bf63307e45e7e77283e6bf437",
"packages": [
{
"packageId": "@tech-log/studio-contract",
"version": "2.0.0",
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
"runtimeProtocolVersion": 1,
"sourceRevision": "ce2e748"
}
]
}
}
+21 -7
View File
@@ -13,12 +13,20 @@ import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtim
* 1. clean dist and .generated/frontend-runtime
* 2. generate contractSet and build-info source
* 3. Vite app build (emptyOutDir = true)
* 4. scan app dist and generate the static asset source
* 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
* 6. generate Release Manifest V2 and the build manifest
* 4. materialize dist/config.json from the declared APP_PROFILE
* 5. scan app dist and generate the static asset source
* 6. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
* 7. generate the self-contained TechLog production serving boundary
* 8. generate Release Manifest V2 and the build manifest
*
* Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
* Steps 5 and 6 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
* and `null`: those modes never run an active worker build.
*
* Step 4 has to follow the Vite build and precede the asset scan. Vite copies
* `public/` verbatim, so without it every build — including a production one —
* ships the local runtime document; and the Service Worker hashes the emitted
* `config.json`, so the profile must be in place before that inventory is
* taken.
*/
const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker;
@@ -45,10 +53,13 @@ run("node", ["scripts/generate-contract-set.ts"]);
// 3. app build
run("npx", ["vite", "build"]);
// 4. runtime config for the declared profile
run("node", ["scripts/generate-runtime-config.ts"]);
if (buildsActiveWorker) {
// 4. hashed asset inventory
// 5. hashed asset inventory
run("node", ["scripts/generate-service-worker-assets.ts", "dist"]);
// 5. service worker build
// 6. service worker build
run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]);
} else {
process.stdout.write(
@@ -56,5 +67,8 @@ if (buildsActiveWorker) {
);
}
// 6. release + build manifest
// 7. production serving boundary
run("node", ["scripts/generate-tech-log-serving-artifact.ts"]);
// 8. release + build manifest
run("node", ["scripts/generate-build-manifest.ts"]);
+43 -3
View File
@@ -723,9 +723,13 @@ function findArchitectureViolations(
continue;
}
for (const dependency of dependencies) {
const sourceGroups = rule.from?.path
? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ??
[])
: [];
if (
matchesPath(dependency.source, rule.from) &&
matchesPath(dependency.target, rule.to)
matchesPath(dependency.target, rule.to, sourceGroups)
) {
violations.push({
rule: rule.name,
@@ -746,16 +750,52 @@ function findArchitectureViolations(
function matchesPath(
modulePath: string,
criterion: PathRule | undefined,
sourceGroups: readonly string[] = [],
): boolean {
if (!criterion) return true;
if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) {
if (
criterion.path &&
!new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test(
modulePath,
)
) {
return false;
}
return !(
criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath)
criterion.pathNot &&
new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test(
modulePath,
)
);
}
/**
* Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from`
* pattern matched on the importing module.
*
* Without it, "an adapter may not import a *different* adapter" cannot be
* written as one rule: the target pattern has to name the importer's own
* directory to exempt it. The alternative is one rule per adapter group, which
* silently stops covering a group the moment somebody adds one — exactly the
* gap that let `diagnostics` import `telemetry` while the documented rule said
* it could not.
*/
function expandSourceGroups(
pattern: string,
sourceGroups: readonly string[],
): string {
return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => {
const captured = sourceGroups[Number(index) - 1];
// A `from` pattern that did not capture leaves the token literal rather
// than quietly matching everything.
return captured === undefined ? whole : escapeRegExp(captured);
});
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
}
function validateArchitectureRules(rules: readonly ArchitectureRule[]): void {
if (!rules.some((rule) => rule.to?.circular === true)) {
throw new Error("Architecture configuration must contain a circular rule");
+2 -2
View File
@@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat
}
const expectedGateIds = Array.from(
{ length: 26 },
{ length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
const passingResults: Record<string, GateResult> = Object.fromEntries(
@@ -139,4 +139,4 @@ if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n");
process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n");
+2 -1
View File
@@ -2,6 +2,7 @@ import { mkdir, readFile, readdir } from "node:fs/promises";
import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { containsRawPaletteValue } from "./lib/design-system-source.ts";
import path from "node:path";
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
@@ -109,7 +110,7 @@ for (const file of sources) {
}
if (
!file.includes("src/presentation/design-system/tokens/") &&
/(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source)
containsRawPaletteValue(source)
) {
failures.push(`raw palette value in ${file}`);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Gate: the hand-maintained dev fixture `public/release-manifest.json` must
* declare the same `contractSet` the build compiles.
*
* `corepack pnpm dev` serves that file verbatim, and `verifyContractSet` runs
* unconditionally at boot, so a stale fixture is a hard boot failure of
* `pnpm dev` in the default `MOCK` mode — not an `HTTP`-mode caveat. Nothing
* else in the gate set reads `public/*.json`, which is how a completely broken
* `pnpm dev` shipped with every static check green.
*
* `--write` refreshes the block instead of failing; that is what
* `corepack pnpm generate:tech-log-contract` calls.
*/
import process from "node:process";
import {
DEV_RELEASE_MANIFEST_PATH,
checkDevReleaseManifestContractSet,
refreshDevReleaseManifestContractSet,
} from "./lib/dev-release-manifest.ts";
const write = process.argv.includes("--write");
if (write) {
const { changed, contractSet } = await refreshDevReleaseManifestContractSet();
process.stdout.write(
`${changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` +
`(${contractSet.packages.length} package(s), ${contractSet.setDigest})\n`,
);
} else {
const failures = await checkDevReleaseManifestContractSet();
if (failures.length > 0) {
process.stderr.write(
`dev release manifest drift:\n- ${failures.join("\n- ")}\n` +
`Run: corepack pnpm generate:dev-release-manifest\n`,
);
process.exit(1);
}
process.stdout.write(
`${DEV_RELEASE_MANIFEST_PATH} contractSet matches the compiled contract set.\n`,
);
}
+108
View File
@@ -0,0 +1,108 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import {
DEPLOYMENT_TARGETS,
findAdmissionViolations,
isDeploymentTarget,
type AdmissionInput,
} from "../src/contracts/deployment-admission.ts";
import { parseRuntimeConfigArtifact } from "../src/contracts/release-artifacts.ts";
/**
* §6.4 / FE-GATE-027. Refuses to admit an artifact to an environment it was not
* built for.
*
* Release coherence already proves the artifacts agree with each other. It
* cannot prove they belong in production, because a local build is coherent
* with itself: `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API pass
* every existing gate. This gate closes that by making the destination an
* explicit, declared input and refusing anything that does not match it.
*
* It fails closed in both directions. An undeclared destination is a refusal,
* not a default, so an artifact can never be admitted by omission; and every
* rule is stated as a reason to refuse, so an unreadable field cannot pass.
*/
const RUNTIME_CONFIG_PATH = "dist/config.json";
const RECORD_PATH = "artifacts/release/deployment-admission.json";
async function main(): Promise<void> {
const declared = process.env["RELEASE_TARGET"];
if (!isDeploymentTarget(declared)) {
process.stderr.write(
"release admission refused: RELEASE_TARGET must be declared as one of " +
`${DEPLOYMENT_TARGETS.join(", ")}; received ${
declared === undefined ? "nothing" : declared
}.\n` +
"An artifact is never admitted by default — name the environment it is for.\n",
);
process.exitCode = 1;
return;
}
let document: unknown;
try {
document = JSON.parse(await readFile(RUNTIME_CONFIG_PATH, "utf8"));
} catch (error) {
process.stderr.write(
`release admission refused: ${RUNTIME_CONFIG_PATH} is unreadable: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
return;
}
let config: AdmissionInput;
try {
config = parseRuntimeConfigArtifact(document) as AdmissionInput;
} catch (error) {
process.stderr.write(
`release admission refused: ${RUNTIME_CONFIG_PATH} is not a valid runtime config: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
return;
}
const violations = findAdmissionViolations(declared, config);
await mkdir("artifacts/release", { recursive: true });
await writeFile(
RECORD_PATH,
`${JSON.stringify(
{
schemaVersion: 1,
target: declared,
appEnv: config.APP_ENV,
authMode: config.AUTH_MODE,
apiBaseUrl: config.API_BASE_URL,
buildId: config.BUILD_ID ?? null,
releaseId: config.RELEASE_ID ?? null,
status: violations.length === 0 ? "ADMITTED" : "REFUSED",
violations,
},
null,
2,
)}\n`,
"utf8",
);
if (violations.length > 0) {
process.stderr.write(
`release admission refused for ${declared}:\n${violations
.map((violation) => ` ${violation.field}: ${violation.reason}`)
.join("\n")}\n`,
);
process.exitCode = 1;
return;
}
process.stdout.write(
`release admission: ${declared} ADMITTED ` +
`(APP_ENV=${config.APP_ENV}, AUTH_MODE=${config.AUTH_MODE}, ` +
`API=${config.API_BASE_URL}); record at ${RECORD_PATH}\n`,
);
}
await main();
+106 -13
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { z } from "zod";
import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts";
import { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.ts";
import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
@@ -247,6 +248,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
"provider-provenance",
"provider-verification",
"ci-contract-report",
"deployment-admission",
]),
})
.strict(),
@@ -442,7 +444,22 @@ export type LoadCiGateContractOptions = Readonly<{
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
"a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4";
// Template merge. Both sides carried a digest of their own gate set; neither
// describes the merged one. Recomputed from the merged config/ci/gates.json.
// Task 11: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_ASSETS manual accessibility evidence artifact.
// Final fix wave item 1: recomputed again after FE-GATE-010 gained
// `check-tech-log-contract`. Recomputed with `canonicalGateShapeSha256`
// below, verified by first reproducing the previous constant from the
// previous `config/ci/gates.json` before hashing the new one.
// Alignment follow-up item 2: recomputed again after FE-GATE-007 gained
// `test-tech-log` and its junit evidence, by the same method — the previous
// constant f3cc9075… was reproduced from the previous gates.json first, so
// the transcription that produced this value is known to be the real one.
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
// reproduced from the previous gates.json before this value was hashed.
"b40962448e617883060e09eb7183837cfea6833519f435f11713efd083210dbe";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -473,16 +490,30 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
(total, gate) => total + gate.commandIds.length,
0,
);
if (contract.gates.length !== 26) {
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
}
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
// Final fix wave, item 1: FE-GATE-010 gained `check-tech-log-contract`, the
// drift gate that pins the vendored canonical Studio contract to its digest.
// Until it was referenced by a gate it ran only when typed by hand.
// Alignment follow-up, item 2: FE-GATE-007 gained `test-tech-log`. The suite
// already ran inside `test:coverage`'s combined vitest invocation, so a
// TechLog failure was reported as a coverage-gate failure with no junit of
// its own to name it.
// Dev release manifest drift fix, item 2: FE-GATE-010 gained
// `check-dev-release-manifest`. No gate read `public/*.json` at all, so a
// fixture that did not declare the compiled contract set broke `pnpm dev`
// outright while every static gate stayed green.
if (contract.commands.length !== 85 || commandReferenceCount !== 97) {
failures.push(
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
`command authority baseline must contain exactly 85 definitions and 97 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
if (contract.artifacts.length !== 105) {
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`);
// Template merge. 126 product artifacts plus the two the template added.
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
// Alignment follow-up, item 2 added the TechLog junit report.
if (contract.artifacts.length !== 130) {
failures.push(`artifact authority baseline must contain exactly 130 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -511,7 +542,7 @@ export function parseCiGateContract(
.join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
}
if ((options.mode ?? "canonical") === "canonical") {
if ((options.mode ?? defaultCiContractMode()) === "canonical") {
const failures = canonicalAuthorityBaselineFailures(result.data);
if (failures.length > 0) {
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
@@ -520,11 +551,29 @@ export function parseCiGateContract(
return result.data;
}
/**
* A removal fixture runs the whole suite against a deliberately *reduced* CI
* contract: the removed capability's gates, commands and artifacts are pruned.
* Loading that contract in canonical mode re-imposes the full exact-count
* authority on it, so the fixture failed on the very reduction it exists to
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
* is honoured.
*/
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
return process.env.CI_CONTRACT_MODE === "removal-fixture"
? "removal-fixture"
: "canonical";
}
export function isReducedCiContractRun(): boolean {
return defaultCiContractMode() === "removal-fixture";
}
export async function loadCiGateContract(
root = process.cwd(),
options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> {
const mode = options.mode ?? "canonical";
const mode = options.mode ?? defaultCiContractMode();
const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"),
@@ -723,6 +772,44 @@ function validateContractSemantics(
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
}
}
const accessibilityGate = contract.gates.find(
({ id: gateId }) => gateId === "FE-GATE-009",
);
const manualAccessibilityArtifacts = MANUAL_A11Y_ROUTE_IDS.map((routeId) => ({
id: `artifact-artifacts-tests-a11y-manual-${routeId.replaceAll("_", "-")}-md`,
path: `artifacts/tests/a11y-manual/${routeId}.md`,
}));
const expectedAccessibilityEvidenceArtifactIds = [
"artifact-artifacts-tests-a11y-json",
...manualAccessibilityArtifacts.map(({ id: artifactId }) => artifactId),
"artifact-artifacts-tests-a11y-manual-report-json",
];
if (
!accessibilityGate ||
JSON.stringify(accessibilityGate.evidenceArtifactIds) !==
JSON.stringify(expectedAccessibilityEvidenceArtifactIds)
) {
issue(
"FE-GATE-009 manual accessibility evidence must exactly match the installed route scope",
);
}
for (const expected of manualAccessibilityArtifacts) {
const artifact = contract.artifacts.find(
({ id: artifactId }) => artifactId === expected.id,
);
if (
!artifact ||
artifact.path !== expected.path ||
artifact.schemaId !== "markdown" ||
artifact.production !== "source-controlled"
) {
issue(
`FE-GATE-009 manual accessibility artifact registration is invalid: ${expected.id}`,
);
}
}
const referencedRetentionClasses = new Set(
contract.gates.map(({ retentionClassId }) => retentionClassId),
);
@@ -759,11 +846,11 @@ function validateContractSemantics(
}
const expectedGateIds = Array.from(
{ length: 26 },
{ length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
issue("gate registry must contain FE-GATE-001..026 in canonical order");
issue("gate registry must contain FE-GATE-001..027 in canonical order");
}
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
@@ -834,7 +921,7 @@ function validateContractSemantics(
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
immutable_build: ["FE-GATE-015"],
immutable_build: ["FE-GATE-015", "FE-GATE-027"],
vulnerability_provider: [],
provenance_provider: [],
promotion: [],
@@ -888,7 +975,13 @@ function validateContractSemantics(
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
merge_gate: [],
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
immutable_build: [],
immutable_build: [
// FE-GATE-027 admits the built artifact to a named environment, so both
// the profile it was built from and the destination it is claimed for are
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
],
vulnerability_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
+56 -3
View File
@@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z
})
.strict();
/**
* FE-GATE-027. The record of which environment an artifact was admitted to, and
* every reason it was refused. Refusals are kept in the artifact so a rejected
* promotion leaves evidence rather than only a non-zero exit code.
*/
export const deploymentAdmissionArtifactSchema = z
.object({
schemaVersion: z.literal(1),
target: z.enum(["local", "development", "staging", "production"]),
appEnv: z.enum(["local", "development", "staging", "production"]),
authMode: z.enum(["external", "demo"]),
apiBaseUrl: nonEmptyString,
buildId: nonEmptyString.nullable(),
releaseId: nonEmptyString.nullable(),
status: z.enum(["ADMITTED", "REFUSED"]),
violations: z.array(
z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(),
),
})
.strict();
export const releaseVerificationArtifactSchema = z
.object({
schemaVersion: z.literal(1),
@@ -1321,9 +1342,28 @@ export const documentationReviewArtifactSchema = z
reviewer: z.literal("wiki-diagram-reviewer"),
standard: z.literal("rules/diagram-standards.md v2"),
evidenceReport: z
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
.object({
repoPath: nonEmptyString,
upstreamCanonicalPath: nonEmptyString,
canonicalSha256: sha256,
})
.strict(),
reportDigestValid: z.boolean(),
/**
* The declared review scope, derived from the installed route registry
* rather than read off a sentence. Both scope documents claimed six routes
* while ten were registered.
*/
routeScope: z.array(
z
.object({
path: nonEmptyString,
missingRouteIds: z.array(nonEmptyString),
documented: z.boolean(),
})
.strict(),
).min(1),
routeScopeDocumented: z.boolean(),
results: z.array(
z
.object({
@@ -1350,8 +1390,21 @@ export const documentationReviewArtifactSchema = z
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
}
});
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
artifact.routeScope.forEach((entry, index) => {
if (entry.documented !== (entry.missingRouteIds.length === 0)) {
context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" });
}
});
if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) {
context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" });
}
if (
artifact.passed !==
(artifact.reportDigestValid &&
artifact.routeScopeDocumented &&
artifact.results.every(({ passed }) => passed))
) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" });
}
});
+13 -10
View File
@@ -21,13 +21,12 @@ import {
} from "./lib/build-environment.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts";
import {
findViteDynamicRouteChunk,
type ViteManifestRouteEntry,
} from "./lib/vite-route-chunks.ts";
assertCiBuildEnvironment(process.env);
type ViteManifestEntry = Readonly<{
file: string;
name?: string;
isDynamicEntry?: boolean;
}>;
const packageJson = parsePackageMetadata(
JSON.parse(await readFile("package.json", "utf8")),
@@ -63,9 +62,9 @@ const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
ROUTE_RUNTIME_CONTRACT;
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime = runtimeContracts[definition.routeId];
const asset = Object.values(viteManifestObject).find(
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
);
const asset = runtime
? findViteDynamicRouteChunk(viteManifestObject, runtime.moduleId)
: undefined;
if (!runtime || !asset?.file) {
throw new Error(`Missing built route chunk: ${definition.routeId}`);
}
@@ -163,9 +162,9 @@ function parsePackageMetadata(value: unknown): Readonly<{
function parseViteManifest(
value: unknown,
): Readonly<Record<string, ViteManifestEntry>> {
): Readonly<Record<string, ViteManifestRouteEntry>> {
if (!isRecord(value)) throw new TypeError("Vite manifest must be an object");
const entries: Record<string, ViteManifestEntry> = {};
const entries: Record<string, ViteManifestRouteEntry> = {};
for (const [key, candidate] of Object.entries(value)) {
if (!isRecord(candidate) || typeof candidate.file !== "string") {
throw new TypeError(`Invalid Vite manifest entry: ${key}`);
@@ -176,6 +175,10 @@ function parseViteManifest(
...(typeof candidate.isDynamicEntry === "boolean"
? { isDynamicEntry: candidate.isDynamicEntry }
: {}),
...(Array.isArray(candidate.dynamicImports) &&
candidate.dynamicImports.every((item) => typeof item === "string")
? { dynamicImports: candidate.dynamicImports as string[] }
: {}),
};
}
return entries;
+93
View File
@@ -0,0 +1,93 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
DEPLOYMENT_TARGETS,
isDeploymentTarget,
type DeploymentTarget,
} from "../src/contracts/deployment-admission.ts";
import { runtimeConfigV2ArtifactSchema } from "../src/contracts/release-artifacts.ts";
/**
* §6.4. Materializes `dist/config.json` from the profile the build declares.
*
* `public/` is copied verbatim into `dist/`, so before this step the runtime
* document that shipped with every build was the local one `APP_ENV: local`,
* `AUTH_MODE: demo`, a loopback API regardless of what the build was for.
* The profile is the source of truth instead, and the only values a deployment
* may inject are the ones it actually owns: its endpoints and its identity.
*
* The result is validated against the same V2 schema the browser will apply, so
* an override cannot produce a document that only fails at boot.
*/
const PROFILE_DIRECTORY = "config/runtime";
const OUTPUT_PATH = "dist/config.json";
/**
* Deployment-supplied values. Everything else is fixed by the profile so a
* deployment cannot quietly widen what was reviewed.
*/
const OVERRIDES = Object.freeze({
API_BASE_URL: "RUNTIME_API_BASE_URL",
TELEMETRY_ENDPOINT: "RUNTIME_TELEMETRY_ENDPOINT",
} as const);
export async function generateRuntimeConfig(
target: DeploymentTarget,
environment: NodeJS.ProcessEnv = process.env,
): Promise<Record<string, unknown>> {
const profilePath = path.join(PROFILE_DIRECTORY, `${target}.json`);
const source: unknown = JSON.parse(await readFile(profilePath, "utf8"));
if (source === null || typeof source !== "object" || Array.isArray(source)) {
throw new TypeError(`${profilePath}: runtime profile must be an object`);
}
const draft: Record<string, unknown> = { ...(source as Record<string, unknown>) };
if (draft["APP_ENV"] !== target) {
throw new Error(
`${profilePath}: declares APP_ENV ${String(draft["APP_ENV"])}, expected ${target}`,
);
}
for (const [field, variable] of Object.entries(OVERRIDES)) {
const supplied = environment[variable];
if (supplied !== undefined && supplied !== "") draft[field] = supplied;
}
const buildId = environment["VITE_BUILD_ID"] ?? "local-build";
const releaseId = environment["RELEASE_ID"] ?? "local-release";
draft["BUILD_ID"] = buildId;
draft["RELEASE_ID"] = releaseId;
const parsed = runtimeConfigV2ArtifactSchema.safeParse(draft);
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
.join("\n ");
throw new Error(`${profilePath}: runtime config is invalid\n ${issues}`);
}
return draft;
}
function resolveTarget(environment: NodeJS.ProcessEnv): DeploymentTarget {
const declared = environment["APP_PROFILE"] ?? "local";
if (!isDeploymentTarget(declared)) {
throw new Error(
`APP_PROFILE must be one of ${DEPLOYMENT_TARGETS.join(", ")}; received ${declared}`,
);
}
return declared;
}
async function main(): Promise<void> {
const target = resolveTarget(process.env);
const config = await generateRuntimeConfig(target);
await writeFile(OUTPUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
process.stdout.write(
`runtime config: ${target} profile written to ${OUTPUT_PATH} ` +
`(APP_ENV=${String(config["APP_ENV"])}, AUTH_MODE=${String(config["AUTH_MODE"])})\n`,
);
}
if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) {
await main();
}
+141
View File
@@ -0,0 +1,141 @@
/**
* canonical studio-v1.yaml을 vendor하고 .
*
* . `openapi-typescript` TypeScript 5
* classic compiler API를 TypeScript 7.0.2
* (VD-01), TS7 compiler API를 . `pnpm dlx`
* lockfile과 peer .
*
* `--check` canonical . vendor된
* digest와 , operationId가
* . yaml이나 generated.ts를 .
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { argv, env, exit } from "node:process";
const CANONICAL_ROOT =
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
const check = argv.includes("--check");
function digestOf(bytes: Buffer | string): string {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function operationIdsOf(yaml: string): string[] {
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!);
}
function specVersionOf(yaml: string): string {
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
if (!match) throw new Error("canonical yaml has no info.version");
return match[1]!;
}
type CanonicalRecord = Readonly<{
packageId: string;
version: string;
digest: string;
sourceRevision: string;
operationIds: readonly string[];
}>;
function fail(problems: readonly string[]): never {
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
console.error("Run: corepack pnpm generate:tech-log-contract");
exit(1);
}
if (check) {
const vendored = readFileSync(VENDOR_YAML, "utf8");
const generated = readFileSync(GENERATED, "utf8");
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
const problems: string[] = [];
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
}
const vendoredOperations = operationIdsOf(vendored);
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
}
if (specVersionOf(vendored) !== record.version) {
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
}
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
for (const operationId of record.operationIds) {
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
problems.push(`${GENERATED} is missing operation ${operationId}`);
}
}
if (problems.length > 0) fail(problems);
console.log(
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
exit(0);
}
const canonicalBytes = readFileSync(CANONICAL_YAML);
const canonicalText = canonicalBytes.toString("utf8");
const record: CanonicalRecord = {
packageId: "@tech-log/studio-contract",
version: specVersionOf(canonicalText),
digest: digestOf(canonicalBytes),
sourceRevision: execFileSync(
"git",
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
{ encoding: "utf8" },
).trim(),
operationIds: operationIdsOf(canonicalText),
};
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
const generated = execFileSync(
"corepack",
[
"pnpm",
"dlx",
"--package",
GENERATOR_TYPESCRIPT,
"--package",
OPENAPI_TYPESCRIPT,
"openapi-typescript",
CANONICAL_YAML,
],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
);
writeFileSync(VENDOR_YAML, canonicalText);
writeFileSync(GENERATED, generated);
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
console.log(
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
// 선언해야 하고(§5.5, `verifyContractSet`는 MOCK 모드에서도 무조건 돈다),
// 그러지 않으면 dev 부팅이 CONTRACT_SET_PACKAGE_MISSING으로 닫힌다.
// 방금 쓴 canonical-source.json을 읽어야 하므로 정적 import가 아닌 동적
// import로 불러온다.
const { refreshDevReleaseManifestContractSet, DEV_RELEASE_MANIFEST_PATH } =
await import("./lib/dev-release-manifest.ts");
const refreshed = await refreshDevReleaseManifestContractSet();
console.log(
`${refreshed.changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` +
`(${refreshed.contractSet.packages.length} package(s), ${refreshed.contractSet.setDigest})`,
);
// contract를 다시 만들지 않아도 구성된 set은 바뀔 수 있다
// (`installed-contract-contributions.ts`에 기여가 추가/제거되는 경우).
// 그 경로는 여기서 못 잡으므로 `check:dev-release-manifest` 게이트가 잡는다.
@@ -0,0 +1,15 @@
import {
projects,
publicRecords,
releases,
} from "../src/features/tech-log/adapters/static/public-content.ts";
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
const contract = createTechLogServingContract({
projects,
publicRecords,
releases,
});
await writeTechLogServingArtifact({ distRoot: "dist", contract });
+13 -1
View File
@@ -6,10 +6,22 @@ export const CI_BUILD_ENVIRONMENT_VARIABLES = Object.freeze([
"SOURCE_DATE_EPOCH",
]);
/**
* The repository's single definition of "this is a CI run". The workflow sets
* `CI: "true"` at the top-level `env:` block, so it holds in every job; the
* `CI_RUN_ID` family is declared only by the release-tier provider jobs and is
* absent from the merge gates.
*/
export function isCiRun(
environment: Readonly<Record<string, string | undefined>>,
) {
return environment.CI === "true";
}
export function ciBuildEnvironmentFailures(
environment: Readonly<Record<string, string | undefined>>,
) {
if (environment.CI !== "true") return [];
if (!isCiRun(environment)) return [];
const failures = CI_BUILD_ENVIRONMENT_VARIABLES.filter(
(name) => !environment[name]?.trim(),
+2
View File
@@ -35,6 +35,7 @@ import {
registryGovernanceRunArtifactSchema,
registryCompatibilityFixturesArtifactSchema,
registrySnapshotArtifactSchema,
deploymentAdmissionArtifactSchema,
releaseVerificationArtifactSchema,
reproducibleBuildArtifactSchema,
runbookRecordArtifactSchema,
@@ -238,6 +239,7 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
"provider-provenance": provenanceProviderAttestationSchema,
"provider-verification": providerVerificationArtifactSchema,
"ci-contract-report": ciContractReportSchema,
"deployment-admission": deploymentAdmissionArtifactSchema,
});
export function hasCiArtifactSemanticValidator(
+40 -24
View File
@@ -5,7 +5,6 @@ import type { FileHandle } from "node:fs/promises";
import {
lstat,
mkdir,
mkdtemp,
open,
readFile,
readdir,
@@ -28,6 +27,10 @@ import {
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./ci-gate-log.ts";
import {
makePrivateTemporaryDirectory,
withPrivateUmask,
} from "./private-filesystem.ts";
const MAX_ARCHIVE_BYTES = 268_435_456;
const MAX_CANDIDATE_FILES = 4_096;
@@ -147,11 +150,11 @@ export async function verifyCiCandidateArchive(
path.dirname(extractionTarget),
);
await assertSafePublishLeaf(extractionTarget, input.extractTo);
extractionRoot = await mkdtemp(
extractionRoot = makePrivateTemporaryDirectory(
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
);
} else {
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-candidate-archive-"));
}
let published = false;
try {
@@ -221,7 +224,7 @@ export async function verifyCapturedCiCandidateArchive(
throw new Error("candidate archive SHA-256 mismatch");
}
const captured = await materializeCapturedArchive(archive);
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
const extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-candidate-"));
try {
const manifest = preflightArchiveHandle(captured.handle);
extractArchiveHandle(captured.handle, extractionRoot);
@@ -318,25 +321,33 @@ function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateMani
}
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
const extracted = spawnSync(
TAR_EXECUTABLE,
[
"--extract",
"--gzip",
"--file",
"/proc/self/fd/3",
"--directory",
extractionRoot,
"--no-same-owner",
"--no-same-permissions",
],
{
encoding: "utf8",
maxBuffer: 1_048_576,
timeout: 30_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
// `--no-same-permissions` is what keeps an untrusted archive from choosing
// its own modes, but it hands the decision to the inherited umask instead.
// Under a hardened `umask 077x` tar then creates directories it cannot
// descend into and extraction fails part-way. Pinning the umask for the
// duration makes the extracted tree exactly private, whatever the caller's
// ambient state is. `spawnSync` keeps this window free of interleaved work.
const extracted = withPrivateUmask(() =>
spawnSync(
TAR_EXECUTABLE,
[
"--extract",
"--gzip",
"--file",
"/proc/self/fd/3",
"--directory",
extractionRoot,
"--no-same-owner",
"--no-same-permissions",
],
{
encoding: "utf8",
maxBuffer: 1_048_576,
timeout: 30_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
),
);
if (extracted.status !== 0 || extracted.signal || extracted.error) {
throw new Error(
@@ -592,7 +603,7 @@ function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateMan
async function materializeCapturedArchive(
archive: Buffer,
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
const root = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-archive-"));
const file = path.join(root, "candidate.tar.gz");
let handle: FileHandle | undefined;
try {
@@ -601,6 +612,11 @@ async function materializeCapturedArchive(
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
// `open` subtracts the umask too. The extractor re-opens this file by
// `/proc/self/fd/N` from a child process, and that re-open is a real
// permission check, so a umask-zeroed mode makes `tar` fail to read the
// candidate it was just handed.
await handle.chmod(0o600);
await handle.writeFile(archive);
await handle.sync();
await unlink(file);
+1 -1
View File
@@ -4,7 +4,7 @@ export const ciContractReportSchema = z
.object({
schemaVersion: z.literal(2),
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
gateCount: z.literal(26),
gateCount: z.literal(27),
commandDefinitionCount: z.number().int().positive(),
commandReferenceCount: z.number().int().positive(),
artifactCount: z.number().int().positive(),
+6
View File
@@ -0,0 +1,6 @@
const RAW_PALETTE_VALUE_PATTERN =
/(?<![a-z0-9/_-])(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i;
export function containsRawPaletteValue(source: string): boolean {
return RAW_PALETTE_VALUE_PATTERN.test(source);
}
+135
View File
@@ -0,0 +1,135 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { buildContractSet } from "../generate-contract-set.ts";
import { contractSetSchema, type ContractSet } from "../../src/contracts/contract-set.ts";
/**
* `corepack pnpm dev` never runs the build pipeline: plain `vite` serves
* `public/release-manifest.json` verbatim. That fixture therefore has to carry
* the same `contractSet` block `scripts/generate-build-manifest.ts` writes into
* `dist/release-manifest.json`, because `verifyContractSet` runs unconditionally
* at boot in the default `MOCK` mode as much as in `HTTP` mode. When the two
* disagree the app renders the fail-closed boot screen instead of the shell.
*
* Both halves of the answer live here so they cannot disagree: the generation
* step rewrites the block, and the CI gate compares it.
*/
export const DEV_RELEASE_MANIFEST_PATH = "public/release-manifest.json";
export type DevReleaseManifestContractSet = ContractSet;
function resolveManifestPath(root: string): string {
return path.join(root, DEV_RELEASE_MANIFEST_PATH);
}
/**
* The contract set the build would compile, derived from
* `EXPECTED_CONTRACT_SET_PACKAGES` exactly as `generate-build-manifest.ts`
* derives it.
*/
export function expectedDevReleaseManifestContractSet(): DevReleaseManifestContractSet {
return contractSetSchema.parse(
JSON.parse(JSON.stringify(buildContractSet())),
);
}
/**
* Compares one manifest `contractSet` block against the compiled expectation.
* Returns one human-readable line per disagreement; an empty array means the
* fixture would boot.
*/
export function compareDevReleaseManifestContractSet(
actual: unknown,
expected: DevReleaseManifestContractSet,
): string[] {
const parsed = contractSetSchema.safeParse(actual);
if (!parsed.success) {
return [
`${DEV_RELEASE_MANIFEST_PATH} contractSet is missing or not a valid contract set: ${parsed.error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("; ")}`,
];
}
const manifest = parsed.data;
const failures: string[] = [];
if (manifest.setAlgorithm !== expected.setAlgorithm) {
failures.push(
`setAlgorithm drift: manifest ${manifest.setAlgorithm}, build ${expected.setAlgorithm}`,
);
}
if (manifest.setDigest !== expected.setDigest) {
failures.push(
`setDigest drift: manifest ${manifest.setDigest}, build ${expected.setDigest}`,
);
}
const manifestById = new Map(
manifest.packages.map((entry) => [entry.packageId, entry] as const),
);
const expectedById = new Map(
expected.packages.map((entry) => [entry.packageId, entry] as const),
);
for (const [packageId, entry] of expectedById) {
const declared = manifestById.get(packageId);
if (!declared) {
failures.push(
`package missing from the manifest (boot fails with CONTRACT_SET_PACKAGE_MISSING): ${packageId}@${entry.version}`,
);
continue;
}
if (JSON.stringify(declared) !== JSON.stringify(entry)) {
failures.push(
`package drift for ${packageId}: manifest ${JSON.stringify(declared)}, build ${JSON.stringify(entry)}`,
);
}
}
for (const packageId of manifestById.keys()) {
if (!expectedById.has(packageId)) {
failures.push(
`package declared by the manifest but not compiled into the build (boot fails with CONTRACT_SET_PACKAGE_UNEXPECTED): ${packageId}`,
);
}
}
return failures;
}
export async function readDevReleaseManifest(
root: string = process.cwd(),
): Promise<Record<string, unknown>> {
const raw = await readFile(resolveManifestPath(root), "utf8");
const document: unknown = JSON.parse(raw);
if (!document || typeof document !== "object" || Array.isArray(document)) {
throw new TypeError(`${DEV_RELEASE_MANIFEST_PATH} must be a JSON object`);
}
return document as Record<string, unknown>;
}
export async function checkDevReleaseManifestContractSet(
root: string = process.cwd(),
): Promise<readonly string[]> {
const document = await readDevReleaseManifest(root);
return compareDevReleaseManifestContractSet(
document["contractSet"],
expectedDevReleaseManifestContractSet(),
);
}
/**
* Rewrites only the `contractSet` block. Every other field of the hand
* maintained fixture is preserved byte for byte by round-tripping the same
* two-space JSON encoding the file already uses.
*/
export async function refreshDevReleaseManifestContractSet(
root: string = process.cwd(),
): Promise<Readonly<{ changed: boolean; contractSet: DevReleaseManifestContractSet }>> {
const target = resolveManifestPath(root);
const document = await readDevReleaseManifest(root);
const contractSet = expectedDevReleaseManifestContractSet();
const previous = JSON.stringify(document["contractSet"]);
document["contractSet"] = contractSet;
const next = `${JSON.stringify(document, null, 2)}\n`;
const changed = previous !== JSON.stringify(contractSet);
if (changed) await writeFile(target, next, "utf8");
return Object.freeze({ changed, contractSet });
}
+22 -6
View File
@@ -41,6 +41,7 @@ import {
type ReleaseCandidateManifest,
} from "./release-candidate.ts";
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
import { findViteDynamicRouteChunk } from "./vite-route-chunks.ts";
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
import {
compareStoredDependencyEvidence,
@@ -1256,12 +1257,27 @@ async function verifyReleaseOutputs(
ROUTE_RUNTIME_CONTRACT;
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime = runtimeContracts[definition.routeId];
const viteEntry = Object.values(viteManifest).find(
(entry) =>
isRecord(entry) &&
entry.name === runtime?.moduleId &&
entry.isDynamicEntry === true,
);
const viteEntry = runtime
? findViteDynamicRouteChunk(
Object.fromEntries(
Object.entries(viteManifest).flatMap(([key, entry]) => {
if (!isRecord(entry) || typeof entry.file !== "string") return [];
return [[key, {
file: entry.file,
...(typeof entry.name === "string" ? { name: entry.name } : {}),
...(typeof entry.isDynamicEntry === "boolean"
? { isDynamicEntry: entry.isDynamicEntry }
: {}),
...(Array.isArray(entry.dynamicImports) &&
entry.dynamicImports.every((item) => typeof item === "string")
? { dynamicImports: entry.dynamicImports as string[] }
: {}),
}]];
}),
),
runtime.moduleId,
)
: undefined;
const file = isRecord(viteEntry) ? viteEntry.file : null;
if (
typeof file !== "string" ||
+36
View File
@@ -0,0 +1,36 @@
import { mkdirSync, mkdtempSync } from "node:fs";
/**
* Creation modes that must not depend on the caller's ambient umask.
*
* `mkdir(path, { mode: 0o700 })` and `open(path, ..., 0o600)` are requests, not
* guarantees: the kernel subtracts the process umask from every one of them. A
* runner hardened with `umask 0777` therefore produces directories nobody can
* enter and files nobody can read, and the failure surfaces far from its cause
* as `tar` failing to mkdir a nested path, or as EACCES opening a staging
* leaf this process created moments earlier.
*
* Release evidence has to be exactly private, so the mode is pinned rather than
* inherited. The pin is held across a synchronous call only: nothing else in
* this process can interleave, so the global umask is never observably changed.
*/
const PRIVATE_UMASK = 0o077;
export function withPrivateUmask<T>(operation: () => T): T {
const previous = process.umask(PRIVATE_UMASK);
try {
return operation();
} finally {
process.umask(previous);
}
}
/** Creates a uniquely named private directory under `prefix`. */
export function makePrivateTemporaryDirectory(prefix: string): string {
return withPrivateUmask(() => mkdtempSync(prefix));
}
/** Creates `target` privately, failing if it already exists. */
export function makePrivateDirectory(target: string): void {
withPrivateUmask(() => mkdirSync(target, { mode: 0o700 }));
}
+14 -2
View File
@@ -6,7 +6,6 @@ import {
import { constants } from "node:fs";
import {
lstat,
mkdir,
open,
readdir,
rm,
@@ -38,6 +37,7 @@ import {
} from "./ci-candidate-archive.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
import { makePrivateDirectory } from "./private-filesystem.ts";
export type StagedFile = Readonly<{
@@ -334,6 +334,18 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
await dependencies.beforeRemove?.();
const visibleParent = await lstat(parent);
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
// Re-bind the name to the inode before removing anything.
//
// The removals below run through the pinned staging descriptor, so they
// always reach the owned inode even after the name has been re-pointed
// somewhere else. That is safe for the substitute, but it destroys this
// promotion's exact five first and only reports the substitution
// afterwards — a caller that retries then finds a half-emptied staging
// directory and no way to tell a completed cleanup from an interrupted
// one. Detecting the swap here makes the failure total: nothing is
// removed unless the leaf still is what was validated.
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
for (const name of PROMOTED_FILE_NAMES) {
await rm(path.join(stagingDescriptorRoot, name), { force: false });
}
@@ -456,7 +468,7 @@ export async function publishPrivatePromotionStaging(
try {
const procMetadata = await stat(descriptorRoot);
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
await mkdir(descriptorStaging, { mode: 0o700 });
makePrivateDirectory(descriptorStaging);
ownsStaging = true;
const createdStaging = await lstat(descriptorStaging);
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
+43 -3
View File
@@ -112,6 +112,21 @@ export function systemdRunProviderArguments(
export type ProviderScopeFrame = Readonly<{
bwrapInput: Buffer;
/**
* The sandboxed command, kept out of the args file on purpose.
*
* `bwrap --args FD` splices the file's options into the option stream, but
* bubblewrap stops at the first non-option and never propagates the command
* back out of the recursive parse. A command written into the args file is
* therefore silently dropped and bubblewrap exits with its usage text, so
* the sandbox is never entered and the provider produces no evidence at all.
* Only the options may be hidden; the command travels on real argv.
*
* Nothing secret lives here: credentials and the provider command reach the
* sandbox through `--setenv` inside the args file, and this vector only ever
* names `prlimit` and a shell that expands `$PROVIDER_COMMAND`.
*/
bwrapCommand: readonly string[];
reportPath: string;
reportDev: number;
reportIno: number;
@@ -126,8 +141,10 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
) {
throw new TypeError("provider scope frame is invalid");
}
assertBwrapCommand(input.bwrapCommand);
const payload = Buffer.from(JSON.stringify({
bwrapInputBase64: input.bwrapInput.toString("base64"),
bwrapCommand: [...input.bwrapCommand],
reportPath: input.reportPath,
reportDev: input.reportDev,
reportIno: input.reportIno,
@@ -138,13 +155,36 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
return frame;
}
/**
* The command vector bubblewrap will exec. It has to be an absolute executable
* so the sandbox never resolves it through a `PATH` the caller controls.
*/
export function assertBwrapCommand(command: readonly string[]): void {
if (
!Array.isArray(command) || command.length === 0 ||
typeof command[0] !== "string" || !command[0].startsWith("/") ||
command.some((argument) =>
typeof argument !== "string" || argument.includes("\0"),
)
) {
throw new TypeError("provider bwrap command is invalid");
}
}
export function encodeProviderBwrapInput(
arguments_: readonly string[],
optionArguments: readonly string[],
environment: Readonly<Record<string, string | undefined>>,
): Buffer {
if (arguments_.some((argument) => argument.includes("\0"))) {
if (optionArguments.some((argument) => argument.includes("\0"))) {
throw new TypeError("provider bwrap argument is invalid");
}
// A bare `--` ends bubblewrap's option stream. Inside an args file that also
// ends the recursive parse, so everything after it is discarded rather than
// executed. Refusing it here keeps the drop from being reintroduced by a
// caller that appends a command to the option list.
if (optionArguments.includes("--")) {
throw new TypeError("provider bwrap options may not terminate the option stream");
}
const entries = Object.entries(environment).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
);
@@ -155,7 +195,7 @@ export function encodeProviderBwrapInput(
}
const input = ["--clearenv"];
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
input.push(...arguments_);
input.push(...optionArguments);
return Buffer.from(`${input.join("\0")}\0`);
}

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