fix: collapse the evidence-key gate and resolver into one decision

Three fix rounds each rebuilt the gate as a separate expression that merely
agreed with the resolver on the inputs that round's tests used. Different
expressions cannot agree in general, so the defect class stayed open while
each reported instance closed.

`findResolvableAsset(assets, key)` is now the single place that decides which
Asset an evidence key resolves to. Every gate is
`Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared
composition, and every resolver returns what it returns:

- validate-working-copy: the key gate and the decorative lookup (a last-wins
  Map against the resolver's first-wins find, so alt could be judged against a
  different Asset than the one rendered)
- adapters/mock/project-public-render-model: gate and resolver
- instant-preview: gate and descriptor resolver
- createAssetCatalogResolver: the pixels, a fourth expression nobody had
  listed -- one Asset's caption could sit over another Asset's image

Duplicate assetKeys are a contract violation but reachable through a paged
list, so the choice is total and order-independent: newest updatedAt wins,
tie-broken by id.

InstantPreview's gate is no longer looser than the others. The un-loaded-asset
case it was loosened for blanks either way; all the looseness bought was
catalog-only keys rendering an empty gap with no message while validation said
EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the
consistent behaviour, and the false comment claiming a fix that did not exist
is gone.

idempotent() now maps a deterministic content failure to VALIDATION_STALE/409
instead of offering a retry that fails identically, and no longer caches
uncharacterized internal failures -- reporting one as retryable while freezing
it in the ledger meant the retry could never re-run.

Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial
(asset list, key) combinations asserting the agreement itself rather than
examples. It reported 53 disagreements against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 06:55:06 +09:00
co-authored by Claude Opus 5
parent 783e9b2cf1
commit 073fda87eb
9 changed files with 754 additions and 275 deletions
@@ -2,7 +2,8 @@ import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import {
evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn,
findResolvableAsset,
supportsResolvableEvidenceKey,
} from "../../../domain/content-format/asset-evidence-catalog.ts";
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
import {
@@ -27,19 +28,15 @@ function isLegacyStaticEvidenceKey(key: string): boolean {
}
/**
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
* for the pixels actually shown; that comes from `resolveEvidenceAsset` below
* (`createAssetCatalogResolver`). So this must never throw: a key the Asset Picker
* (Task 10) inserts, with no catalog match yet, still gets a placeholder descriptor
* instead of blanking the whole preview behind an error panel.
* Total -- never throws. Resolves through `findResolvableAsset`, the same
* function this file's key gate and `createAssetCatalogResolver` (which
* supplies the pixels) use, so the descriptor attached to a block and the
* image rendered for it always come from one Asset.
*/
function resolveAssetDescriptor(assets: readonly Asset[]) {
return (key: string): ResolvedAsset => {
const asset = assets.find(
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
);
if (asset?.publicPath) {
const asset = findResolvableAsset(assets, key);
if (asset) {
return {
assetId: asset.id,
assetKey: asset.assetKey,
@@ -94,27 +91,12 @@ export function InstantPreview({
draft,
effectiveCatalog,
{ mode: "PREVIEW", publishedAt: null },
// 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),
// The same expression `validate-working-copy.ts` and the mock's
// preview projection use: a resolvable Asset, or the legacy static
// key. A key this rejects fails the projection, and the error panel
// below names it -- the same key `validateDocument` reports
// `EVIDENCE_UNSUPPORTED` for.
supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey),
),
resolveAssetDescriptor(assets),
);