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);
}
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; };
@@ -1,7 +1,8 @@
import type { components } from "../../contracts/studio/generated.ts";
import type { Asset } from "../../contracts/studio/contract.ts";
import {
evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn,
supportsEvidenceKeyFromReadyAssets,
} from "../../domain/content-format/asset-evidence-catalog.ts";
import {
projectWorkingCopy as projectWorkingCopyWithEvidence,
@@ -12,6 +13,7 @@ import {
resolveEvidenceAssetDescriptor,
} from "../static/evidence-assets.ts";
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
// 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
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
//
// Fix round 2 (I2 regression). Gate 1 (`supportsEvidenceKey` below) must ask
// "does a real Asset (or the legacy key) back this", not "is there any
// EVIDENCE catalog row for it" -- `catalog` carries EVIDENCE rows unrelated
// to media (e.g. a QUESTION resolution-target row). Handing gate 1 the
// merged catalog let a key backed by nothing pass the projection and reach
// the resolver below, whose own `isSupportedEvidenceKey`-only fallback then
// threw a raw, unwrapped `Error` -- the disagreement in the opposite
// direction from I2 (preview crashing instead of `validateDocument`
// rejecting), and a contract violation on top (`StudioGatewayError` is what
// may leave this port, never a bare `Error`). With gate 1 narrowed to
// asset-backing/legacy only, any key the resolver would fail to resolve was
// already rejected before `projectWorkingCopyWithEvidence` returns, so the
// resolver's fallback throw below is unreachable for every key that made it
// this far.
// Fix round 3 (I2 regression, third instance). Gate 1 (`supportsEvidenceKey`
// below) is built from `supportsEvidenceKeyFromReadyAssets` -- the exact
// predicate the resolver below uses (`assetKey === key && READY &&
// publicPath truthy`) -- not a catalog lookup. Round 1 gated on "is there
// any EVIDENCE catalog row for it" (too loose: rows that exist for
// something other than media, e.g. a QUESTION resolution target, passed).
// Round 2 narrowed to a catalog synthesized *from* Assets, but that catalog
// lookup (`evidenceCatalogEntryFor`, matching `id || label || publicPath`)
// is still a different expression than the resolver's own condition: a
// `READY` Asset with `publicPath: null`/`""`, or one whose `publicPath`
// happens to satisfy the legacy `/media/${someOtherKey}.svg` convention for
// a key that is not its own `assetKey`, passed that gate while the resolver
// below could not produce real pixels for it -- the same
// `validateDocument`-says-VALID / `createPreview`-throws disagreement, twice.
// Gate 2 (`evidenceCatalogEntryFor` below) keeps reading the merged catalog.
export function projectWorkingCopy(
input: ProjectArguments[0],
catalog: ProjectArguments[1],
context: ProjectArguments[2],
assets: readonly Asset[] = [],
) {
const assetEntries = evidenceCatalogEntriesFromAssets(assets);
const evidenceCatalog = [...catalog, ...assetEntries];
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
const supportsEvidenceKey = (key: string) =>
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key);
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key);
const model = projectWorkingCopyWithEvidence(
input,
evidenceCatalog,
@@ -54,7 +56,24 @@ export function projectWorkingCopy(
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(
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
);
@@ -69,13 +88,17 @@ export function projectWorkingCopy(
decorative: asset.decorative,
};
}
if (!isSupportedEvidenceKey(key)) {
// Defensive only -- gate 1 above already rejects any key that would
// 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}`);
if (isSupportedEvidenceKey(key)) {
return resolveEvidenceAssetDescriptor(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 {
evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn,
supportsEvidenceKeyFromReadyAssets,
} from "../../domain/content-format/asset-evidence-catalog.ts";
import { parseCaseContent } from "../../domain/content-format/parse-case-content.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`
* below) intentionally does NOT read the merged catalog -- see that
* 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>;
};
@@ -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.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
else try {
const assetEntries = evidenceCatalogEntriesFromAssets(dependencies.assets);
const evidenceCatalog = [...dependencies.catalog, ...assetEntries];
// Fix round 2 (I2 regression). Gate 1 must ask "does a real Asset (or
// the legacy key) back this" -- NOT "is there any EVIDENCE catalog row
// for it". `dependencies.catalog` carries EVIDENCE rows unrelated to
// media (e.g. a QUESTION resolution-target row), so handing gate 1 the
// merged catalog (as fix round 1 did) let a key backed by nothing
// resolve here while `createPreview`'s resolver -- which only ever
// knew about the legacy key and real Assets -- threw a raw, unwrapped
// Error. Gate 2 (`evidenceCatalogEntryFor` below) still reads the
// merged catalog; only gate 1 narrows.
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
// Fix round 3 (I2 regression, third instance). Gate 1 must ask "does a
// real, resolvable Asset (or the legacy key) back this" -- not "is
// there a catalog row for it" (round 1's bug) and not "is there a
// catalog row synthesized *from* an Asset for it" either (round 2's
// bug: `evidenceCatalogEntryFor` matches on `id || label ||
// publicPath`, which is still gate 2's question over fewer rows, and a
// `READY` Asset with a null/empty `publicPath`, or one whose
// `publicPath` happens to satisfy the legacy convention for a
// *different* key, passed it while the resolver could not produce
// 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) =>
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]));
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
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
* function instead.
*
* Fix round 2 (I2 regression): this module supplies the *building blocks* for
* gate 1 (`supportsEvidenceKey` — "may this document reference this key at
* all") and gate 2 (`evidenceCatalogEntryFor` — "does some catalog actually
* carry it"); it deliberately does not hand callers a single combined
* predicate. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)` for BOTH
* gates, which made gate 1 ask the identical question gate 2 already asks —
* so any `EVIDENCE` catalog row backing something other than an Asset (e.g.
* the pre-existing "Fetch Join Case" row that exists for QUESTION resolution
* targets, not media) passed gate 1 even though no Asset and no legacy key
* back it. Callers must instead compose gate 1 from *asset-backing only*:
* `(key) => isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))(key)`,
* then hand gate 2 the merged catalog (`[...documentCatalog,
* ...evidenceCatalogEntriesFromAssets(assets)]`) separately. See
* `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
* and `instant-preview.tsx` for the three compositions (each has its own
* notion of "legacy key" per its layer).
* Fix round 2 (I2 regression): gate 1 ("may this document reference this key
* at all") and gate 2 ("does some catalog actually carry it") must ask
* different questions. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)`
* for BOTH gates, so any `EVIDENCE` catalog row backing something other than
* an Asset (e.g. the pre-existing "Fetch Join Case" row that exists for
* QUESTION resolution targets, not media) passed gate 1 even though no Asset
* and no legacy key back it. Round 2 narrowed gate 1 to
* `supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))` --
* still `evidenceCatalogEntryFor` under the hood, matching on
* `id || label || publicPath`.
*
* Fix round 3 (same regression, third instance): `evidenceCatalogEntryFor`
* is gate 2's question, asked over fewer rows -- it is still not the same
* predicate as the resolver's actual success condition (`assetKey === key &&
* managementStatus === "READY" && Boolean(publicPath)`), so a `READY` Asset
* 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(
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(
catalog: ReadonlyArray<CatalogEntry>,
): SupportsEvidenceKey {
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,
resolveCaseEvidenceAssets,
} 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 { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
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
* 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"
* half now (fix round 2) -- see `supportsEvidenceKeyForAssets` below.
* backs it, so `assets` never resolves it.
*/
function isLegacyStaticEvidenceKey(key: string): boolean {
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
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
@@ -111,7 +94,27 @@ export function InstantPreview({
draft,
effectiveCatalog,
{ 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),
);