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
@@ -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),
);