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>
This commit is contained in:
DongHyeonka
2026-08-18 06:24:46 +09:00
co-authored by Claude Opus 5
parent 7ff9728a5c
commit 783e9b2cf1
6 changed files with 449 additions and 77 deletions
@@ -86,7 +86,27 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
return clone(prior.outcome.value as T); return clone(prior.outcome.value as T);
} }
try { const value = work(); state.idempotency.set(key, { fingerprint, outcome: { kind: "success", value: clone(value) } }); return clone(value); } try { const value = work(); state.idempotency.set(key, { fingerprint, outcome: { kind: "success", value: clone(value) } }); return clone(value); }
catch (error) { if (error instanceof StudioGatewayError) { state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(error.problem) } }); throw new StudioGatewayError(clone(error.problem)); } throw error; } catch (error) {
// Fix round 3. This port's contract is `StudioGatewayError` only --
// nothing else may cross it. Round 2 fixed one way a raw `Error`
// reached here (`createPreview`'s resolver); the class recurred a
// third time via a different Asset shape, and each time the actual
// symptom at the port boundary was the same: an unwrapped exception
// escaping `work()`. Rather than keep chasing individual internal
// causes, anything that isn't already a `StudioGatewayError` is
// normalized into one here, so a future internal defect degrades to a
// proper problem response instead of a bare `Error`.
const studioError = error instanceof StudioGatewayError
? error
: gatewayProblem(
500,
"STUDIO_UNAVAILABLE",
error instanceof Error ? error.message : "Studio가 요청을 처리하지 못했습니다.",
{ retryable: true },
);
state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(studioError.problem) } });
throw new StudioGatewayError(clone(studioError.problem));
}
} }
const document = (id: string) => { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; }; const document = (id: string) => { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; };
@@ -1,7 +1,8 @@
import type { components } from "../../contracts/studio/generated.ts";
import type { Asset } from "../../contracts/studio/contract.ts"; import type { Asset } from "../../contracts/studio/contract.ts";
import { import {
evidenceCatalogEntriesFromAssets, evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn, supportsEvidenceKeyFromReadyAssets,
} from "../../domain/content-format/asset-evidence-catalog.ts"; } from "../../domain/content-format/asset-evidence-catalog.ts";
import { import {
projectWorkingCopy as projectWorkingCopyWithEvidence, projectWorkingCopy as projectWorkingCopyWithEvidence,
@@ -12,6 +13,7 @@ import {
resolveEvidenceAssetDescriptor, resolveEvidenceAssetDescriptor,
} from "../static/evidence-assets.ts"; } from "../static/evidence-assets.ts";
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>; type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
// The domain projection has no asset catalog access, so a CASE projection's // The domain projection has no asset catalog access, so a CASE projection's
@@ -23,30 +25,30 @@ type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
// `validateDocument` about which evidence keys exist), so it resolves the // `validateDocument` about which evidence keys exist), so it resolves the
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`. // descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
// //
// Fix round 2 (I2 regression). Gate 1 (`supportsEvidenceKey` below) must ask // Fix round 3 (I2 regression, third instance). Gate 1 (`supportsEvidenceKey`
// "does a real Asset (or the legacy key) back this", not "is there any // below) is built from `supportsEvidenceKeyFromReadyAssets` -- the exact
// EVIDENCE catalog row for it" -- `catalog` carries EVIDENCE rows unrelated // predicate the resolver below uses (`assetKey === key && READY &&
// to media (e.g. a QUESTION resolution-target row). Handing gate 1 the // publicPath truthy`) -- not a catalog lookup. Round 1 gated on "is there
// merged catalog let a key backed by nothing pass the projection and reach // any EVIDENCE catalog row for it" (too loose: rows that exist for
// the resolver below, whose own `isSupportedEvidenceKey`-only fallback then // something other than media, e.g. a QUESTION resolution target, passed).
// threw a raw, unwrapped `Error` -- the disagreement in the opposite // Round 2 narrowed to a catalog synthesized *from* Assets, but that catalog
// direction from I2 (preview crashing instead of `validateDocument` // lookup (`evidenceCatalogEntryFor`, matching `id || label || publicPath`)
// rejecting), and a contract violation on top (`StudioGatewayError` is what // is still a different expression than the resolver's own condition: a
// may leave this port, never a bare `Error`). With gate 1 narrowed to // `READY` Asset with `publicPath: null`/`""`, or one whose `publicPath`
// asset-backing/legacy only, any key the resolver would fail to resolve was // happens to satisfy the legacy `/media/${someOtherKey}.svg` convention for
// already rejected before `projectWorkingCopyWithEvidence` returns, so the // a key that is not its own `assetKey`, passed that gate while the resolver
// resolver's fallback throw below is unreachable for every key that made it // below could not produce real pixels for it -- the same
// this far. // `validateDocument`-says-VALID / `createPreview`-throws disagreement, twice.
// Gate 2 (`evidenceCatalogEntryFor` below) keeps reading the merged catalog.
export function projectWorkingCopy( export function projectWorkingCopy(
input: ProjectArguments[0], input: ProjectArguments[0],
catalog: ProjectArguments[1], catalog: ProjectArguments[1],
context: ProjectArguments[2], context: ProjectArguments[2],
assets: readonly Asset[] = [], assets: readonly Asset[] = [],
) { ) {
const assetEntries = evidenceCatalogEntriesFromAssets(assets); const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
const evidenceCatalog = [...catalog, ...assetEntries];
const supportsEvidenceKey = (key: string) => const supportsEvidenceKey = (key: string) =>
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key); isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key);
const model = projectWorkingCopyWithEvidence( const model = projectWorkingCopyWithEvidence(
input, input,
evidenceCatalog, evidenceCatalog,
@@ -54,7 +56,24 @@ export function projectWorkingCopy(
supportsEvidenceKey, supportsEvidenceKey,
); );
return resolveCaseEvidenceAssets(model, (key) => { return resolveCaseEvidenceAssets(model, resolveAssetDescriptor(assets));
}
/**
* Fix round 3. Total -- never throws -- the same discipline
* `instant-preview.tsx`'s resolver already followed. This class of bug has
* now recurred twice as "gate 1 passes a key the resolver then cannot
* resolve, and the resolver throws a raw, unwrapped `Error`". With gate 1
* above built from this function's exact condition, that specific
* disagreement cannot happen through `projectWorkingCopy`'s own interface
* any more -- but a resolver that structurally cannot throw is worth more
* than a resolver plus a comment asserting it never will (round 2's comment
* here made exactly that claim, and was wrong). Kept as defence in depth:
* if a future change reintroduces a mismatch, this degrades to a
* placeholder instead of putting a bare `Error` through the gateway port.
*/
function resolveAssetDescriptor(assets: readonly Asset[]) {
return (key: string): ResolvedAsset => {
const asset = assets.find( const asset = assets.find(
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY", (candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
); );
@@ -69,13 +88,17 @@ export function projectWorkingCopy(
decorative: asset.decorative, decorative: asset.decorative,
}; };
} }
if (!isSupportedEvidenceKey(key)) { if (isSupportedEvidenceKey(key)) {
// Defensive only -- gate 1 above already rejects any key that would return resolveEvidenceAssetDescriptor(key);
// reach here without a resolvable Asset or the legacy key. Kept as a
// guard rather than removed so a future gate regression fails loudly
// here too, not silently.
throw new Error(`Unknown local evidence asset: ${key}`);
} }
return resolveEvidenceAssetDescriptor(key); return {
}); assetId: "00000000-0000-4000-8000-000000000000",
assetKey: key,
mediaType: "application/octet-stream",
publicPath: "",
width: null,
height: null,
decorative: true,
};
};
} }
@@ -2,7 +2,7 @@ import type { components } from "../../contracts/studio/generated.ts";
import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts"; import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
import { import {
evidenceCatalogEntriesFromAssets, evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn, supportsEvidenceKeyFromReadyAssets,
} from "../../domain/content-format/asset-evidence-catalog.ts"; } from "../../domain/content-format/asset-evidence-catalog.ts";
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts"; import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts"; import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
@@ -29,6 +29,11 @@ export type ValidationDependencies = {
* `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey` * `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey`
* below) intentionally does NOT read the merged catalog -- see that * below) intentionally does NOT read the merged catalog -- see that
* variable's comment. * variable's comment.
*
* Fix round 3: gate 1 also stopped reading a catalog derived from these
* assets at all -- it now calls `supportsEvidenceKeyFromReadyAssets`
* directly against this array, the same predicate the resolver uses, so
* the two can no longer disagree.
*/ */
assets: ReadonlyArray<Asset>; assets: ReadonlyArray<Asset>;
}; };
@@ -182,19 +187,23 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요."); if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요."); if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
else try { else try {
const assetEntries = evidenceCatalogEntriesFromAssets(dependencies.assets); const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
const evidenceCatalog = [...dependencies.catalog, ...assetEntries]; // Fix round 3 (I2 regression, third instance). Gate 1 must ask "does a
// Fix round 2 (I2 regression). Gate 1 must ask "does a real Asset (or // real, resolvable Asset (or the legacy key) back this" -- not "is
// the legacy key) back this" -- NOT "is there any EVIDENCE catalog row // there a catalog row for it" (round 1's bug) and not "is there a
// for it". `dependencies.catalog` carries EVIDENCE rows unrelated to // catalog row synthesized *from* an Asset for it" either (round 2's
// media (e.g. a QUESTION resolution-target row), so handing gate 1 the // bug: `evidenceCatalogEntryFor` matches on `id || label ||
// merged catalog (as fix round 1 did) let a key backed by nothing // publicPath`, which is still gate 2's question over fewer rows, and a
// resolve here while `createPreview`'s resolver -- which only ever // `READY` Asset with a null/empty `publicPath`, or one whose
// knew about the legacy key and real Assets -- threw a raw, unwrapped // `publicPath` happens to satisfy the legacy convention for a
// Error. Gate 2 (`evidenceCatalogEntryFor` below) still reads the // *different* key, passed it while the resolver could not produce
// merged catalog; only gate 1 narrows. // real pixels for it). Gate 1 is now built directly from
// `supportsEvidenceKeyFromReadyAssets` -- the exact predicate the
// resolver uses (`assetKey === key && READY && publicPath truthy`) --
// so the two cannot diverge. Gate 2 (`evidenceCatalogEntryFor` below)
// still reads the merged catalog; only gate 1 changed.
const supportsEvidenceKey = (key: string) => const supportsEvidenceKey = (key: string) =>
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key); isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(dependencies.assets)(key);
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset])); const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") { for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`); if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
@@ -17,22 +17,34 @@ type CatalogEntry = components["schemas"]["CatalogEntry"];
* preview perfectly and then fail validation. Both paths now call this one * preview perfectly and then fail validation. Both paths now call this one
* function instead. * function instead.
* *
* Fix round 2 (I2 regression): this module supplies the *building blocks* for * Fix round 2 (I2 regression): gate 1 ("may this document reference this key
* gate 1 (`supportsEvidenceKey` — "may this document reference this key at * at all") and gate 2 ("does some catalog actually carry it") must ask
* all") and gate 2 (`evidenceCatalogEntryFor` — "does some catalog actually * different questions. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)`
* carry it"); it deliberately does not hand callers a single combined * for BOTH gates, so any `EVIDENCE` catalog row backing something other than
* predicate. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)` for BOTH * an Asset (e.g. the pre-existing "Fetch Join Case" row that exists for
* gates, which made gate 1 ask the identical question gate 2 already asks — * QUESTION resolution targets, not media) passed gate 1 even though no Asset
* so any `EVIDENCE` catalog row backing something other than an Asset (e.g. * and no legacy key back it. Round 2 narrowed gate 1 to
* the pre-existing "Fetch Join Case" row that exists for QUESTION resolution * `supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))` --
* targets, not media) passed gate 1 even though no Asset and no legacy key * still `evidenceCatalogEntryFor` under the hood, matching on
* back it. Callers must instead compose gate 1 from *asset-backing only*: * `id || label || publicPath`.
* `(key) => isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))(key)`, *
* then hand gate 2 the merged catalog (`[...documentCatalog, * Fix round 3 (same regression, third instance): `evidenceCatalogEntryFor`
* ...evidenceCatalogEntriesFromAssets(assets)]`) separately. See * is gate 2's question, asked over fewer rows -- it is still not the same
* `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`, * predicate as the resolver's actual success condition (`assetKey === key &&
* and `instant-preview.tsx` for the three compositions (each has its own * managementStatus === "READY" && Boolean(publicPath)`), so a `READY` Asset
* notion of "legacy key" per its layer). * with `publicPath: null`/`""`, or one whose `publicPath` happens to match
* `/media/${someOtherKey}.svg` for a key that isn't its own `assetKey`,
* still passed gate 1 while the resolver could not produce real pixels for
* it. Gate 1 must therefore be built directly from
* `supportsEvidenceKeyFromReadyAssets` below -- the resolver's own
* condition, not a catalog lookup -- never through
* `evidenceCatalogEntryFor`/`supportsEvidenceKeyIn`. Gate 2 keeps using the
* merged catalog via `evidenceCatalogEntryFor` as before. See
* `validate-working-copy.ts` and `adapters/mock/project-public-render-model.ts`
* for the compositions; `instant-preview.tsx` deliberately keeps gate 1 on
* the merged catalog (its resolver can never throw, so a loose gate there
* only ever degrades to a placeholder, and a strict assets-only gate was
* itself a regression -- see that file's comment).
*/ */
export function evidenceCatalogEntriesFromAssets( export function evidenceCatalogEntriesFromAssets(
assets: readonly Asset[], assets: readonly Asset[],
@@ -55,9 +67,43 @@ export function evidenceCatalogEntriesFromAssets(
})); }));
} }
/** `SupportsEvidenceKey` built from the same rule the catalog-entry gate uses. */ /**
* `SupportsEvidenceKey` built from the same rule the catalog-entry gate
* uses. Deliberately kept -- `instant-preview.tsx`'s gate 1 still uses this
* over the merged catalog on purpose (see that file), but fix round 3
* removed it from `validate-working-copy.ts` and
* `adapters/mock/project-public-render-model.ts`'s gate 1: it answers gate
* 2's question, not "can a real Asset resolve this key", and reusing it for
* gate 1 is what let this bug recur twice.
*/
export function supportsEvidenceKeyIn( export function supportsEvidenceKeyIn(
catalog: ReadonlyArray<CatalogEntry>, catalog: ReadonlyArray<CatalogEntry>,
): SupportsEvidenceKey { ): SupportsEvidenceKey {
return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key)); return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key));
} }
/**
* Fix round 3. The resolver's own success condition, extracted so gate 1 can
* be built from the *exact* predicate that decides whether a key actually
* produces pixels, instead of a catalog lookup that can diverge from it
* (`evidenceCatalogEntryFor` matches on `id || label || publicPath` against
* whatever `evidenceCatalogEntriesFromAssets` happened to synthesize, which
* is not the same expression as "does some `READY` Asset's own `assetKey`
* equal this key and does it have real `publicPath`"). Two shapes where they
* disagreed: 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 is not its own
* `assetKey`. Both passed the catalog-based gate while the resolver could
* not produce a real descriptor for them.
*/
export function supportsEvidenceKeyFromReadyAssets(
assets: readonly Asset[],
): SupportsEvidenceKey {
return (key: string) =>
assets.some(
(asset) =>
asset.assetKey === key &&
asset.managementStatus === "READY" &&
Boolean(asset.publicPath),
);
}
@@ -9,7 +9,6 @@ import {
projectWorkingCopy, projectWorkingCopy,
resolveCaseEvidenceAssets, resolveCaseEvidenceAssets,
} from "../../../domain/content-format/project-public-render-model.ts"; } from "../../../domain/content-format/project-public-render-model.ts";
import type { SupportsEvidenceKey } from "../../../domain/public-render-content.ts";
import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts"; import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
@@ -21,28 +20,12 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"];
/** /**
* The one legacy key predates the Asset gateway entirely: the document * The one legacy key predates the Asset gateway entirely: the document
* catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record * catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record
* backs it, so `assets` never resolves it. This is also gate 1's "legacy" * backs it, so `assets` never resolves it.
* half now (fix round 2) -- see `supportsEvidenceKeyForAssets` below.
*/ */
function isLegacyStaticEvidenceKey(key: string): boolean { function isLegacyStaticEvidenceKey(key: string): boolean {
return key === "fetch-strategy-boundary"; return key === "fetch-strategy-boundary";
} }
/**
* Gate 1: "may this document reference this key at all" -- the legacy key,
* or a `READY` Asset actually backs it. Deliberately narrower than gate 2
* (the merged-catalog lookup `projectWorkingCopy` runs internally): fix
* round 1 collapsed this into `supportsEvidenceKeyIn(effectiveCatalog)`,
* which made gate 1 ask the same question as gate 2 and let any `EVIDENCE`
* catalog row backing something other than an Asset (e.g. a QUESTION
* resolution-target row) pass gate 1 with no Asset and no legacy key behind
* it. See `asset-evidence-catalog.ts`'s module doc for the shared shape.
*/
function supportsEvidenceKeyForAssets(assets: readonly Asset[]): SupportsEvidenceKey {
const assetOnly = supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets));
return (key: string) => isLegacyStaticEvidenceKey(key) || assetOnly(key);
}
/** /**
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring * `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset` * model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
@@ -111,7 +94,27 @@ export function InstantPreview({
draft, draft,
effectiveCatalog, effectiveCatalog,
{ mode: "PREVIEW", publishedAt: null }, { mode: "PREVIEW", publishedAt: null },
supportsEvidenceKeyForAssets(assets), // Gate 1 deliberately asks the SAME question gate 2 asks here
// (`supportsEvidenceKeyIn` over the merged catalog) -- unlike
// `validate-working-copy.ts`/`adapters/mock/project-public-render-model.ts`,
// where reusing gate 2's question for gate 1 was the bug across fix
// rounds 1-3 (see `asset-evidence-catalog.ts`'s module doc). The
// difference: `resolveAssetDescriptor` below is total and cannot
// throw, so a gate 1 that passes a key the resolver cannot fully
// resolve only ever degrades that one figure to a placeholder --
// never a crash, and there is no port-contract to violate here.
// Fix round 2 narrowed this to an assets-only check (mirroring the
// mock's gate at the time), which was itself a regression: a CASE
// referencing an Asset the editor has not yet loaded -- outside the
// Picker's 50-item page, or simply because `assets` is still `[]`
// while `listAssets` is in flight on first mount -- failed gate 1
// entirely and blanked the WHOLE preview, for every block in the
// document, not just the one unresolved reference. Reverted to the
// merged catalog (fix round 3): a key any part of the backend
// already considers legitimate (document catalog OR a loaded Asset)
// passes gate 1, and the resolver fills in real pixels when it has
// them or a placeholder when it does not.
supportsEvidenceKeyIn(effectiveCatalog),
), ),
resolveAssetDescriptor(assets), resolveAssetDescriptor(assets),
); );
@@ -20,6 +20,7 @@ import { StudioProvider } from "../../../src/features/tech-log/presentation/stud
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts"; import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
import { validateWorkingCopy } from "../../../src/features/tech-log/adapters/mock/validate-working-copy.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts"; import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts"; import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
@@ -788,3 +789,273 @@ test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a
JSON.stringify(informativeReport.issues), JSON.stringify(informativeReport.issues),
); );
}); });
// --- Fix round 3 ---
// Round 2's gate 1 (`supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))`)
// still routed through `evidenceCatalogEntryFor` (id || label || publicPath),
// which is gate 2's question over fewer rows -- not the resolver's actual
// success condition (`assetKey === key && READY && publicPath truthy`). Two
// Asset shapes make the two predicates disagree; both are exercised through
// `MockStudioDependencies.assets` injection directly, the same way a test or
// harness (or a future adapter) could construct one, bypassing the mock
// uploader (which always sets a real, matching `publicPath` and so cannot
// produce either shape itself).
function assetFixture(overrides: Partial<Asset>): Asset {
return {
id: "77777777-7777-4777-8777-777777777771",
assetKey: "shape-check",
kind: "IMAGE",
mediaType: "image/svg+xml",
originalFilename: "shape.svg",
byteSize: 10,
width: null,
height: null,
altText: null,
decorative: false,
managementStatus: "READY",
publicPath: "/media/shape-check.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
...overrides,
};
}
async function expectBothPathsRejectSlipThrough(
assets: Map<string, Asset>,
directiveKey: string,
label: string,
) {
const gateway = createMockStudioGateway({ assets });
const created = await gateway.createDocument(
{
kind: "CASE",
title: `slip-through 확인 (${label})`,
slug: `slip-through-${label}`,
summary: "gate 1과 resolver가 다른 조건을 쓰면 일어나는 문제를 확인합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: `:::evidence key="${directiveKey}" alt="근거" caption="근거" zoom="false"\n:::`,
},
{ idempotencyKey: `slip-through-create-${label}` },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: `slip-through-validate-${label}` },
);
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
assert.ok(
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
JSON.stringify(report.issues),
);
await assert.rejects(
gateway.createPreview(
created.id,
{ expectedVersion: created.version, validationId: report.validationId },
{ idempotencyKey: `slip-through-preview-${label}` },
),
(error: unknown) => {
assert.ok(
error instanceof StudioGatewayError,
`expected a StudioGatewayError, got ${String(error)}`,
);
assert.equal(error.code, "VALIDATION_STALE");
return true;
},
);
}
for (const [publicPath, label] of [
[null, "null-path"],
["", "empty-path"],
] as const) {
test(`a READY asset with a ${label === "null-path" ? "null" : "empty-string"} publicPath fails both mock paths, never as a raw Error`, async () => {
const key = `${label}-check`;
const asset = assetFixture({ assetKey: key, publicPath });
await expectBothPathsRejectSlipThrough(new Map([[asset.assetKey, asset]]), key, label);
});
}
test("a READY asset whose publicPath satisfies a different key's legacy convention fails both mock paths, never as a raw Error", async () => {
// assetKey "real-owner" legitimately owns "/media/real-owner.svg", but its
// publicPath is set to "/media/decoy-key.svg" instead -- which happens to
// satisfy `evidenceCatalogEntryFor`'s legacy `/media/${key}.svg` match for
// the UNRELATED key "decoy-key", a key this asset does not own
// (`assetKey !== "decoy-key"`). The resolver requires `assetKey === key`,
// so it can never resolve "decoy-key" from this asset.
const asset = assetFixture({ assetKey: "real-owner", publicPath: "/media/decoy-key.svg" });
await expectBothPathsRejectSlipThrough(
new Map([[asset.assetKey, asset]]),
"decoy-key",
"mismatched-path",
);
});
// idempotent()'s new wrapping (part 3 of the ruling) is not exercised by the
// shapes above -- those are caught by validateDocument's now-correct gate,
// so createPreview only ever reaches its pre-existing VALIDATION_STALE
// guard, itself already a StudioGatewayError. To exercise the *new*
// wrapping, force a genuine internal disagreement: validate while the Asset
// still backs the key (VALID), then remove it before createPreview, without
// bumping dependencyRevision -- a narrow but real staleness gap the mock's
// existing guard does not close on its own. projectWorkingCopy's gate,
// re-evaluated fresh inside createPreview against the now-mutated assets,
// correctly rejects -- and that internal ContentFormatError must not leave
// the port unwrapped.
test("idempotent() wraps a raw domain error into a StudioGatewayError, never lets it escape the port", async () => {
const asset = assetFixture({ assetKey: "race-check" });
const assets = new Map<string, Asset>([[asset.assetKey, asset]]);
const gateway = createMockStudioGateway({ assets });
const created = await gateway.createDocument(
{
kind: "CASE",
title: "검증과 미리보기 사이의 경쟁 상태",
slug: "validate-preview-race-check",
summary: "검증 이후 Asset이 사라지면 gate가 새로 평가되어 거부해야 합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: `:::evidence key="race-check" alt="근거" caption="근거" zoom="false"\n:::`,
},
{ idempotencyKey: "race-create" },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: "race-validate" },
);
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
assets.delete("race-check");
await assert.rejects(
gateway.createPreview(
created.id,
{ expectedVersion: created.version, validationId: report.validationId },
{ idempotencyKey: "race-preview" },
),
(error: unknown) => {
assert.ok(
error instanceof StudioGatewayError,
`expected a StudioGatewayError, got ${String(error)}`,
);
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
},
);
});
// EVIDENCE_NOT_FOUND has been unreachable through `createMockStudioGateway()`
// on its own: the fixture catalog it always loads already carries a row for
// the one legacy key, so gate 1 passing (via the legacy key) always implied
// gate 2 passing too. Pinned directly against `validateWorkingCopy` instead,
// with a hand-built catalog that omits the row on purpose.
test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the legacy key with no catalog EVIDENCE row", () => {
const document = {
id: "88888888-8888-4888-8888-888888888881",
version: 1,
updatedAt: "2026-08-14T00:00:00.000Z",
kind: "CASE",
title: "레거시 키인데 카탈로그에 없음",
slug: "legacy-key-no-catalog-row",
summary: "레거시 키는 gate 1을 통과하지만 카탈로그에 행이 없으면 gate 2가 거부해야 합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: ':::evidence key="fetch-strategy-boundary" alt="근거" caption="근거" zoom="false"\n:::',
};
const report = validateWorkingCopy(document as never, {
now: new Date("2026-08-14T01:00:00.000Z"),
validationId: "evidence-not-found-check",
dependencyRevision: "r1",
catalog: [
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", dependencyRevision: "r1" },
{ id: FIXTURE_IDS.projectBackend, type: "PROJECT", label: "Backend", dependencyRevision: "r1" },
// Deliberately no EVIDENCE row for "fetch-strategy-boundary" -- gate 1
// (the legacy key) passes, gate 2 (this catalog) must not.
] as never,
documents: [document as never],
assets: [],
});
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
assert.ok(
report.issues.some((issue) => issue.code === "EVIDENCE_NOT_FOUND"),
JSON.stringify(report.issues),
);
});
// Low note (reviewer): round 2's assets-only gate 1 in InstantPreview made a
// CASE referencing a document-catalog-backed key blank the WHOLE preview
// behind the error panel whenever that key's Asset was not (yet) in the
// loaded `assets` array -- outside the Picker's 50-item page, or simply
// because `assets` is still `[]` while `listAssets` is in flight. Fix round
// 3 reverted InstantPreview's gate 1 to the merged catalog; this pins that
// such a document now degrades the one unresolved figure to a placeholder
// instead of failing the whole preview.
test("Instant Preview degrades to a placeholder for a document-catalog-backed key with no loaded Asset, instead of blanking the whole preview", async () => {
const user = userEvent.setup();
const gateway = createMockStudioGateway();
const assetGateway = gatewayOf([]);
const created = await gateway.createDocument(
{
kind: "CASE",
title: "카탈로그로만 뒷받침되는 근거 미리보기",
slug: "catalog-only-evidence-preview-check",
summary: "로드된 Asset 없이 카탈로그만으로 뒷받침되는 키의 미리보기 동작을 확인합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: `## 제목\n\n:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`,
},
{ idempotencyKey: "catalog-only-evidence-preview-create" },
);
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
assert.equal(within(panel).queryByRole("alert"), null);
assert.ok(
within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }),
);
});