Commit Graph
125 Commits
Author SHA1 Message Date
DongHyeonka 21f8425f1e fix: keep line breaks in the fields that are not Markdown
The earlier fix covered the Markdown body and stopped there, so the preview
still ran lines together — which is exactly what it looked like from the
outside: nothing had changed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things the pattern did not already cover:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:08:46 +09:00