perf(redis): shield residual dashboard read egress#5263
Conversation
Strix Security ReviewNo security issues found. Updated for Reviewed by Strix |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Greptile SummaryThis PR reduces Redis GET egress on two fronts: the wildfire bootstrap hydration now reads a pre-compacted 500-detection key (
Confidence Score: 3/5The CDN-routing and session-bypass logic is well-validated; the main concern is the seeder — the new The missing scripts/seed-fire-detections.mjs — the Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Browser
participant WmSessionInterceptor
participant VercelCDN
participant Gateway
participant Redis
Note over Browser,Redis: Dashboard refresh (news/displacement)
Browser->>WmSessionInterceptor: "fetch list-feed-digest?variant=full&lang=en"
WmSessionInterceptor->>WmSessionInterceptor: "publicRpcFetch adds public=1, strips credentials"
WmSessionInterceptor->>VercelCDN: "GET with public=1 credentials omit"
alt CDN HIT
VercelCDN-->>Browser: 200 cached no Redis read
else CDN MISS
VercelCDN->>Gateway: forward request
Gateway->>Gateway: isPublicSharedRpcRequest skips auth
Gateway->>Redis: read cache
Redis-->>Gateway: payload
Gateway-->>VercelCDN: 200 plus CDN-Cache-Control s-maxage
VercelCDN-->>Browser: 200 cached for next caller
end
Note over Browser,Redis: Wildfire bootstrap
Browser->>Gateway: GET /api/bootstrap
Gateway->>Redis: GET wildfire:fires-bootstrap:v1
Redis-->>Gateway: up to 500 detections
Gateway-->>Browser: bootstrap payload
Note over Redis: Railway seeder cron
Redis->>Redis: write wildfire:fires:v1 canonical
Redis->>Redis: write wildfire:fires-bootstrap:v1 compact top-500
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Browser
participant WmSessionInterceptor
participant VercelCDN
participant Gateway
participant Redis
Note over Browser,Redis: Dashboard refresh (news/displacement)
Browser->>WmSessionInterceptor: "fetch list-feed-digest?variant=full&lang=en"
WmSessionInterceptor->>WmSessionInterceptor: "publicRpcFetch adds public=1, strips credentials"
WmSessionInterceptor->>VercelCDN: "GET with public=1 credentials omit"
alt CDN HIT
VercelCDN-->>Browser: 200 cached no Redis read
else CDN MISS
VercelCDN->>Gateway: forward request
Gateway->>Gateway: isPublicSharedRpcRequest skips auth
Gateway->>Redis: read cache
Redis-->>Gateway: payload
Gateway-->>VercelCDN: 200 plus CDN-Cache-Control s-maxage
VercelCDN-->>Browser: 200 cached for next caller
end
Note over Browser,Redis: Wildfire bootstrap
Browser->>Gateway: GET /api/bootstrap
Gateway->>Redis: GET wildfire:fires-bootstrap:v1
Redis-->>Gateway: up to 500 detections
Gateway-->>Browser: bootstrap payload
Note over Redis: Railway seeder cron
Redis->>Redis: write wildfire:fires:v1 canonical
Redis->>Redis: write wildfire:fires-bootstrap:v1 compact top-500
Reviews (1): Last reviewed commit: "fix(ci): refresh generated docs stats" | Re-trigger Greptile |
koala73
left a comment
There was a problem hiding this comment.
Reviewed at head f48d9be14fd82fc331739be44f57b54dfdffaca2. CI fully green. I filed #5259, so this is a review against the intent of that issue.
What's right (verified, not assumed)
- Both routes really are caller-invariant.
list-feed-digest.tsandget-displacement-summary.tscontain zeroctx./userId/entitlement branching (thetierhits in the digest are source tiers, not user tiers). The central premise holds. - No entitlement bypass. Neither path is in
ENDPOINT_ENTITLEMENTS(getRequiredTier→ null) norPREMIUM_RPC_PATHS, so settingisPublicNoAuthRpcdoesn't unlock anything paid. - Rate limiting still applies — the limiter block is gated only on
!internalMcpVerified, so public URLs still hitcheckRateLimit. - Classifying the public URL identically regardless of attached credentials is correct, not a bug — a Vercel cache hit precedes the handler. Same contract as #5250, and
publicRpcFetchstrips credential headers + setscredentials: 'omit'anyway. runSeedgenuinely honorsextraKeys[].transform(_seed-utils.mjs:1617), so the compact key really is compacted — I checked, because a silent no-op there would have made the whole wildfire change worthless.- Deploy ordering is safe:
src/services/wildfires/index.ts:50-52falls back to the RPC when bootstrap lackswildfires, so the window before the seeder publishes degrades rather than breaks. - Dropping
cache: 'no-cache'from the digest fetch is required, not incidental — it would have forced revalidation and defeated the CDN.
🔴 BLOCKER — the displacement public URL is far too permissive
src/shared/public-rpc-cache.ts:38-48 admits, credential-free:
year— any integer 1950–2100country_limit— 1–500flow_limit— 1–500- all three optional
The client only ever emits ?flow_limit=50&public=1 (src/services/displacement/index.ts:117-121 sends year: 0, countryLimit: 0, and the generated client omits zero values — service_client.ts:138-140). So the accepted shape space is enormously wider than anything we use, and it creates two problems on the exact resource #5259 is trying to protect:
1. CDN-defeating cache-key fan-out. country_limit × flow_limit alone = ~251k distinct public URLs that all resolve to the same 713 KB displacement:summary:v1:2026 read. Every distinct shape is a MISS → origin → full-key Redis read. At the generic per-IP limit (GLOBAL_RATE_LIMIT = 600), a single unauthenticated IP that varies flow_limit never gets a cache hit: 600/min × 713 KB ≈ 616 GB/day of Redis egress from one IP — several times the entire monthly plan, in a day.
2. Attacker-chosen year reaches a live upstream fetch. The server parses year straight from the query (service_server.ts:150), and on a seed miss the handler falls through to cachedFetchJson → fetchUnhcrYearItems(requestYear) (get-displacement-summary.ts:182-192) — a paginated crawl against api.unhcr.org with an attacker-supplied year. 151 unseeded years are reachable with no credential at all.
Neither is a brand-new capability in the strictest sense (a freely-mintable wms_ session could already reach the legacy URL), but this PR removes the last credential hop and, more importantly, passes up the chance to close it. The whole point of an explicit public URL is that repeats become free — that only works if the shape is pinned.
#5250 set the right precedent: exactly two fixed shapes (tier=fast|slow). Please do the same here — restrict the displacement public shape to what the client actually sends and drop year / country_limit from the public surface entirely:
const DISPLACEMENT_QUERY_KEYS = new Set(['flow_limit', 'public']);
// and require flow_limit === '50' (or omit it), nothing elseThat collapses the public key space to 1, makes every repeat a CDN hit, and makes the surface strictly safer than today. The credentialed legacy URL keeps serving any other shape, unchanged. (News is fine as-is: 6 variants × 25 langs = 150 bounded keys.)
🟡 P2 — list-fire-detections still pays the 1.36 MB read it no longer needs
SEED_CACHE_KEY stays wildfire:fires:v1 (list-fire-detections.ts:16), so the RPC still pulls the full canonical (up to 1.36 MB at peak) and trims after. But the handler already caps every response at 500 with no pagination — limitFireDetectionsForDashboard(rawDetections) with the default limit, nextCursor: '' always.
Reading wildfire:fires-bootstrap:v1 instead would return a byte-identical response: compactWildfireDashboardPayload preserves pagination.totalCount (the pre-trim count), and when detections ≤ 500 it returns the payload untouched. So the compact-key path yields the same fireDetections, the same totalCount, the same fetchedAt.
This matters because it's the largest remaining wildfire term (~10.9k RPC reads/day × up to 1.36 MB ≈ up to ~14.9 GB/day at peak, vs ~0.9 GB/day off the compact key) — and it's the path the client falls back to whenever bootstrap lacks the key. Suggest reading compact with a canonical fallback for cold start.
🟡 P2 — the new keys are unmonitored, so the fix can silently stop working
wildfire:fires-bootstrap:v1 and seed-meta:wildfire:fires-bootstrap are not registered in api/health.js (BOOTSTRAP_KEYS still maps wildfires → wildfire:fires:v1; SEED_META still has only seed-meta:wildfire:fires).
The failure mode is nasty precisely because the client fallback works: if the extraKey write breaks or the transform regresses, the wildfire panel keeps rendering (via the RPC), health stays green, and egress quietly reverts to the expensive path — i.e. this PR's savings evaporate with no alarm anywhere. Please register the served key + its seed-meta.
Verdict: the architecture is right and the caller-invariance claim checks out. Please pin the displacement public shape before merge (blocker); the two P2s can land as follow-ups, though the list-fire-detections one is most of the remaining wildfire win and is a ~3-line change.
Also agree with keeping #5259 open until a full-day measurement lands — and note the measurement must be taken at a peak hour: 64 GB/day was sampled at trough, and wildfire alone swings 79 KB ↔ 1.36 MB.
Pin displacement sharing to one canonical URL, serve wildfire RPCs from the compact seed with canonical fallback, and monitor the compact publish independently.
koala73
left a comment
There was a problem hiding this comment.
Re-reviewed at head 5d141e50da4d21db41fc04ea45f19d9741ad855d (was f48d9be). CI green, 27/27.
🔴 BLOCKER — resolved, and resolved decisively
src/shared/public-rpc-cache.ts:47 now does an exact-string match:
const DISPLACEMENT_PUBLIC_SEARCH = '?flow_limit=50&public=1';
...
return url.search === DISPLACEMENT_PUBLIC_SEARCH;That collapses the public displacement surface from ~251k shapes to exactly one, which closes both halves of the finding:
- No CDN fan-out — every repeat is a hit, which is the entire point of the public URL.
yearis no longer accepted on the public path at all, so the unauthenticated, attacker-chosen paginated UNHCR crawl (get-displacement-summary.ts:182-192) is unreachable without credentials.
I checked the fail-closed behaviour too: any other shape (reordered params, extra params, different flow_limit) simply isn't classified as public, so it falls through to the credentialed legacy URL and 401s for anon. Correct direction to fail. And it matches what the client actually emits — addPublicSharedRpcMarker appends public=1 to the generated client's ?flow_limit=50, producing that exact string.
🟡 P2s — both resolved
list-fire-detections (:31-44) now reads wildfire:fires-bootstrap:v1 first and falls back to the canonical when it's absent — exactly the compact-with-cold-start-fallback shape. Response stays byte-identical (the compact payload preserves pagination.totalCount).
Health registration (api/health.js:31, :277) — wildfiresBootstrap is now in both BOOTSTRAP_KEYS and SEED_META. I also verified the piece that would have made this half-work silently: runSeed's extra-key loop honors ek.metaKey under contract mode (_seed-utils.mjs, if (contractMode && ek.metaKey) → writeSeedMeta(...)), and this seeder is in contract mode, so seed-meta:wildfire:fires-bootstrap really does get written.
🟡 NEW P2 (introduced by the health-registration fix) — cold-start false CRIT
EMPTY: 'crit' (api/health.js:866), and wildfiresBootstrap is not in EMPTY_DATA_OK_KEYS.
So from the moment this deploys until the Railway seeder's first post-deploy tick publishes wildfire:fires-bootstrap:v1, health scores a crit on a key that has simply never been written. At ~200 checks, one crit lands under the 3% threshold → overall DEGRADED, and it writes health:last-failure + a health:failure-log entry — a false incident, on every environment, for the length of that window.
The PR's own new test pins this as expected behaviour:
assert.equal(missingCompact.status, 'EMPTY'); // tests/wildfire-bootstrap-health.test.mjsThis exact cold-start case has been solved twice already, and the precedents are sitting two lines away in the same Set (api/health.js:635-636):
'forecastBets', // ... absent before the cron ships it ... tolerate as STALE_SEED (warn), not EMPTY (crit).
'forecastFunnel', // ... before the first seed-forecasts run ships it the key is absent ...Please add wildfiresBootstrap to EMPTY_DATA_OK_KEYS with the same rationale (and flip the test to assert STALE_SEED). Monitoring strength is not lost: once the key exists, the seed-meta:wildfire:fires-bootstrap staleness check (maxStaleMin: 360) still catches a writer that stops, and canonical wildfires still CRITs on real data loss. All this changes is that "never written yet" degrades to a warn instead of a crit — which is precisely what the two precedents encode.
To be clear on blast radius: HTTP status stays 200 for DEGRADED, so UptimeRobot won't flip. This is false-alarm and incident-log pollution, not an outage.
Verdict: the blocker is properly closed and the architecture is sound. One line in EMPTY_DATA_OK_KEYS (plus the test assertion) and this is ready.
Merge-order note: this and #5262 both touch api/health.js in different regions — no logical conflict, but the second one in will need a trivial rebase.
And when the post-deploy measurement runs, take it at a peak hour: the 64 GB/day baseline in #5259 was sampled at trough, and wildfire alone swings 79 KB ↔ 1.36 MB.
koala73
left a comment
There was a problem hiding this comment.
Re-reviewed at head a68c6caa1672725c5d149a215df8a8054078753b (was 5d141e5). CI green 27/27, mergeStateStatus: CLEAN.
The cold-start finding is resolved — and the test got stronger
api/health.js:637 adds wildfiresBootstrap to EMPTY_DATA_OK_KEYS with a rationale matching the forecastBets / forecastFunnel precedents directly above it. Missing-before-first-seed now classifies as STALE_SEED (warn) instead of EMPTY (crit), so the deploy → first-seeder-tick window no longer produces a false DEGRADED verdict or a bogus health:failure-log entry.
The test change is the right one, and it does more than flip the assertion — it adds a second case pinning that canonical wildfires still classifies as EMPTY (crit) when absent. That's exactly the invariant worth locking: the softening applies to the compact side-write only and does not leak to the key that actually signals real wildfire data loss.
I probed the obvious way this softening could backfire — it holds
Adding a key to EMPTY_DATA_OK_KEYS means classifyKey:812 returns OK for missing data + fresh seed-meta:
else if (EMPTY_DATA_OK_KEYS.has(name)) status = seedStale === true ? 'STALE_SEED' : 'OK';That would reopen the exact monitoring gap the earlier P2 was meant to close — a compact write that fails while its meta stays fresh would read green. So I checked whether that state is reachable, and it isn't: writeExtraKey throws on a failed write (scripts/_seed-utils.mjs:678), and the writeSeedMeta call for the extra key only runs after it returns. A fresh compact meta therefore implies a successful compact data write moments earlier. Good — the softening is safe because the writer is fail-fast, not because we got lucky.
One residual nuance (not blocking, just so it's known)
If the compact write starts throwing after having previously succeeded, there's a bounded window where health reads OK for the compact key: the data key expires at its 2 h TTL, while the meta (7-day TTL) stays inside maxStaleMin: 360 for up to 6 h — so roughly a ≤4 h gap before it flips to STALE_SEED.
It is not silent, though, on any axis that matters:
- the seeder itself crashes on the throw (non-zero exit → Railway alarm), and
- after the 6 h staleness threshold, health warns anyway.
So the worst case is a delayed health warning alongside an immediate seeder alarm. Not worth another round.
Merge state
#5262 has since landed on main, and both PRs touch api/health.js — but they edit different regions (5262: the snapshot constants + handler; this: BOOTSTRAP_KEYS / SEED_META / EMPTY_DATA_OK_KEYS). I test-merged: zero conflicts, and GitHub agrees (CLEAN). No rebase needed.
Verdict: ship it. Every finding from both prior rounds is closed:
| Round | Finding | Status |
|---|---|---|
| 1 | 🔴 displacement public URL: ~251k CDN keys + unauthenticated UNHCR crawl via year |
✅ pinned to one exact shape |
| 1 | 🟡 list-fire-detections still read the 1.36 MB canonical |
✅ reads compact, falls back to canonical |
| 1 | 🟡 new bootstrap key unmonitored | ✅ registered in BOOTSTRAP_KEYS + SEED_META |
| 2 | 🟡 cold-start false CRIT from that registration | ✅ EMPTY_DATA_OK_KEYS + strengthened test |
Reminder for the post-deploy step: take the measurement at a peak hour. The 64 GB/day figure in #5259 was sampled at trough, and wildfire alone swings 79 KB ↔ 1.36 MB — a trough reading will flatter the result.
…lded (#5285) (#5287) Both public RPC URLs shipped by #5263 returned 401 in production and were never CDN-cached, so the ~19 GB/day of Redis egress they were meant to shield kept hitting origin — and the deployed client, which now calls only those URLs, fell back to a stale news digest and rendered an empty displacement panel. Vercel serves these through api/<domain>/v1/[rpc].ts and its filesystem router echoes the matched segment back as ?rpc=<lastPathSegment>. isPublicSharedRpcRequest validates the query with an exhaustive allowlist (news) and an exact-string compare (displacement); neither tolerates the injected param, so the gateway never classified the request as public and fell through to validateApiKey. /api/bootstrap (#5250) is unaffected because it is a static route. Every other public RPC only matches on pathname, so #5263 was the first query-strict check on a dynamic route — and the first to trip on this. server/_shared/mcp-internal-hmac.ts already strips the same echo before signing; this reuses that guard. Strip `rpc` only when every value equals the final path segment (the router's own echo). A caller-supplied ?rpc=<anything-else> is left in place and still fails the shape check, so this is not a bypass vector. The echo is removed from the RAW query string rather than a re-serialised URLSearchParams, because re-encoding would normalise flow_limit=%35%30 into flow_limit=50 and silently widen the displacement contract, which is deliberately an exact-string match to keep the CDN key space at one entry. Tests: the hand-built URLs in the existing suite never carried the echo, which is why unit tests stayed green while production was broken. Adds coverage for the router-echo shape on both routes, plus rejection of a caller-supplied ?rpc=. Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9
…ier freight (#5300) (#5302) Every client downloads both bootstrap tiers on every boot, so a key belongs in a tier only if the median client actually reads it. cyberThreats did not. loadCyberThreats is double-gated — on the VITE_ENABLE_CYBER_LAYER build flag AND on mapLayers.cyberThreats (src/app/data-loader.ts:881) — and that layer is OFF by default in all 12 variant configs (src/config/panels.ts). So the slow tier shipped cyber:threats-bootstrap:v2 (364 KB) to every visitor, and the default visitor never read a byte of it: ~2.15 GB/day of Redis egress for data nobody consumed. Introduces ON_DEMAND_KEYS as a first-class third category. A key now rides in the fast tier, the slow tier, or neither — and "neither" is a deliberate, tested state rather than a hole in the invariant. On-demand keys are fetched individually, and only by the clients that render them, through `?keys=<name>&public=1`. That per-key URL is restricted to ONE key drawn from ON_DEMAND_KEYS. An arbitrary `?keys=a,b,c` would make the CDN key space combinatorial, and every distinct combination is a miss that re-reads the registry from Redis — the amplification #5259 exists to prevent. One key per URL keeps the space at |ON_DEMAND_KEYS| entries, each independently cached. The legacy multi-key `?keys=` URL is unchanged: still credentialed, still no-store. Client-side, ensureHydrated() returns the tier value when present (so promoting a key back into a tier needs no consumer change) and otherwise fetches the per-key public URL. It deliberately does NOT fall back to the domain RPC: the RPC reads the same Redis key with no CDN in front of it, so routing misses there would relocate the egress rather than remove it — the trap that made #5263's RPC work a no-op until #5287. Callers keep their existing RPC fallback for the failure case. Tests: the tier invariant now covers SLOW ∪ FAST ∪ ON_DEMAND with pairwise no-overlap; the hydration-coverage guard accepts ensureHydrated() as a consumer form; and the public per-key URL is pinned against widening (multi-key, non- on-demand key, unknown key, and empty all fall through to credentialed no-store). Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9
…not the full watch (#5300) (#5303) * perf(bootstrap): serve thermal escalation as a dashboard-sized view, not the full watch (#5300) The bootstrap slow tier is downloaded by every client on every boot, so what rides in it should be what the UI renders. thermal:escalation:v1 ships ~117 ranked clusters. The dashboard renders 12 — fetchThermalEscalations(maxItems=12) slices the array and recomputes its summary from that slice, so every cluster past the cap is downloaded and thrown away. At peak that is 467 KB per origin miss for a payload the UI never shows: ~2.9 GB/day of Redis egress. Publishes thermal:escalation-bootstrap:v1 — the same watch, capped to the top 24 clusters — and hydrates the tier from it. computeThermalEscalationWatch already ranks clusters (strategic relevance -> severity -> total FRP -> observation count), so the client's slice(0, 12) is a top-12-by-rank and the capped array is the identical ranked prefix. Behaviour is preserved exactly; only the bytes the client discards are gone. The canonical key is untouched and still serves the RPC and analytical consumers. Same shape as the wildfire compaction in #5263: canonical = source, *-bootstrap = the view the dashboard actually renders. Guards, each earned from a prior incident: - the compactor lives in scripts/ and imports nothing outside it. A ../api/ import escapes Railway's scripts-only Nixpacks root and crashes the seeder at startup (#5268); the no-escape-import guard now covers this entry point. - health monitors thermal:escalation-bootstrap:v1 and its seed-meta separately, so a transform or write failure cannot hide behind a healthy canonical key. - the compact key is in EMPTY_DATA_OK_KEYS: it is legitimately absent for one cron interval after deploy, and EMPTY scores as crit, which would flip /api/health to DEGRADED on a false alarm (the trap #5263 hit on round two). Warn instead; seed-meta staleness still catches a writer that stops. - a drift guard pins the client's maxItems default at or below the published cap, so raising one without the other can never silently truncate the dashboard. Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9 * test(mcp): document thermal:escalation-bootstrap:v1 as a cascade-mirror (#5300) The U7 parity guard requires every health-registered seeded key to be covered by an MCP tool or carry a documented exclusion. Registering the compact key in health (so a transform/write failure can't hide behind a healthy canonical key) correctly tripped it. Excluded for the same reason wildfire:fires-bootstrap:v1 is: it is a pre-compacted dashboard VIEW, not a new data source. The canonical thermal:escalation:v1 remains the tool/RPC source and carries the full cluster set — agents must read that, not the top-24 slice the dashboard renders. Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9
Summary
Dashboard refreshes can now reuse shared CDN responses for the news digest and displacement summary through explicit caller-invariant
public=1URLs. Their legacy URLs remain credential-gated and non-shared, including when callers attach credentials to the public URL.Wildfire bootstrap hydration now reads a seed-time compact payload instead of paying Redis egress for the full canonical detection set and trimming it after the read. The canonical wildfire key remains intact for RPC and analytical consumers.
Related: #5259
Test plan
npm run build:fullnpm run test:data- 15,225 passed, 0 failed, 6 skippednpm run typechecknpm run typecheck:apinode --test tests/edge-functions.test.mjs- 228 passedPost-Deploy Monitoring & Validation
wildfire:fires-bootstrap:v1with a healthy envelope and no more than 500 detections.x-vercel-cachetransitions fromMISStoHITwhileCDN-Cache-Controlremains present:/api/news/v1/list-feed-digest?variant=full&lang=en&public=1/api/displacement/v1/get-displacement-summary?flow_limit=50&public=1MONITORsample at peak, normalize health-sweep andEVALSHAnoise, and verify bootstrap no longer readswildfire:fires:v1while the two RPC origin-read counts collapse behind CDN hits.Rollback by reverting this commit. If deployment ordering is the only failure, hold the web rollout until the compact wildfire key exists rather than restoring the oversized bootstrap read.