From 810a4e86383bf6db4ac2e797be3109dcca6486e6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 6 Aug 2026 12:03:51 +0200 Subject: [PATCH 1/2] fix(release-assets): say why a ready manifest was rejected Two hosted sites are serving content but cannot hydrate: every project-owned browser module returns 503 "Browser module manifest unavailable" because the release manifest is fetched, reported ready by the publisher, and then fails validation here. The only operator-visible signal was that 503 and a `release_manifest.fetch_ready_invalid` timing mark, which cannot distinguish framework version skew from a corrupt payload -- so the cause has to be guessed. `describeReadyReleaseAssetManifestRejection` already separates all five rejection reasons, including the schema-version-skew message that names both versions and says to deploy a newer builder. It was written, exported, tested, and never called. This calls it. A `ready` manifest that will not parse is logged at error level with the release id: it is an operator problem, and browser modules stay refused for the whole release until someone acts. Any other state is the release legitimately not being ready yet, so that stays at debug. The regression this guards is the diagnostic going quiet again, not the formatting of the message: the test asserts a schema-skewed manifest actually reaches the log, since a reason that is computable but never surfaced is worth nothing during an incident. Line pins in the generated release-assets reference move with the new code. --- .../api-reference/veryfront/release-assets.md | 22 +++++------ src/release-assets/manifest-cache.test.ts | 37 ++++++++++++++++++- src/release-assets/manifest-cache.ts | 19 +++++++++- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/docs/api-reference/veryfront/release-assets.md b/docs/api-reference/veryfront/release-assets.md index 847b332f21..0507bd2752 100644 --- a/docs/api-reference/veryfront/release-assets.md +++ b/docs/api-reference/veryfront/release-assets.md @@ -66,34 +66,34 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | Name | Description | Source | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L567) | -| `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L577) | +| `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L582) | +| `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L592) | | `contentTypeForExtension` | Resolve the content type for an extension, or null if not allowed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L96) | | `describeReadyReleaseAssetManifestRejection` | Explain why a ready manifest response was rejected. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L381) | -| `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L257) | -| `getReadyManifestForRenderAsync` | Await a ready manifest for rendering when release-manifest consumption is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L358) | +| `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L258) | +| `getReadyManifestForRenderAsync` | Await a ready manifest for rendering when release-manifest consumption is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L359) | | `hasImmutableReleaseAssetDependencies` | True only when manifest dependency entries are safe immutable rewrite targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L283) | | `isAllowedReleaseAssetContentType` | True when the value is a valid allowlisted release asset content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L105) | -| `isReleaseAssetManifestEnabled` | True when production manifest consumption is enabled via env flag. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L202) | +| `isReleaseAssetManifestEnabled` | True when production manifest consumption is enabled via env flag. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L203) | | `isSafeBoundedText` | Check that an untrusted value is a non-empty, trimmed string within `maxLength` that contains no control characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L80) | | `isValidContentHash` | Validate a content hash is exactly 64 lowercase hex characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L114) | | `normalizeManifestModuleKey` | Normalize a logical module path to the manifest's key convention. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L27) | | `parseReadyReleaseAssetManifestResponse` | Parse an untrusted ready response without executing accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L337) | | `parseReleaseAssetManifest` | Parse an untrusted manifest without requiring a registered schema extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L322) | | `readUntrustedOwnDataProperty` | Read an own data property from an untrusted value without invoking accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L425) | -| `registerManifestFetcherForRelease` | Register a project-scoped manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L158) | +| `registerManifestFetcherForRelease` | Register a project-scoped manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L159) | | `releaseAssetUrl` | Map a 64-hex content hash + extension to its public asset URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L85) | | `resolveManifestModuleUrl` | Resolve a module URL through the manifest. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L42) | | `resolveManifestRoutePreloadUrls` | Resolve the route closure module URLs for preload hints from the manifest. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L64) | | `routeForPage` | Derive a route path from a page module logical path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/route-path.ts#L50) | -| `unregisterManifestFetcherForRelease` | Remove the manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L196) | +| `unregisterManifestFetcherForRelease` | Remove the manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L197) | ### Types | Name | Description | Source | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `ImmutableReleaseAssetManifest` | Manifest whose dependency entries name uploaded content-addressed assets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L278) | -| `ReadyManifestReadOptions` | Controls revalidation behavior for awaited manifest reads. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L76) | +| `ReadyManifestReadOptions` | Controls revalidation behavior for awaited manifest reads. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L77) | | `ReadyReleaseAssetManifestResponse` | Strict ready response with a generation-matched validated manifest body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L306) | | `ReleaseAssetContentType` | MIME types accepted for immutable release asset uploads and responses. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L24) | | `ReleaseAssetCssEntry` | Content-addressed CSS entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L272) | @@ -101,9 +101,9 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | `ReleaseAssetEntry` | Content-addressed JavaScript module entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L270) | | `ReleaseAssetExtension` | File extensions supported by the immutable release asset endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L22) | | `ReleaseAssetManifest` | Validated, immutable release asset manifest v2 body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L266) | -| `ReleaseAssetManifestFetchContext` | Cancellation context passed to a release-scoped manifest fetcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L91) | -| `ReleaseAssetManifestFetcher` | Fetcher used to retrieve a manifest for a release. Registered per-releaseId by the runtime adapter that owns that release, so the correct project-scoped token is always used. Returns null when the manifest is unavailable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L108) | -| `ReleaseAssetManifestFetcherCleanup` | Idempotent cleanup for one fetcher registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L116) | +| `ReleaseAssetManifestFetchContext` | Cancellation context passed to a release-scoped manifest fetcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L92) | +| `ReleaseAssetManifestFetcher` | Fetcher used to retrieve a manifest for a release. Registered per-releaseId by the runtime adapter that owns that release, so the correct project-scoped token is always used. Returns null when the manifest is unavailable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L109) | +| `ReleaseAssetManifestFetcherCleanup` | Idempotent cleanup for one fetcher registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L117) | | `ReleaseAssetManifestResponse` | Response shape for the GET asset-manifest endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L299) | | `ReleaseAssetManifestState` | Manifest lifecycle states (DB-owned; mirrored here for runtime checks). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L290) | | `ReleaseAssetRouteEntry` | Per-route module and CSS closure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L274) | diff --git a/src/release-assets/manifest-cache.test.ts b/src/release-assets/manifest-cache.test.ts index 6301868a2a..a490f1cd9a 100644 --- a/src/release-assets/manifest-cache.test.ts +++ b/src/release-assets/manifest-cache.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { clearCachedReleaseAssetManifests, @@ -76,6 +76,41 @@ describe("release asset manifest fetcher ownership", () => { assertEquals(calls, ["newer"]); }); + it("reports why a ready manifest was rejected instead of only refusing it", async () => { + // The failure this pins: assets built by a different framework version are + // published as `ready` and refused here, which takes a site's whole client + // bundle offline. Before this, the only operator-visible signal was a 503 + // and a timing mark, so version skew and a corrupt payload looked alike. + Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); + const skewed = manifest("release-skew", 1); + registerManifestFetcherForRelease("release-skew", () => + Promise.resolve({ + state: "ready", + manifest_version: 1, + manifest: { + ...skewed, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION + 1, + }, + })); + + const originalError = console.error; + let logged = ""; + console.error = (...values: unknown[]) => { + logged += values.map((value) => typeof value === "string" ? value : JSON.stringify(value)) + .join(" "); + }; + + try { + assertEquals(await getReadyManifestForRenderAsync("release-skew"), null); + } finally { + console.error = originalError; + } + + assertStringIncludes(logged, "release-skew"); + assertStringIncludes(logged, "manifest schema version"); + assertStringIncludes(logged, "built by a different framework version"); + }); + it("keeps cache identities distinct for delimiter-shaped release IDs", async () => { Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); const calls: string[] = []; diff --git a/src/release-assets/manifest-cache.ts b/src/release-assets/manifest-cache.ts index e83ced2abe..fa70c535de 100644 --- a/src/release-assets/manifest-cache.ts +++ b/src/release-assets/manifest-cache.ts @@ -30,6 +30,7 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { markRequestProfilePhase, profilePhase } from "#veryfront/observability"; import { RELEASE_ASSET_MANIFEST_ENV_FLAG, RELEASE_ASSET_MANIFEST_LIMITS } from "./constants.ts"; import { + describeReadyReleaseAssetManifestRejection, parseReadyReleaseAssetManifestResponse, type ReleaseAssetManifest, } from "./manifest-schema.ts"; @@ -450,8 +451,22 @@ function fetchManifest(releaseId: string): Promise }); return manifest; } else { - if (state === "ready") markManifestDecision("fetch_ready_invalid"); - else markManifestDecision(`fetch_${manifestState}`); + if (state === "ready") { + markManifestDecision("fetch_ready_invalid"); + // A manifest the publisher calls ready but this build cannot read is + // an operator problem, not a wait: browser modules are refused for + // the whole release until someone acts. Say why. Without this the + // only signal is a 503 and a timing mark, which cannot distinguish + // framework version skew from a corrupt payload. + logger.error("Release manifest is ready upstream but failed validation", { + releaseId, + reason: describeReadyReleaseAssetManifestRejection(result, releaseId), + }); + } else { + markManifestDecision(`fetch_${manifestState}`); + // Any other state is the release legitimately not being ready yet. + logger.debug("Release manifest is not ready", { releaseId, state: manifestState }); + } markManifestDecision("fetch_not_ready"); evictReadyManifests(releaseId, active.token); cacheNonReadyManifest(releaseId, active.token); From 45b11c2dbe6c4b0a9c7ad8768ac34cbae0723cd2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 6 Aug 2026 12:42:19 +0200 Subject: [PATCH 2/2] fix(release-assets): classify the rejection on the publisher's claimed state Review found a hole in the diagnostic this branch adds: it keyed the error branch on the derived `state`, which is normalized to "invalid" whenever the envelope is unusable. A response saying `state: "ready"` with a missing, negative, fractional or unsafe `manifest_version` therefore took the debug path and never called the describe helper -- silencing one of the exact rejection reasons the diagnostic exists to surface ("the response envelope carried no usable manifest_version"). Classification now follows `rawState`, what the publisher actually claimed. That also moves such a response from the `fetch_invalid` timing mark to `fetch_ready_invalid`, which is the more accurate reading: the publisher called it ready and this build refused it. Anything watching that mark will see envelope-level rejections join the schema-level ones already counted there. Found independently by CodeRabbit and Copilot. --- .../api-reference/veryfront/release-assets.md | 4 +-- src/release-assets/manifest-cache.test.ts | 30 +++++++++++++++++++ src/release-assets/manifest-cache.ts | 7 ++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/api-reference/veryfront/release-assets.md b/docs/api-reference/veryfront/release-assets.md index 0507bd2752..28bdc60359 100644 --- a/docs/api-reference/veryfront/release-assets.md +++ b/docs/api-reference/veryfront/release-assets.md @@ -66,8 +66,8 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | Name | Description | Source | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L582) | -| `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L592) | +| `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L587) | +| `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L597) | | `contentTypeForExtension` | Resolve the content type for an extension, or null if not allowed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L96) | | `describeReadyReleaseAssetManifestRejection` | Explain why a ready manifest response was rejected. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L381) | | `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L258) | diff --git a/src/release-assets/manifest-cache.test.ts b/src/release-assets/manifest-cache.test.ts index a490f1cd9a..29c2059d72 100644 --- a/src/release-assets/manifest-cache.test.ts +++ b/src/release-assets/manifest-cache.test.ts @@ -111,6 +111,36 @@ describe("release asset manifest fetcher ownership", () => { assertStringIncludes(logged, "built by a different framework version"); }); + it("reports a ready response whose envelope has no usable manifest_version", async () => { + // `state` is normalized to "invalid" when the envelope is unusable, so + // classifying on it routed this rejection to debug, silencing one of the + // exact reasons the diagnostic exists to surface. Classification follows + // the publisher's claimed state instead. + Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); + registerManifestFetcherForRelease("release-envelope", () => + Promise.resolve({ + state: "ready", + manifest_version: -1, + manifest: manifest("release-envelope", 1), + } as unknown as ReturnType)); + + const originalError = console.error; + let logged = ""; + console.error = (...values: unknown[]) => { + logged += values.map((value) => typeof value === "string" ? value : JSON.stringify(value)) + .join(" "); + }; + + try { + assertEquals(await getReadyManifestForRenderAsync("release-envelope"), null); + } finally { + console.error = originalError; + } + + assertStringIncludes(logged, "release-envelope"); + assertStringIncludes(logged, "no usable manifest_version"); + }); + it("keeps cache identities distinct for delimiter-shaped release IDs", async () => { Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); const calls: string[] = []; diff --git a/src/release-assets/manifest-cache.ts b/src/release-assets/manifest-cache.ts index fa70c535de..d27610a08b 100644 --- a/src/release-assets/manifest-cache.ts +++ b/src/release-assets/manifest-cache.ts @@ -451,7 +451,12 @@ function fetchManifest(releaseId: string): Promise }); return manifest; } else { - if (state === "ready") { + // Classify on what the publisher claimed, not on the derived `state`. + // A response that says `ready` with an unusable `manifest_version` is + // normalized to "invalid" above, so keying on `state` would route the + // envelope-level rejections to debug — silencing exactly the reasons + // this diagnostic exists to surface. + if (rawState === "ready") { markManifestDecision("fetch_ready_invalid"); // A manifest the publisher calls ready but this build cannot read is // an operator problem, not a wait: browser modules are refused for