Repository navigation
ten API routes stop refetching faster than their own data can change - #2724
Merged
Merged
Conversation
Flotapponnier
force-pushed
the
perf/cache-force-dynamic-routes
branch
from
September 30, 2026 09:24
1453a92 to
1b48903
Compare
The 24 force-dynamic route files were audited for whether they could be
cached. The premise that they are uncached does not hold. Measured
against prod on 2026-09-30, every one of the 17 that serves a 200 to an
anonymous GET already returns x-vercel-cache: HIT on a second request:
Vercel's CDN honours the s-maxage each handler sets on its own response,
and force-dynamic does not stop it, it only keeps the route out of ISR.
force-dynamic is also correct on all 24 and stays. Next 16 does not
cache route handlers by default and the documented opt-in is
force-static, which forces headers() and searchParams to empty values.
Every one of these handlers reads the request, for the per-client
limiter if nothing else, so an ISR window would be a no-op at best and
would break filters and limiting at worst. The lever that works is the
CDN window, so that is what moved.
Each new window is the refresh cadence measured upstream, not a round
number, and the reasoning sits next to it:
stat, series, variant, oracle-pairs, hl-frontends history,
compare, builder daily-history 60s -> 300s
builder daily-series, top-users 30s -> 900s
badge snippet 300s -> 3600s
The 300s group all read through data caches that are themselves 300s
(src/lib/spec.ts, prometheus.ts, hl-archive-store.ts), so four of every
five fills recomputed a byte-identical body. The windows do compose, so
this is not free: worst-case data age roughly goes 360s to 600s, which
is parity with the 600s ISR on the bench pages these mirror. The two
builder proxies front a harness that aggregates whole UTC days ending
yesterday, so a published point is final and only a new day ever
appears. The snippet body is a title, a name and two URLs.
Three guards keep the longer windows honest:
- oracle-pairs answers 200 with no rows when the Prom vector is
empty, so that case keeps a 60s window. Otherwise a renamed label
could pin an empty matrix at the edge for twenty minutes.
- the hl-frontends 90d/180d fallback keeps its own 120s window. It
serves 30-day totals labelled 90d while the archive backfills, and
what bounds a stand-in is how soon the real archive can replace it,
not the TTL of the caches it was built from.
- the badge "measurement pending" placeholder keeps its 60s. A stale
rank is a slightly old number; "Measurement pending" once data
exists is a wrong claim in someone else's README, and the path is
rare enough that lengthening it saves nothing.
Also corrected, because the audit found the code and the comments
disagreeing about what production serves:
- /api/citable is served on 300s, not the hour everything said. The
headers() rule in next.config.ts asks for s-maxage=3600 but a config
header does not override a Cache-Control the handler sets itself.
Sampled every 45s, the edge entry went STALE at age 306 and reset.
The rule is inert; both sides now say so. The value is left at 300s,
which is the data-cache floor anyway, because raising the window on
the endpoint LLMs cite most is a freshness call, not a caching one.
- /api/citable/[date] promises a frozen per-date snapshot and does not
have one. /api/citable/2026-01-15 returns all 225 rows byte-equal to
the live index, each carrying asOf 2026-09-30, and a date inside
2025-2100 but in the future answers 200 the same way. Three comments
asserted the freeze; they now record the gap. The window stays at a
day: immutable would pin one scrape's numbers under a past label
forever.
- /api/md asks for no-store to keep its per-read analytics honest, but
the directive does not survive to the edge, so markdown_read already
counts cache fills. Noted, not resolved: the fix points either at the
analytics or at the cost, and that is a product call.
- the three /api/builder routes 503 on prod today (HL_NODE_AUTH unset,
archive blob not written), and a 503 is not cacheable, so their new
windows are inert until that is fixed.
Flotapponnier
force-pushed
the
perf/cache-force-dynamic-routes
branch
from
September 30, 2026 09:27
1b48903 to
05d1117
Compare
Flotapponnier
added a commit
that referenced
this pull request
Sep 30, 2026
…ur dead exports (#2736) Found by an audit of today's diff. Each of these was checked against the code beside it; nothing here changes behaviour. Cache windows that moved and left their prose behind (#2724 widened the routes, these were not resurveyed): - api/stat/[slug] said the worst case went "from about 360 s to about 600 s" and called that parity with the bench page's 600 s ISR. 360 counts stale-while-revalidate on the old header (60 + 300); the matching figure for 300 + 900 is 1200, not 600, so it is about twice the page's ISR, not parity. The comment now says so and records where it shows: /rwa is revalidate = 300 and its FAQ, which becomes FAQPage JSON-LD, tells readers this endpoint carries "the same values and timestamp" while advertising it as a DataDownload. - spec.ts said loadAllBenchmarksCached shares its result "with 60s revalidate" three lines above `revalidate: 300`. Several API routes size their own windows against this number. - benchmark-body.tsx said the variant API "dedupes across users via its 60s cache"; that cache is 300 s. - api/freshness said its edge cache is "5 s s-maxage, 20 s swr" and that the LiveIndicator polls "every 8 s". The response sets s-maxage=30, swr=60 and the widget polls every 30 s. - live-indicator.tsx said it re-fetches /api/citable, edge-cached 60 s. It fetches /api/freshness, and that is 30 s. Both now note the asymmetry this creates: at 30 s the widget is the freshest clock on the page and can read newer than the table under it. - api/aggregate said "The ISR revalidate=60 above handles freshness instead". There is no revalidate on that route; it is force-dynamic. Wrong reason, right conclusion: - next.config.ts said the sitemap sets its own Cache-Control "because metadata routes ignore next.config headers()". sitemap.xml/route.ts is a Route Handler, not a metadata route, and the same comment already gives the real reason two lines earlier. Docs separated from their fields by today's zero_is_a_value insertion, so each described its neighbour. Reordered, text verbatim: - spec-schema.ts: three docs stacked in the reverse order of the three fields, so zero_is_a_value showed ledger_reliability's doc and ledger_columns' doc was stranded two fields up. - types/benchmark.ts: dimensionLabels' doc sat on zeroIsAValue. Counts that no longer hold: - site-footer.tsx named /llms.txt and /api/citable as the links absent from the sitemap, which reads as a complete list. Four of the ten are absent: those two plus /api/openapi.json and /api/llm-context. The next person pruning that row needs all four. - capital-hub.ts and capital/page.tsx both list five benches; eight feed the hub. Both now point at CAPITAL_BENCHES instead of carrying a copy, because the copy went stale the first time the list grew. - capital-hub.ts also credited bench 265 for perp open interest while correctly naming 265 as perp-pf-ratio two lines later. 265 stopped feeding that column today; the block above perpOi explains why. - capital-hub-rules.ts said "n/a" is the missing value on "the other 37 places the site uses it". Not derivable: the literal appears 63 times in src/ across 21 files. The claim keeps its substance and drops the number rather than standing as a fact-shaped guess. - the COLUMN_COVERAGE note quotes the owner's "two rows of seventy-nine" from the first hub draft. Kept as the quotation it is, with the current cohort size (63) named beside it so the two figures stop reading as a contradiction. One reader-facing string, not a comment: the categoryTooSmall reason said a category under five ranked protocols "compares the token with one or two others". Below a floor of five it is up to three others. Dead exports removed from capital-hub-rules.ts, zero callers across src/, scripts/ and worker/: - change7dFromDays and HISTORY_FRESH_MS (its only reader). Today's perp open-interest change removed the single call site, and the doc still named the data path the hub stopped reading. - change7dFromSeries, a one-line wrapper over change7dOfSeries. Its test covers change7dOfSeries' real behaviour, so the test was retargeted at that function rather than deleted: every assertion is kept. - the NaReason type, one reference in the tree and it was its own declaration. Using it instead of deleting it was the alternative, and it is a refactor rather than a cleanup: CCTP_SCOPE_LABEL values are also assigned to the notApplicable fields and are not in NA_REASON, so tightening those to NaReason needs a decision about the union first. typecheck, lint, validate (241 specs) and the suite (367 tests) clean. Deliberately not in this sweep: the warm-up and smoke comments in prod-deploy.yml, staging-deploy.yml and scripts/sitemap-smoke.mjs, which belong to whoever is holding that change; page-metadata.ts's "fallback" comment, which is only wrong because of a behaviour bug fixed separately; and benchmarks/rpc-cost.yml's "one plan in seven", which is page copy whose denominator two passes disagreed on.
Flotapponnier
added a commit
that referenced
this pull request
Sep 30, 2026
* the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x (#2732) * the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x Two comments in the rpc-cost harness teach the next reader the belief the code was changed to remove. profiles.go carried two doc paragraphs on PlanTiers. The upper one said `all` is "the cheapest plan of any tier" and that "Free is scored here too"; the lower one, added with the paid-plan default, says `all` is the cheapest PAID plan because free tiers would hand the leaderboard to the $0 rows. The code is the lower one: model.go sets `paidOnly := tier == "all" && !p.isReference()` and skips TierFree, and benchmarks/rpc-cost.yml labels the tab "Cheapest paid plan". A reader scanning top-down got the stale answer, which is the exact defect the paid-plan default exists to prevent. The stale paragraph goes. model.go and catalogue.go both gave GetBlock's archive ratio as 1.5x on one method family. Read off the full_cu / archive_cu columns in pricing/catalogue.yml, the Ethereum catch-all reads are 20 -> 40 (2x) and the enumerated debug, trace and txpool methods are 40 -> 120 (3x); Solana is 50 -> 100 throughout. No ratio in the table is 1.5x, and the spec already states 2x and 3x. model.go was also naming the same 3x method set twice ("debug/trace" and "the enumerated trace family") and never mentioning the plain reads that are the real second case. The 1.5x is a real number in the wrong place: it is what GetBlock's published archive_multiplier ceiling of 6 implies against the 4x method family, which is why that formula is unusable. That is recorded on the getblock archive_rule note, and model.go now points there instead of repeating the figure as if it described these columns. Comments only. No behaviour change, no figure on the page moves. * the 90-day staleness rule these two comments describe does not exist catalogue.go said "A number nobody has re-verified in 90 days is reported as such on the page instead of quietly passing for current", and refresh.go said "a figure nobody has re-read in 90 days is the thing this bench most needs to admit". There is no 90-day constant in the harness. grep over the package finds the number only in these two comments and in a NOWNodes plan note about a package valid 90 days; benchmarks/rpc-cost.yml carries no threshold either. What the harness does is publish rpc_pricing_artifact_age_seconds per provider per artifact (metrics.go:120) and compare it to nothing. The distinction matters to anyone reading this before touching the freshness path: there is no cutoff past which a figure is withheld or relabelled, so adding one is a feature, not a bug fix, and removing the gauge would remove the only staleness signal there is. The spec already words it correctly at line 87, "Age is published either way, so a figure nobody has re-read is visible as such", so both comments now say that and name the gauge. Comments only. go build and go vet clean. * the tier selector reads its label from the spec, so a cost bench stops saying "Access" (#2733) `dimension_labels` landed with the zero-is-a-value work and every axis reads it except the one that motivated it: venue, bucket, kind, chain and region all do `dimensionLabels?.<key> ?? "<stock>"`, while the tier row hardcoded "Access". benchmarks/rpc-cost.yml declares `tier: Billing model` and its tier axis is usage / dedicated / reference ("Billed per request", "Dedicated capacity", "Free public, not ranked"). "Access" is the wrong word for that axis: on rpc-cost the plan band is the `venue` axis, not this one. The page rendered "Access" sixteen times against a spec that had already written the right label. One line, same shape as the five axes beside it. rpc-cost is the only spec in the tree that declares `dimension_labels.tier`, so the nine chain RPC benches keep "Access", which is correct for them: there the tier axis really is public against API-key. typecheck and eslint clean. * the chain registry had cosmos-hub twice, so /chains listed it twice (#2734) src/lib/chains.ts carried two `cosmos-hub` entries, identical but for `description`. Nothing threw, because CHAIN_BY_SLUG is `new Map(CHAINS.map(...))` and the later entry simply wins, so the duplicate only showed in the places that iterate the array: - /chains rendered two identical Cosmos Hub cards (verified on production: two `href="/chains/cosmos-hub"` on the page); - the sitemap emitted the URL twice, 1,211 <loc> entries for 1,210 unique URLs (verified on an uncached fetch of the live sitemap); - generateStaticParams in src/app/chains/[slug]/page.tsx returned the slug twice, so every build prerendered the route twice; - the stratified smoke test spent one of its 120 tail slots per rotation re-checking a URL it had already checked. The entry removed is the first one, which is the one CHAIN_BY_SLUG was already discarding, so the rendered description does not move: the page serves "The original Cosmos SDK chain and IBC hub..." before and after, checked against the live page. Guard added rather than only the deletion. A duplicate here is invisible to typecheck, to lint and to every Map-based consumer, which is why this one survived; src/lib/chains.test.ts asserts slug uniqueness and that CHAIN_BY_SLUG.size equals CHAINS.length. Checked both ways: the test fails on the pre-fix registry (147 entries, 146 in the Map) and passes after. typecheck, lint and the full suite (371 tests) clean. * eleven comments that described something the code does not do, and four dead exports (#2736) Found by an audit of today's diff. Each of these was checked against the code beside it; nothing here changes behaviour. Cache windows that moved and left their prose behind (#2724 widened the routes, these were not resurveyed): - api/stat/[slug] said the worst case went "from about 360 s to about 600 s" and called that parity with the bench page's 600 s ISR. 360 counts stale-while-revalidate on the old header (60 + 300); the matching figure for 300 + 900 is 1200, not 600, so it is about twice the page's ISR, not parity. The comment now says so and records where it shows: /rwa is revalidate = 300 and its FAQ, which becomes FAQPage JSON-LD, tells readers this endpoint carries "the same values and timestamp" while advertising it as a DataDownload. - spec.ts said loadAllBenchmarksCached shares its result "with 60s revalidate" three lines above `revalidate: 300`. Several API routes size their own windows against this number. - benchmark-body.tsx said the variant API "dedupes across users via its 60s cache"; that cache is 300 s. - api/freshness said its edge cache is "5 s s-maxage, 20 s swr" and that the LiveIndicator polls "every 8 s". The response sets s-maxage=30, swr=60 and the widget polls every 30 s. - live-indicator.tsx said it re-fetches /api/citable, edge-cached 60 s. It fetches /api/freshness, and that is 30 s. Both now note the asymmetry this creates: at 30 s the widget is the freshest clock on the page and can read newer than the table under it. - api/aggregate said "The ISR revalidate=60 above handles freshness instead". There is no revalidate on that route; it is force-dynamic. Wrong reason, right conclusion: - next.config.ts said the sitemap sets its own Cache-Control "because metadata routes ignore next.config headers()". sitemap.xml/route.ts is a Route Handler, not a metadata route, and the same comment already gives the real reason two lines earlier. Docs separated from their fields by today's zero_is_a_value insertion, so each described its neighbour. Reordered, text verbatim: - spec-schema.ts: three docs stacked in the reverse order of the three fields, so zero_is_a_value showed ledger_reliability's doc and ledger_columns' doc was stranded two fields up. - types/benchmark.ts: dimensionLabels' doc sat on zeroIsAValue. Counts that no longer hold: - site-footer.tsx named /llms.txt and /api/citable as the links absent from the sitemap, which reads as a complete list. Four of the ten are absent: those two plus /api/openapi.json and /api/llm-context. The next person pruning that row needs all four. - capital-hub.ts and capital/page.tsx both list five benches; eight feed the hub. Both now point at CAPITAL_BENCHES instead of carrying a copy, because the copy went stale the first time the list grew. - capital-hub.ts also credited bench 265 for perp open interest while correctly naming 265 as perp-pf-ratio two lines later. 265 stopped feeding that column today; the block above perpOi explains why. - capital-hub-rules.ts said "n/a" is the missing value on "the other 37 places the site uses it". Not derivable: the literal appears 63 times in src/ across 21 files. The claim keeps its substance and drops the number rather than standing as a fact-shaped guess. - the COLUMN_COVERAGE note quotes the owner's "two rows of seventy-nine" from the first hub draft. Kept as the quotation it is, with the current cohort size (63) named beside it so the two figures stop reading as a contradiction. One reader-facing string, not a comment: the categoryTooSmall reason said a category under five ranked protocols "compares the token with one or two others". Below a floor of five it is up to three others. Dead exports removed from capital-hub-rules.ts, zero callers across src/, scripts/ and worker/: - change7dFromDays and HISTORY_FRESH_MS (its only reader). Today's perp open-interest change removed the single call site, and the doc still named the data path the hub stopped reading. - change7dFromSeries, a one-line wrapper over change7dOfSeries. Its test covers change7dOfSeries' real behaviour, so the test was retargeted at that function rather than deleted: every assertion is kept. - the NaReason type, one reference in the tree and it was its own declaration. Using it instead of deleting it was the alternative, and it is a refactor rather than a cleanup: CCTP_SCOPE_LABEL values are also assigned to the notApplicable fields and are not in NA_REASON, so tightening those to NaReason needs a decision about the union first. typecheck, lint, validate (241 specs) and the suite (367 tests) clean. Deliberately not in this sweep: the warm-up and smoke comments in prod-deploy.yml, staging-deploy.yml and scripts/sitemap-smoke.mjs, which belong to whoever is holding that change; page-metadata.ts's "fallback" comment, which is only wrong because of a behaviour bug fixed separately; and benchmarks/rpc-cost.yml's "one plan in seven", which is page copy whose denominator two passes disagreed on. * 28 hub share cards were being generated and never used (#2738) pageMetadata pinned openGraph.images and twitter.images to the ROOT /opengraph-image and /twitter-image. The comment called it a fallback for hubs without a dedicated card. It was an unconditional override, and config-based images beats the file convention, so every page built through this helper shared one card. 28 of the 30 callers ship their own opengraph-image.tsx. Every one of them was built, is served at its own URL and was named in no meta tag: checked on production, where /perps/opengraph-image, /rpc/opengraph-image and /team/opengraph-image all answer 200 with a distinct PNG while the pages themselves advertise the root one. /capital shipped a 78 KB card this week that nothing has ever pointed at. Dropping the two images keys lets Next resolve the card from the route's own file, and it fills twitter:image from that same file when the route has no twitter-image.tsx, so both tags land on the right card with its real alt text and dimensions. Two things this turned up that the plan had wrong, both found by rendering the pages rather than by reading the precedence rules: There is no ancestor cascade. A route with no opengraph-image.tsx of its own emits NO og:image once the override is gone, the root file notwithstanding. /rwa, /rpc-map and /perps/[asset] are the three callers in that position, so each gains a file. /rwa and /rpc-map re-export the site card, which is byte for byte what they served before (62,240 B), so those two are dedupe with no visible change; /perps/[asset] takes the perps hub card, which is the right subject and an upgrade on the generic one. Route segment config cannot be re-exported. `export { revalidate } from ...` fails with "can't recognize the exported `revalidate` field in route. It mustn't be reexported", and it does not fail locally: it 500s every image route in the app, the root card included. runtime and revalidate are declared literally in the three new files, with a note saying why. Verified in dev after the change: /rwa, /rpc-map, /perps/eth, /capital, /team and /rpc each emit og:image and twitter:image pointing at their own segment, and all five image routes answer 200 image/png (/capital 78,895 B, /perps/eth 66,985 B, /rwa and /rpc-map 62,240 B). typecheck, lint and the suite (368 tests) clean. * five more capital sections say which kind of nothing they are, and one token matches in the singular (#2739) The perp open-interest section was taught to say so instead of vanishing, and the comment above it states the rule: a table that disappears with nothing said is the same defect as a cell, one level up. Five sections on the same page still vanished: the stablecoin flow bar, the CCTP block, prediction-market open interest, the perp token table, and the protocols block, which takes the divergence heading and the whole reading layer with it. If bench 274 fails to load, the Valuation tab loses its H2, its reading and its table in silence. The data to say it was already there. hub.benches carries live and failed per bench, so sectionState (now in capital-hub-rules.ts with the other shared rules, and tested) tells three cases apart: failed the load threw; the reading exists, this render missed it unserved a bench this deployment does not carry empty served, ran, ranked nobody: the only one that is a measurement and returns null when the bench is not in the list at all, which is the gated case. Benches 280 and 281 are only listed where they are served, and a reading the deployment never promised is not a reading that is missing. Without that clause prod would print a note for the CCTP block it is not supposed to have. MissingSection is one implementation, because the point of the rule is that every section says it the same way; each caller passes what is missing and why the nearest other reading is not a stand-in. The perp OI paragraph keeps its own text: its source is the perp venue cohort snapshot, which has no entry in hub.benches and so no flag to read. The comment there now says that rather than implying it is the only section with the treatment. Separately, the divergence FAQ answer had a zero branch and a plural branch and no singular one, so at one match it read "1 of 67 tokens match today, Moonwell among them". Wrong agreement, and "among them" for one row. Singular is the common case now: the rule selected 11 rows before the two size floors and the category floor, and 1 today. This answer is in the FAQPage JSON-LD, so it is the sentence an answer engine quotes. Verified by rendering. With data present every section is unchanged and the FAQ reads "1 of 67 tokens matches today, Moonwell". With bench 277 forced to failed and its rows emptied, the section renders its heading plus "is unavailable here: the bench behind it did not answer on this render, which is transient and not a retirement", and the stub was reverted. typecheck, lint, validate (241 specs) and the suite (372 tests, 5 new on sectionState) clean. * the two blob proxies are ungated on purpose, and nothing said so (#2740) An audit found that /api/aggregate and /api/sitemap-data answer 200 on production carrying every dev-only bench, bench 208 included, with its full editorial fields and an seo_title naming a leader for a page that 404s. Eleven gated slugs in the aggregate, nine in the sitemap blob. The obvious fix is wrong and would cause an outage. Both libraries point every Vercel deployment at these routes ON THE PRODUCTION DOMAIN (aggregate-blob.ts:66, sitemap-blob.ts:4), because the alternative is a 12 s transatlantic fetch of a 7.5 MB blob from the Paris VPS. Staging is the only place the dev-only benches render, and it reads its data through production's proxy, so filtering on VERCEL_ENV in either route would strip all nine benches from staging. Verified: chain-bridged-tvl and perp-liq-rate both answer 200 on openchainbench-staging.vercel.app today. So the routes stay verbatim and now say why, at the top of each, addressed to whoever finds this next. The gate that matters is the loader (materialize/load.ts) and sitemap-builder.ts, and production's own surfaces are correct: sitemap.xml, llms.txt, RSS, /api/citable, /api/stat, MCP, the Markdown views and every page all drop bench 208, each checked live. What bounds the exposure, recorded with the warning: both routes are noindex via the /api/:path* X-Robots-Tag, neither is linked from any page, neither appears in llms.txt or the OpenAPI document, and the payload holds no secret. Scanned for keyed URL shapes (quiknode, alchemy, chainstack, getblock, api-key, token): none. While in that file, the leading docblock was wrong in three more ways. It described the three sets as one, so it gave 410 as the answer for a dev-only slug when the middleware only handles REMOVED_BENCH_SLUGS and a dev-only page 404s. It credited src/app/sitemap.ts, which does not exist since the sitemap became a Route Handler. And its list of protected surfaces was the list that omitted these two routes. It is now a map of which set does what, with the status code each produces and why a 404 is right for a URL that never published. Documentation only. typecheck, lint and the suite (368 tests) clean. * crm: a 24 h window next to the 7 d one, on every figure at once (#2742) Every windowed number on the dashboard was a fixed "last 7 days against the 7 before it". That is the right default for a site read by a few hundred people a week and the wrong one on the day something ships: a launch that triples today's traffic moves a 7-day average by a seventh, so the page meant to show the spike flattens it. The window is a parameter now. The header carries a 7 d / 24 h toggle, `?w=24h` switches the whole dashboard, and the comparison window is always the same length as the window itself, which is what the deltas on every card already claimed to be. The refresh runs the 20 windowed sections a second time for 24 h and stores them beside the first pass. The 8 long sections (the 28-day and 90-day series, the weekly cohorts, the kiosk list) mean the same thing whatever window the reader picked, so they are fetched once and a 24 h page reads them from the 7 d slice. That is 48 queries a pass against 28 before, 192 an hour at the 15 min cadence, 8 % of PostHog's organisation-wide 2,400; the local budget default goes from 300 to 500 so the manual Refresh button still fits beside it. Three things that had to follow the window and would otherwise have mislabelled a number: - every "vs previous 7 d" under a delta, which is why Metric and Kpi take a prevLabel instead of hardcoding a week - the manual refresh, which kept only the referer's pathname and so returned a 24 h reader to the 7 d view - every in-app link, so switching to 24 h and then clicking through does not silently drop back Two things deliberately do NOT follow it: the bench staleness thresholds on /health (Stale > 24 h, Expired > 7 d) describe the data, not the reader's window, and the daily history table reads history.jsonl, which records the 7 d headline numbers once a day whatever anyone is looking at. Both are commented where they sit. Tests cover that the window sets both intervals, that the fixed sections render identical SQL in either window, and that the windowed list is exactly the set whose SQL actually moves, so a section cannot be stored once and shown under two labels. * crm: the delta column said w/w under a 24 h window (#2743) Four tables carry a delta column headed "w/w". Under the 24 h window the comparison is the day before, not the week before, so the header named a period the number was not measured over. Spotted on the referring-domains table, where t.co read "+27000% w/w" on a delta that was against yesterday. The header follows the window now, "w/w" at 7 d and "d/d" at 24 h. One "w/w" is deliberately left alone: the AI delta on the overview is computed from lastFull and prevFull, two complete weeks off the weekly series, and stays week over week whatever window the reader picked. * the map goes back to staging, the speed test keeps collecting Three things, all from one report: the speed test on production returned no results at all. The map returns to DEV_ONLY_ROUTES. Shipping it this afternoon was half right: the deadlock worth breaking was the speed test being gated, since it is the only source of contributions. The map going with it was not, because an honest empty state still asks a reader to look at a world map with nothing on it. Collection does not depend on this: the contribute route is gated on "/speedtest-rpc" and derives the city cell server-side from Vercel's IP headers, so it needs neither the map page nor /api/speedtest/whereami. The un-gate condition is written where the gate is: check `total` and `cells` on /api/speedtest/map. With the map gone from production, the speed test must not offer a link to it. The line after a test said "This result was added anonymously to the global latency map" followed by "View the map", which would have been a 404 on every completed test. It now says the result was recorded for a map that opens once enough areas have samples, and the link is only rendered where the map is served. The three Lava Network endpoints are gone from the directory. All of them, eth1, arb1 and sonic, answer 410 Gone: the service is retired, so every visitor who picked one of those three chains was told a dead endpoint was "not testable from a browser". Finally, the message that reports blocked endpoints could not say the one thing the reader needed. Checked from this origin, four of the six Ethereum endpoints return access-control-allow-origin (publicnode, drpc, meowrpc, tenderly); nodies fails its preflight and lava is the 410 above. So when a run blocks ALL of them, "not testable from a browser" is false of four of the six, and the real cause is local: shields, a privacy extension, a VPN, a corporate network. That case now says so and suggests what to try, instead of blaming the providers and leaving no way forward.
Flotapponnier
added a commit
that referenced
this pull request
Sep 30, 2026
* the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x (#2732) * the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x Two comments in the rpc-cost harness teach the next reader the belief the code was changed to remove. profiles.go carried two doc paragraphs on PlanTiers. The upper one said `all` is "the cheapest plan of any tier" and that "Free is scored here too"; the lower one, added with the paid-plan default, says `all` is the cheapest PAID plan because free tiers would hand the leaderboard to the $0 rows. The code is the lower one: model.go sets `paidOnly := tier == "all" && !p.isReference()` and skips TierFree, and benchmarks/rpc-cost.yml labels the tab "Cheapest paid plan". A reader scanning top-down got the stale answer, which is the exact defect the paid-plan default exists to prevent. The stale paragraph goes. model.go and catalogue.go both gave GetBlock's archive ratio as 1.5x on one method family. Read off the full_cu / archive_cu columns in pricing/catalogue.yml, the Ethereum catch-all reads are 20 -> 40 (2x) and the enumerated debug, trace and txpool methods are 40 -> 120 (3x); Solana is 50 -> 100 throughout. No ratio in the table is 1.5x, and the spec already states 2x and 3x. model.go was also naming the same 3x method set twice ("debug/trace" and "the enumerated trace family") and never mentioning the plain reads that are the real second case. The 1.5x is a real number in the wrong place: it is what GetBlock's published archive_multiplier ceiling of 6 implies against the 4x method family, which is why that formula is unusable. That is recorded on the getblock archive_rule note, and model.go now points there instead of repeating the figure as if it described these columns. Comments only. No behaviour change, no figure on the page moves. * the 90-day staleness rule these two comments describe does not exist catalogue.go said "A number nobody has re-verified in 90 days is reported as such on the page instead of quietly passing for current", and refresh.go said "a figure nobody has re-read in 90 days is the thing this bench most needs to admit". There is no 90-day constant in the harness. grep over the package finds the number only in these two comments and in a NOWNodes plan note about a package valid 90 days; benchmarks/rpc-cost.yml carries no threshold either. What the harness does is publish rpc_pricing_artifact_age_seconds per provider per artifact (metrics.go:120) and compare it to nothing. The distinction matters to anyone reading this before touching the freshness path: there is no cutoff past which a figure is withheld or relabelled, so adding one is a feature, not a bug fix, and removing the gauge would remove the only staleness signal there is. The spec already words it correctly at line 87, "Age is published either way, so a figure nobody has re-read is visible as such", so both comments now say that and name the gauge. Comments only. go build and go vet clean. * the tier selector reads its label from the spec, so a cost bench stops saying "Access" (#2733) `dimension_labels` landed with the zero-is-a-value work and every axis reads it except the one that motivated it: venue, bucket, kind, chain and region all do `dimensionLabels?.<key> ?? "<stock>"`, while the tier row hardcoded "Access". benchmarks/rpc-cost.yml declares `tier: Billing model` and its tier axis is usage / dedicated / reference ("Billed per request", "Dedicated capacity", "Free public, not ranked"). "Access" is the wrong word for that axis: on rpc-cost the plan band is the `venue` axis, not this one. The page rendered "Access" sixteen times against a spec that had already written the right label. One line, same shape as the five axes beside it. rpc-cost is the only spec in the tree that declares `dimension_labels.tier`, so the nine chain RPC benches keep "Access", which is correct for them: there the tier axis really is public against API-key. typecheck and eslint clean. * the chain registry had cosmos-hub twice, so /chains listed it twice (#2734) src/lib/chains.ts carried two `cosmos-hub` entries, identical but for `description`. Nothing threw, because CHAIN_BY_SLUG is `new Map(CHAINS.map(...))` and the later entry simply wins, so the duplicate only showed in the places that iterate the array: - /chains rendered two identical Cosmos Hub cards (verified on production: two `href="/chains/cosmos-hub"` on the page); - the sitemap emitted the URL twice, 1,211 <loc> entries for 1,210 unique URLs (verified on an uncached fetch of the live sitemap); - generateStaticParams in src/app/chains/[slug]/page.tsx returned the slug twice, so every build prerendered the route twice; - the stratified smoke test spent one of its 120 tail slots per rotation re-checking a URL it had already checked. The entry removed is the first one, which is the one CHAIN_BY_SLUG was already discarding, so the rendered description does not move: the page serves "The original Cosmos SDK chain and IBC hub..." before and after, checked against the live page. Guard added rather than only the deletion. A duplicate here is invisible to typecheck, to lint and to every Map-based consumer, which is why this one survived; src/lib/chains.test.ts asserts slug uniqueness and that CHAIN_BY_SLUG.size equals CHAINS.length. Checked both ways: the test fails on the pre-fix registry (147 entries, 146 in the Map) and passes after. typecheck, lint and the full suite (371 tests) clean. * eleven comments that described something the code does not do, and four dead exports (#2736) Found by an audit of today's diff. Each of these was checked against the code beside it; nothing here changes behaviour. Cache windows that moved and left their prose behind (#2724 widened the routes, these were not resurveyed): - api/stat/[slug] said the worst case went "from about 360 s to about 600 s" and called that parity with the bench page's 600 s ISR. 360 counts stale-while-revalidate on the old header (60 + 300); the matching figure for 300 + 900 is 1200, not 600, so it is about twice the page's ISR, not parity. The comment now says so and records where it shows: /rwa is revalidate = 300 and its FAQ, which becomes FAQPage JSON-LD, tells readers this endpoint carries "the same values and timestamp" while advertising it as a DataDownload. - spec.ts said loadAllBenchmarksCached shares its result "with 60s revalidate" three lines above `revalidate: 300`. Several API routes size their own windows against this number. - benchmark-body.tsx said the variant API "dedupes across users via its 60s cache"; that cache is 300 s. - api/freshness said its edge cache is "5 s s-maxage, 20 s swr" and that the LiveIndicator polls "every 8 s". The response sets s-maxage=30, swr=60 and the widget polls every 30 s. - live-indicator.tsx said it re-fetches /api/citable, edge-cached 60 s. It fetches /api/freshness, and that is 30 s. Both now note the asymmetry this creates: at 30 s the widget is the freshest clock on the page and can read newer than the table under it. - api/aggregate said "The ISR revalidate=60 above handles freshness instead". There is no revalidate on that route; it is force-dynamic. Wrong reason, right conclusion: - next.config.ts said the sitemap sets its own Cache-Control "because metadata routes ignore next.config headers()". sitemap.xml/route.ts is a Route Handler, not a metadata route, and the same comment already gives the real reason two lines earlier. Docs separated from their fields by today's zero_is_a_value insertion, so each described its neighbour. Reordered, text verbatim: - spec-schema.ts: three docs stacked in the reverse order of the three fields, so zero_is_a_value showed ledger_reliability's doc and ledger_columns' doc was stranded two fields up. - types/benchmark.ts: dimensionLabels' doc sat on zeroIsAValue. Counts that no longer hold: - site-footer.tsx named /llms.txt and /api/citable as the links absent from the sitemap, which reads as a complete list. Four of the ten are absent: those two plus /api/openapi.json and /api/llm-context. The next person pruning that row needs all four. - capital-hub.ts and capital/page.tsx both list five benches; eight feed the hub. Both now point at CAPITAL_BENCHES instead of carrying a copy, because the copy went stale the first time the list grew. - capital-hub.ts also credited bench 265 for perp open interest while correctly naming 265 as perp-pf-ratio two lines later. 265 stopped feeding that column today; the block above perpOi explains why. - capital-hub-rules.ts said "n/a" is the missing value on "the other 37 places the site uses it". Not derivable: the literal appears 63 times in src/ across 21 files. The claim keeps its substance and drops the number rather than standing as a fact-shaped guess. - the COLUMN_COVERAGE note quotes the owner's "two rows of seventy-nine" from the first hub draft. Kept as the quotation it is, with the current cohort size (63) named beside it so the two figures stop reading as a contradiction. One reader-facing string, not a comment: the categoryTooSmall reason said a category under five ranked protocols "compares the token with one or two others". Below a floor of five it is up to three others. Dead exports removed from capital-hub-rules.ts, zero callers across src/, scripts/ and worker/: - change7dFromDays and HISTORY_FRESH_MS (its only reader). Today's perp open-interest change removed the single call site, and the doc still named the data path the hub stopped reading. - change7dFromSeries, a one-line wrapper over change7dOfSeries. Its test covers change7dOfSeries' real behaviour, so the test was retargeted at that function rather than deleted: every assertion is kept. - the NaReason type, one reference in the tree and it was its own declaration. Using it instead of deleting it was the alternative, and it is a refactor rather than a cleanup: CCTP_SCOPE_LABEL values are also assigned to the notApplicable fields and are not in NA_REASON, so tightening those to NaReason needs a decision about the union first. typecheck, lint, validate (241 specs) and the suite (367 tests) clean. Deliberately not in this sweep: the warm-up and smoke comments in prod-deploy.yml, staging-deploy.yml and scripts/sitemap-smoke.mjs, which belong to whoever is holding that change; page-metadata.ts's "fallback" comment, which is only wrong because of a behaviour bug fixed separately; and benchmarks/rpc-cost.yml's "one plan in seven", which is page copy whose denominator two passes disagreed on. * 28 hub share cards were being generated and never used (#2738) pageMetadata pinned openGraph.images and twitter.images to the ROOT /opengraph-image and /twitter-image. The comment called it a fallback for hubs without a dedicated card. It was an unconditional override, and config-based images beats the file convention, so every page built through this helper shared one card. 28 of the 30 callers ship their own opengraph-image.tsx. Every one of them was built, is served at its own URL and was named in no meta tag: checked on production, where /perps/opengraph-image, /rpc/opengraph-image and /team/opengraph-image all answer 200 with a distinct PNG while the pages themselves advertise the root one. /capital shipped a 78 KB card this week that nothing has ever pointed at. Dropping the two images keys lets Next resolve the card from the route's own file, and it fills twitter:image from that same file when the route has no twitter-image.tsx, so both tags land on the right card with its real alt text and dimensions. Two things this turned up that the plan had wrong, both found by rendering the pages rather than by reading the precedence rules: There is no ancestor cascade. A route with no opengraph-image.tsx of its own emits NO og:image once the override is gone, the root file notwithstanding. /rwa, /rpc-map and /perps/[asset] are the three callers in that position, so each gains a file. /rwa and /rpc-map re-export the site card, which is byte for byte what they served before (62,240 B), so those two are dedupe with no visible change; /perps/[asset] takes the perps hub card, which is the right subject and an upgrade on the generic one. Route segment config cannot be re-exported. `export { revalidate } from ...` fails with "can't recognize the exported `revalidate` field in route. It mustn't be reexported", and it does not fail locally: it 500s every image route in the app, the root card included. runtime and revalidate are declared literally in the three new files, with a note saying why. Verified in dev after the change: /rwa, /rpc-map, /perps/eth, /capital, /team and /rpc each emit og:image and twitter:image pointing at their own segment, and all five image routes answer 200 image/png (/capital 78,895 B, /perps/eth 66,985 B, /rwa and /rpc-map 62,240 B). typecheck, lint and the suite (368 tests) clean. * five more capital sections say which kind of nothing they are, and one token matches in the singular (#2739) The perp open-interest section was taught to say so instead of vanishing, and the comment above it states the rule: a table that disappears with nothing said is the same defect as a cell, one level up. Five sections on the same page still vanished: the stablecoin flow bar, the CCTP block, prediction-market open interest, the perp token table, and the protocols block, which takes the divergence heading and the whole reading layer with it. If bench 274 fails to load, the Valuation tab loses its H2, its reading and its table in silence. The data to say it was already there. hub.benches carries live and failed per bench, so sectionState (now in capital-hub-rules.ts with the other shared rules, and tested) tells three cases apart: failed the load threw; the reading exists, this render missed it unserved a bench this deployment does not carry empty served, ran, ranked nobody: the only one that is a measurement and returns null when the bench is not in the list at all, which is the gated case. Benches 280 and 281 are only listed where they are served, and a reading the deployment never promised is not a reading that is missing. Without that clause prod would print a note for the CCTP block it is not supposed to have. MissingSection is one implementation, because the point of the rule is that every section says it the same way; each caller passes what is missing and why the nearest other reading is not a stand-in. The perp OI paragraph keeps its own text: its source is the perp venue cohort snapshot, which has no entry in hub.benches and so no flag to read. The comment there now says that rather than implying it is the only section with the treatment. Separately, the divergence FAQ answer had a zero branch and a plural branch and no singular one, so at one match it read "1 of 67 tokens match today, Moonwell among them". Wrong agreement, and "among them" for one row. Singular is the common case now: the rule selected 11 rows before the two size floors and the category floor, and 1 today. This answer is in the FAQPage JSON-LD, so it is the sentence an answer engine quotes. Verified by rendering. With data present every section is unchanged and the FAQ reads "1 of 67 tokens matches today, Moonwell". With bench 277 forced to failed and its rows emptied, the section renders its heading plus "is unavailable here: the bench behind it did not answer on this render, which is transient and not a retirement", and the stub was reverted. typecheck, lint, validate (241 specs) and the suite (372 tests, 5 new on sectionState) clean. * the two blob proxies are ungated on purpose, and nothing said so (#2740) An audit found that /api/aggregate and /api/sitemap-data answer 200 on production carrying every dev-only bench, bench 208 included, with its full editorial fields and an seo_title naming a leader for a page that 404s. Eleven gated slugs in the aggregate, nine in the sitemap blob. The obvious fix is wrong and would cause an outage. Both libraries point every Vercel deployment at these routes ON THE PRODUCTION DOMAIN (aggregate-blob.ts:66, sitemap-blob.ts:4), because the alternative is a 12 s transatlantic fetch of a 7.5 MB blob from the Paris VPS. Staging is the only place the dev-only benches render, and it reads its data through production's proxy, so filtering on VERCEL_ENV in either route would strip all nine benches from staging. Verified: chain-bridged-tvl and perp-liq-rate both answer 200 on openchainbench-staging.vercel.app today. So the routes stay verbatim and now say why, at the top of each, addressed to whoever finds this next. The gate that matters is the loader (materialize/load.ts) and sitemap-builder.ts, and production's own surfaces are correct: sitemap.xml, llms.txt, RSS, /api/citable, /api/stat, MCP, the Markdown views and every page all drop bench 208, each checked live. What bounds the exposure, recorded with the warning: both routes are noindex via the /api/:path* X-Robots-Tag, neither is linked from any page, neither appears in llms.txt or the OpenAPI document, and the payload holds no secret. Scanned for keyed URL shapes (quiknode, alchemy, chainstack, getblock, api-key, token): none. While in that file, the leading docblock was wrong in three more ways. It described the three sets as one, so it gave 410 as the answer for a dev-only slug when the middleware only handles REMOVED_BENCH_SLUGS and a dev-only page 404s. It credited src/app/sitemap.ts, which does not exist since the sitemap became a Route Handler. And its list of protected surfaces was the list that omitted these two routes. It is now a map of which set does what, with the status code each produces and why a 404 is right for a URL that never published. Documentation only. typecheck, lint and the suite (368 tests) clean. * crm: a 24 h window next to the 7 d one, on every figure at once (#2742) Every windowed number on the dashboard was a fixed "last 7 days against the 7 before it". That is the right default for a site read by a few hundred people a week and the wrong one on the day something ships: a launch that triples today's traffic moves a 7-day average by a seventh, so the page meant to show the spike flattens it. The window is a parameter now. The header carries a 7 d / 24 h toggle, `?w=24h` switches the whole dashboard, and the comparison window is always the same length as the window itself, which is what the deltas on every card already claimed to be. The refresh runs the 20 windowed sections a second time for 24 h and stores them beside the first pass. The 8 long sections (the 28-day and 90-day series, the weekly cohorts, the kiosk list) mean the same thing whatever window the reader picked, so they are fetched once and a 24 h page reads them from the 7 d slice. That is 48 queries a pass against 28 before, 192 an hour at the 15 min cadence, 8 % of PostHog's organisation-wide 2,400; the local budget default goes from 300 to 500 so the manual Refresh button still fits beside it. Three things that had to follow the window and would otherwise have mislabelled a number: - every "vs previous 7 d" under a delta, which is why Metric and Kpi take a prevLabel instead of hardcoding a week - the manual refresh, which kept only the referer's pathname and so returned a 24 h reader to the 7 d view - every in-app link, so switching to 24 h and then clicking through does not silently drop back Two things deliberately do NOT follow it: the bench staleness thresholds on /health (Stale > 24 h, Expired > 7 d) describe the data, not the reader's window, and the daily history table reads history.jsonl, which records the 7 d headline numbers once a day whatever anyone is looking at. Both are commented where they sit. Tests cover that the window sets both intervals, that the fixed sections render identical SQL in either window, and that the windowed list is exactly the set whose SQL actually moves, so a section cannot be stored once and shown under two labels. * crm: the delta column said w/w under a 24 h window (#2743) Four tables carry a delta column headed "w/w". Under the 24 h window the comparison is the day before, not the week before, so the header named a period the number was not measured over. Spotted on the referring-domains table, where t.co read "+27000% w/w" on a delta that was against yesterday. The header follows the window now, "w/w" at 7 d and "d/d" at 24 h. One "w/w" is deliberately left alone: the AI delta on the overview is computed from lastFull and prevFull, two complete weeks off the weekly series, and stays week over week whatever window the reader picked. * the nav offered a route production answers with a 404 The sidebar listed "RPC latency map" on production while the server sent HTML that contained no such link. Both were true: the server rendered the menu correctly, hydration put the link back. site-nav-items.ts guards those entries with isDevOnlyRoute(), which reads `process.env.VERCEL_ENV`. The sidebar and the header are client components, and Next only inlines NEXT_PUBLIC_-prefixed variables into a client bundle. Everything else is undefined there, so the flag read false, so the guard passed. The deployed chunk says it plainly: hR = "production" === P.default.env.VERCEL_ENV // undefined in a browser hC = new Set(["/rpc-map"]) // the gate arrived intact ...hR && hC.has("/rpc-map") ? [] : [{ href: "/rpc-map", ... }] The set of gated routes crossed the boundary correctly; only the environment flag was dead. Clicking the entry gave a 404. The gate is a parameter now. The root layout is a server component, reads it where the environment is real, and hands `hiddenRoutes` to both client components. A caller that passes nothing gets the full nav, which is the right default for a menu: a missing argument should not silently hide navigation. This was latent on /speedtest-rpc for the nine days it was gated, on every production page. Nobody reported it because the entry pointed at a page that was coming rather than one that had been withdrawn. The test asserts the structure rather than the symptom: the nav module must contain no `process.env` and must not import removed-benches, which is the module that reads VERCEL_ENV at import time. Hiding a route must remove it from the flat list, from the groups the sidebar walks and from the header row, and hiding nothing must leave the nav whole.
Flotapponnier
added a commit
that referenced
this pull request
Sep 30, 2026
* the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x (#2732) * the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x Two comments in the rpc-cost harness teach the next reader the belief the code was changed to remove. profiles.go carried two doc paragraphs on PlanTiers. The upper one said `all` is "the cheapest plan of any tier" and that "Free is scored here too"; the lower one, added with the paid-plan default, says `all` is the cheapest PAID plan because free tiers would hand the leaderboard to the $0 rows. The code is the lower one: model.go sets `paidOnly := tier == "all" && !p.isReference()` and skips TierFree, and benchmarks/rpc-cost.yml labels the tab "Cheapest paid plan". A reader scanning top-down got the stale answer, which is the exact defect the paid-plan default exists to prevent. The stale paragraph goes. model.go and catalogue.go both gave GetBlock's archive ratio as 1.5x on one method family. Read off the full_cu / archive_cu columns in pricing/catalogue.yml, the Ethereum catch-all reads are 20 -> 40 (2x) and the enumerated debug, trace and txpool methods are 40 -> 120 (3x); Solana is 50 -> 100 throughout. No ratio in the table is 1.5x, and the spec already states 2x and 3x. model.go was also naming the same 3x method set twice ("debug/trace" and "the enumerated trace family") and never mentioning the plain reads that are the real second case. The 1.5x is a real number in the wrong place: it is what GetBlock's published archive_multiplier ceiling of 6 implies against the 4x method family, which is why that formula is unusable. That is recorded on the getblock archive_rule note, and model.go now points there instead of repeating the figure as if it described these columns. Comments only. No behaviour change, no figure on the page moves. * the 90-day staleness rule these two comments describe does not exist catalogue.go said "A number nobody has re-verified in 90 days is reported as such on the page instead of quietly passing for current", and refresh.go said "a figure nobody has re-read in 90 days is the thing this bench most needs to admit". There is no 90-day constant in the harness. grep over the package finds the number only in these two comments and in a NOWNodes plan note about a package valid 90 days; benchmarks/rpc-cost.yml carries no threshold either. What the harness does is publish rpc_pricing_artifact_age_seconds per provider per artifact (metrics.go:120) and compare it to nothing. The distinction matters to anyone reading this before touching the freshness path: there is no cutoff past which a figure is withheld or relabelled, so adding one is a feature, not a bug fix, and removing the gauge would remove the only staleness signal there is. The spec already words it correctly at line 87, "Age is published either way, so a figure nobody has re-read is visible as such", so both comments now say that and name the gauge. Comments only. go build and go vet clean. * the tier selector reads its label from the spec, so a cost bench stops saying "Access" (#2733) `dimension_labels` landed with the zero-is-a-value work and every axis reads it except the one that motivated it: venue, bucket, kind, chain and region all do `dimensionLabels?.<key> ?? "<stock>"`, while the tier row hardcoded "Access". benchmarks/rpc-cost.yml declares `tier: Billing model` and its tier axis is usage / dedicated / reference ("Billed per request", "Dedicated capacity", "Free public, not ranked"). "Access" is the wrong word for that axis: on rpc-cost the plan band is the `venue` axis, not this one. The page rendered "Access" sixteen times against a spec that had already written the right label. One line, same shape as the five axes beside it. rpc-cost is the only spec in the tree that declares `dimension_labels.tier`, so the nine chain RPC benches keep "Access", which is correct for them: there the tier axis really is public against API-key. typecheck and eslint clean. * the chain registry had cosmos-hub twice, so /chains listed it twice (#2734) src/lib/chains.ts carried two `cosmos-hub` entries, identical but for `description`. Nothing threw, because CHAIN_BY_SLUG is `new Map(CHAINS.map(...))` and the later entry simply wins, so the duplicate only showed in the places that iterate the array: - /chains rendered two identical Cosmos Hub cards (verified on production: two `href="/chains/cosmos-hub"` on the page); - the sitemap emitted the URL twice, 1,211 <loc> entries for 1,210 unique URLs (verified on an uncached fetch of the live sitemap); - generateStaticParams in src/app/chains/[slug]/page.tsx returned the slug twice, so every build prerendered the route twice; - the stratified smoke test spent one of its 120 tail slots per rotation re-checking a URL it had already checked. The entry removed is the first one, which is the one CHAIN_BY_SLUG was already discarding, so the rendered description does not move: the page serves "The original Cosmos SDK chain and IBC hub..." before and after, checked against the live page. Guard added rather than only the deletion. A duplicate here is invisible to typecheck, to lint and to every Map-based consumer, which is why this one survived; src/lib/chains.test.ts asserts slug uniqueness and that CHAIN_BY_SLUG.size equals CHAINS.length. Checked both ways: the test fails on the pre-fix registry (147 entries, 146 in the Map) and passes after. typecheck, lint and the full suite (371 tests) clean. * eleven comments that described something the code does not do, and four dead exports (#2736) Found by an audit of today's diff. Each of these was checked against the code beside it; nothing here changes behaviour. Cache windows that moved and left their prose behind (#2724 widened the routes, these were not resurveyed): - api/stat/[slug] said the worst case went "from about 360 s to about 600 s" and called that parity with the bench page's 600 s ISR. 360 counts stale-while-revalidate on the old header (60 + 300); the matching figure for 300 + 900 is 1200, not 600, so it is about twice the page's ISR, not parity. The comment now says so and records where it shows: /rwa is revalidate = 300 and its FAQ, which becomes FAQPage JSON-LD, tells readers this endpoint carries "the same values and timestamp" while advertising it as a DataDownload. - spec.ts said loadAllBenchmarksCached shares its result "with 60s revalidate" three lines above `revalidate: 300`. Several API routes size their own windows against this number. - benchmark-body.tsx said the variant API "dedupes across users via its 60s cache"; that cache is 300 s. - api/freshness said its edge cache is "5 s s-maxage, 20 s swr" and that the LiveIndicator polls "every 8 s". The response sets s-maxage=30, swr=60 and the widget polls every 30 s. - live-indicator.tsx said it re-fetches /api/citable, edge-cached 60 s. It fetches /api/freshness, and that is 30 s. Both now note the asymmetry this creates: at 30 s the widget is the freshest clock on the page and can read newer than the table under it. - api/aggregate said "The ISR revalidate=60 above handles freshness instead". There is no revalidate on that route; it is force-dynamic. Wrong reason, right conclusion: - next.config.ts said the sitemap sets its own Cache-Control "because metadata routes ignore next.config headers()". sitemap.xml/route.ts is a Route Handler, not a metadata route, and the same comment already gives the real reason two lines earlier. Docs separated from their fields by today's zero_is_a_value insertion, so each described its neighbour. Reordered, text verbatim: - spec-schema.ts: three docs stacked in the reverse order of the three fields, so zero_is_a_value showed ledger_reliability's doc and ledger_columns' doc was stranded two fields up. - types/benchmark.ts: dimensionLabels' doc sat on zeroIsAValue. Counts that no longer hold: - site-footer.tsx named /llms.txt and /api/citable as the links absent from the sitemap, which reads as a complete list. Four of the ten are absent: those two plus /api/openapi.json and /api/llm-context. The next person pruning that row needs all four. - capital-hub.ts and capital/page.tsx both list five benches; eight feed the hub. Both now point at CAPITAL_BENCHES instead of carrying a copy, because the copy went stale the first time the list grew. - capital-hub.ts also credited bench 265 for perp open interest while correctly naming 265 as perp-pf-ratio two lines later. 265 stopped feeding that column today; the block above perpOi explains why. - capital-hub-rules.ts said "n/a" is the missing value on "the other 37 places the site uses it". Not derivable: the literal appears 63 times in src/ across 21 files. The claim keeps its substance and drops the number rather than standing as a fact-shaped guess. - the COLUMN_COVERAGE note quotes the owner's "two rows of seventy-nine" from the first hub draft. Kept as the quotation it is, with the current cohort size (63) named beside it so the two figures stop reading as a contradiction. One reader-facing string, not a comment: the categoryTooSmall reason said a category under five ranked protocols "compares the token with one or two others". Below a floor of five it is up to three others. Dead exports removed from capital-hub-rules.ts, zero callers across src/, scripts/ and worker/: - change7dFromDays and HISTORY_FRESH_MS (its only reader). Today's perp open-interest change removed the single call site, and the doc still named the data path the hub stopped reading. - change7dFromSeries, a one-line wrapper over change7dOfSeries. Its test covers change7dOfSeries' real behaviour, so the test was retargeted at that function rather than deleted: every assertion is kept. - the NaReason type, one reference in the tree and it was its own declaration. Using it instead of deleting it was the alternative, and it is a refactor rather than a cleanup: CCTP_SCOPE_LABEL values are also assigned to the notApplicable fields and are not in NA_REASON, so tightening those to NaReason needs a decision about the union first. typecheck, lint, validate (241 specs) and the suite (367 tests) clean. Deliberately not in this sweep: the warm-up and smoke comments in prod-deploy.yml, staging-deploy.yml and scripts/sitemap-smoke.mjs, which belong to whoever is holding that change; page-metadata.ts's "fallback" comment, which is only wrong because of a behaviour bug fixed separately; and benchmarks/rpc-cost.yml's "one plan in seven", which is page copy whose denominator two passes disagreed on. * 28 hub share cards were being generated and never used (#2738) pageMetadata pinned openGraph.images and twitter.images to the ROOT /opengraph-image and /twitter-image. The comment called it a fallback for hubs without a dedicated card. It was an unconditional override, and config-based images beats the file convention, so every page built through this helper shared one card. 28 of the 30 callers ship their own opengraph-image.tsx. Every one of them was built, is served at its own URL and was named in no meta tag: checked on production, where /perps/opengraph-image, /rpc/opengraph-image and /team/opengraph-image all answer 200 with a distinct PNG while the pages themselves advertise the root one. /capital shipped a 78 KB card this week that nothing has ever pointed at. Dropping the two images keys lets Next resolve the card from the route's own file, and it fills twitter:image from that same file when the route has no twitter-image.tsx, so both tags land on the right card with its real alt text and dimensions. Two things this turned up that the plan had wrong, both found by rendering the pages rather than by reading the precedence rules: There is no ancestor cascade. A route with no opengraph-image.tsx of its own emits NO og:image once the override is gone, the root file notwithstanding. /rwa, /rpc-map and /perps/[asset] are the three callers in that position, so each gains a file. /rwa and /rpc-map re-export the site card, which is byte for byte what they served before (62,240 B), so those two are dedupe with no visible change; /perps/[asset] takes the perps hub card, which is the right subject and an upgrade on the generic one. Route segment config cannot be re-exported. `export { revalidate } from ...` fails with "can't recognize the exported `revalidate` field in route. It mustn't be reexported", and it does not fail locally: it 500s every image route in the app, the root card included. runtime and revalidate are declared literally in the three new files, with a note saying why. Verified in dev after the change: /rwa, /rpc-map, /perps/eth, /capital, /team and /rpc each emit og:image and twitter:image pointing at their own segment, and all five image routes answer 200 image/png (/capital 78,895 B, /perps/eth 66,985 B, /rwa and /rpc-map 62,240 B). typecheck, lint and the suite (368 tests) clean. * five more capital sections say which kind of nothing they are, and one token matches in the singular (#2739) The perp open-interest section was taught to say so instead of vanishing, and the comment above it states the rule: a table that disappears with nothing said is the same defect as a cell, one level up. Five sections on the same page still vanished: the stablecoin flow bar, the CCTP block, prediction-market open interest, the perp token table, and the protocols block, which takes the divergence heading and the whole reading layer with it. If bench 274 fails to load, the Valuation tab loses its H2, its reading and its table in silence. The data to say it was already there. hub.benches carries live and failed per bench, so sectionState (now in capital-hub-rules.ts with the other shared rules, and tested) tells three cases apart: failed the load threw; the reading exists, this render missed it unserved a bench this deployment does not carry empty served, ran, ranked nobody: the only one that is a measurement and returns null when the bench is not in the list at all, which is the gated case. Benches 280 and 281 are only listed where they are served, and a reading the deployment never promised is not a reading that is missing. Without that clause prod would print a note for the CCTP block it is not supposed to have. MissingSection is one implementation, because the point of the rule is that every section says it the same way; each caller passes what is missing and why the nearest other reading is not a stand-in. The perp OI paragraph keeps its own text: its source is the perp venue cohort snapshot, which has no entry in hub.benches and so no flag to read. The comment there now says that rather than implying it is the only section with the treatment. Separately, the divergence FAQ answer had a zero branch and a plural branch and no singular one, so at one match it read "1 of 67 tokens match today, Moonwell among them". Wrong agreement, and "among them" for one row. Singular is the common case now: the rule selected 11 rows before the two size floors and the category floor, and 1 today. This answer is in the FAQPage JSON-LD, so it is the sentence an answer engine quotes. Verified by rendering. With data present every section is unchanged and the FAQ reads "1 of 67 tokens matches today, Moonwell". With bench 277 forced to failed and its rows emptied, the section renders its heading plus "is unavailable here: the bench behind it did not answer on this render, which is transient and not a retirement", and the stub was reverted. typecheck, lint, validate (241 specs) and the suite (372 tests, 5 new on sectionState) clean. * the two blob proxies are ungated on purpose, and nothing said so (#2740) An audit found that /api/aggregate and /api/sitemap-data answer 200 on production carrying every dev-only bench, bench 208 included, with its full editorial fields and an seo_title naming a leader for a page that 404s. Eleven gated slugs in the aggregate, nine in the sitemap blob. The obvious fix is wrong and would cause an outage. Both libraries point every Vercel deployment at these routes ON THE PRODUCTION DOMAIN (aggregate-blob.ts:66, sitemap-blob.ts:4), because the alternative is a 12 s transatlantic fetch of a 7.5 MB blob from the Paris VPS. Staging is the only place the dev-only benches render, and it reads its data through production's proxy, so filtering on VERCEL_ENV in either route would strip all nine benches from staging. Verified: chain-bridged-tvl and perp-liq-rate both answer 200 on openchainbench-staging.vercel.app today. So the routes stay verbatim and now say why, at the top of each, addressed to whoever finds this next. The gate that matters is the loader (materialize/load.ts) and sitemap-builder.ts, and production's own surfaces are correct: sitemap.xml, llms.txt, RSS, /api/citable, /api/stat, MCP, the Markdown views and every page all drop bench 208, each checked live. What bounds the exposure, recorded with the warning: both routes are noindex via the /api/:path* X-Robots-Tag, neither is linked from any page, neither appears in llms.txt or the OpenAPI document, and the payload holds no secret. Scanned for keyed URL shapes (quiknode, alchemy, chainstack, getblock, api-key, token): none. While in that file, the leading docblock was wrong in three more ways. It described the three sets as one, so it gave 410 as the answer for a dev-only slug when the middleware only handles REMOVED_BENCH_SLUGS and a dev-only page 404s. It credited src/app/sitemap.ts, which does not exist since the sitemap became a Route Handler. And its list of protected surfaces was the list that omitted these two routes. It is now a map of which set does what, with the status code each produces and why a 404 is right for a URL that never published. Documentation only. typecheck, lint and the suite (368 tests) clean. * crm: a 24 h window next to the 7 d one, on every figure at once (#2742) Every windowed number on the dashboard was a fixed "last 7 days against the 7 before it". That is the right default for a site read by a few hundred people a week and the wrong one on the day something ships: a launch that triples today's traffic moves a 7-day average by a seventh, so the page meant to show the spike flattens it. The window is a parameter now. The header carries a 7 d / 24 h toggle, `?w=24h` switches the whole dashboard, and the comparison window is always the same length as the window itself, which is what the deltas on every card already claimed to be. The refresh runs the 20 windowed sections a second time for 24 h and stores them beside the first pass. The 8 long sections (the 28-day and 90-day series, the weekly cohorts, the kiosk list) mean the same thing whatever window the reader picked, so they are fetched once and a 24 h page reads them from the 7 d slice. That is 48 queries a pass against 28 before, 192 an hour at the 15 min cadence, 8 % of PostHog's organisation-wide 2,400; the local budget default goes from 300 to 500 so the manual Refresh button still fits beside it. Three things that had to follow the window and would otherwise have mislabelled a number: - every "vs previous 7 d" under a delta, which is why Metric and Kpi take a prevLabel instead of hardcoding a week - the manual refresh, which kept only the referer's pathname and so returned a 24 h reader to the 7 d view - every in-app link, so switching to 24 h and then clicking through does not silently drop back Two things deliberately do NOT follow it: the bench staleness thresholds on /health (Stale > 24 h, Expired > 7 d) describe the data, not the reader's window, and the daily history table reads history.jsonl, which records the 7 d headline numbers once a day whatever anyone is looking at. Both are commented where they sit. Tests cover that the window sets both intervals, that the fixed sections render identical SQL in either window, and that the windowed list is exactly the set whose SQL actually moves, so a section cannot be stored once and shown under two labels. * crm: the delta column said w/w under a 24 h window (#2743) Four tables carry a delta column headed "w/w". Under the 24 h window the comparison is the day before, not the week before, so the header named a period the number was not measured over. Spotted on the referring-domains table, where t.co read "+27000% w/w" on a delta that was against yesterday. The header follows the window now, "w/w" at 7 d and "d/d" at 24 h. One "w/w" is deliberately left alone: the AI delta on the overview is computed from lastFull and prevFull, two complete weeks off the weekly series, and stays week over week whatever window the reader picked. * say "Brave" to Brave, before the test rather than after it A Brave visitor gets every endpoint blocked and no result: Shields blocks requests to RPC hosts by default through its filter lists. Confirmed on one machine, one network, this afternoon: Safari returned five providers (PublicNode 49 ms, Tenderly 41 ms, dRPC 78 ms, MeowRPC 83 ms, Arbitrum Official 132 ms) while Brave returned nothing. The advice we had is true and useless to that person: "a shields-up browser, a privacy or ad-blocking extension, a VPN or a corporate network" names four things, three of which they do not have, and never names the one they do. navigator.brave.isBrave() tells us which it is, so the message can name Shields and where the toggle lives. Said on the setup screen as well, not only after a failed run. The test takes its full duration before the results screen appears, so a Brave visitor otherwise spends the whole run to be told to change a setting and start over. This is the most we can do from our side, and the comment on the detector says why: the test only means anything because the requests go from the visitor's browser straight to the provider. Proxying them through us to dodge the block would measure our server instead of their connection and would send their API keys to us, which is the opposite of what the page promises twice above the fold. The server-side answer already exists and the page already links it: the public benchmarks.
This was referenced Oct 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit of the 24 route files that declare
export const dynamic = "force-dynamic", and a cache window for the ones paying for freshness nobody could observe.What the audit found first
The premise that these routes are uncached does not hold. Measured against prod on 2026-09-30, all 17 of the 24 that serve a 200 to an anonymous GET already return
x-vercel-cache: HITon a second request. Vercel's CDN honours thes-maxageeach handler sets on its own response;force-dynamicdoes not stop that, it only keeps the route out of ISR.Measured directly, twice each, second request in parentheses:
stat(HIT),series(HIT),variant(HIT),badge(HIT),badge/snippet(HIT),compare(HIT),oracle-pairs(HIT),hl-frontends/history(HIT),citable(HIT),citable/[date](HIT),llm-context(HIT),sparkline(HIT),capital(HIT),search/featured(HIT),live-prices(HIT),md(HIT),sitemap.xml(HIT).aggregateis MISS on every request. The threebuilder/*routes are MISS because they 503 on prod today. The two crons are 401-gated andrevalidate-aggregateis a POST, so neither is cacheable by design.force-dynamicis correct on all 24 and none of it is removed. Next 16 does not cache route handlers by default, and the documented opt-in isdynamic = 'force-static', which forcesheaders(),cookies()andsearchParamsto empty values. Every one of these handlers reads the request, for the per-client rate limiter if nothing else, so arevalidatewindow would be a no-op at best and would break filters and limiting at worst. The repo already knows this:src/app/api/search/index/route.tscarries the note "No request data is read so the route stays static."So the lever that reduces invocations here is the CDN window, and that is what moved.
What changed
api/stat/[slug]getBenchmarkreads through a 300 s data cache (src/lib/spec.ts:476/711/811)api/series/[slug]getSeriesMapCachedisrevalidate: 300; so is the slim bench readapi/bench/[slug]/variantSWEEP_SECxVARIANT_EVERY= 300 sapi/bench/[slug]/oracle-pairsapi/bench/hyperliquid-frontends/historyapi/compare/[a]/[b]api/builder/[slug]/daily-historyhl-archive-storeisrevalidate: 300, over daily aggregatesapi/builder/[slug]/daily-seriesapi/builder/[slug]/top-usersapi/badge/.../snippetThe 300 s group all sit on data caches that are themselves 300 s, so four of every five fills at a 60 s window recomputed the handler and returned a byte-identical body. The windows do compose, so this is not free: worst-case data age goes from roughly 360 s to roughly 600 s. That is parity with the 600 s ISR on the bench pages these mirror, which is what a reader comparing the two would see anyway.
The two builder proxies rest on the data's shape rather than the harness poll interval, deliberately:
fetchRangeinagg.goends the window at yesterday, so a published point is final and the only thing that ever changes is a new day appearing after the UTC rollover. That holds whatever-pollthe deployed harness runs, which matters because the 30 m inmain.go:47is a flag default that nothing in this repo pins.Three guards that keep the longer windows honest
oracle-pairsanswers 200 with no rows when the Prom vector comes back empty. That case keeps a 60 s window; otherwise a Prom restart or a renamedpair/source_alabel could pin an empty "View by oracle pair" matrix at the edge for twenty minutes with nothing able to bust it.hl-frontends90d/180d fallback keeps its own 120 s window. It serves 30-day totals labelled 90d while the archive backfills. What bounds a stand-in is how soon the real archive can replace it, not the TTL of the caches it was built from.Error-path headers (
s-maxage=60on 400s and 404s) are left short so a mistyped slug cannot hold a CDN entry.Corrections, where the code and the comments disagreed about production
/api/citableis served on 300 s, not the hour everything claimed.headers()innext.config.tsmatchessource: "/api/citable"and asks fors-maxage=3600, but a config header does not override a Cache-Control the route handler sets on its own response. Sampled every 45 s for ten minutes: the edge entry wentSTALEatage: 306and reset to42, climbed, and wentSTALEagain at313. Two clean cycles, so this is the window rather than a one-off eviction. The config rule is inert. Both sides now record that, with the measurement. The value is left at 300 s, which is the data-cache floor anyway; raising the window on the endpoint LLMs cite most is a freshness decision for the owner, not something to slip into a caching PR./api/citable/[date]is not the immutable snapshot it advertises. It looks like the obvious win and it is a trap. The handler reads the current benchmarks and relabels them with the requested date;dateis never consulted below the validation. Verified:/api/citable/2026-01-15returns all 225 rows byte-equal to the live/api/citable, each carryingasOf: 2026-09-30. A future date inside 2025-2100 answers 200 the same way, so/api/citable/2100-12-31is a CORS-open, day-cached "snapshot" dated 74 years out. Three separate comments asserted the freeze was implemented; they now record the gap. The window stays at a day: immutable would pin one arbitrary scrape's numbers under a past label permanently, which is the kind of quietly-wrong figure this week was spent removing./api/md/[...path]asks forno-storeand does not get it. The route documents that it is uncached so each read emits onemarkdown_readevent. On prod a fresh cache key answers MISS, then HIT with a climbing age, and the client sees a barecache-control: public. So the analytics already count cache fills. Left as is on purpose: makingno-storestick restores the counting and pays a function per read, while accepting the cache means fixing the counting instead. That is a product call.The three
/api/builderroutes 503 on prod today (hl_node_not_configured, soHL_NODE_AUTHis unset; andarchive_pendingfor daily-history). A 503 is not a cacheable status, so they cost a function per request and cache nothing until that is fixed. Their new windows are inert until then, and each says so.Kept dynamic, and correct as such
api/capital(per-request by design, so a reader can get a fresh reading when the ISR page is stale),api/internal/revalidate-aggregate(POST action endpoint),api/cron/health-checkandapi/cron/indexnow(cron targets, where a cache would defeat the alert; both 401 to the public),api/chain/[slug]/live-prices(2 s client poll behind a 5 sunstable_cache),api/llm-contextandapi/sparkline/[slug](already at the 300 s data-cache floor, no room without adding real staleness),api/search/featured(60 s matches the worker's 60 s rewrite of the blob exactly, verified atworker/index.ts:399, which runs every sweep), andsitemap.xml(3600 s, and its conditional header must stay conditional so a fallback stub is never cached for an hour).The one route that is genuinely uncached
/api/aggregatereturns MISS on every request, back to back, and is the most expensive item in the set: a 7.3 MB / 3.4 MB gzipped body,maxDuration = 90, an 18-40 s Paris-to-IAD1 transfer buffered in a billed function, fetched by every Vercel function that needs the aggregate. A proxy whose entire purpose is to put the VPS blob behind Vercel's CDN is currently doing the opposite.Not fixed here, because the safe fix is not obvious. It is under both documented CDN size caps (10 MB non-streaming, 20 MB streaming), so size alone does not explain it. The response carries no
Content-Length, meaning it streams, and Vercel's cacheability criterion is written as "response doesn't exceed 10MB in content length", which a streamed response cannot satisfy. SettingContent-Lengthexplicitly is a one-line change and the handler already holds the full buffer, but it risks tripping the 4.5 MB non-streaming function response cap and taking down the path every bench page depends on. That wants the runtime-log cache reason read first and a staging test, in its own PR.Savings
Deliberately not given as a dollar figure. The cut in origin cache fills is exact and multiplicative per route: 5x on the seven 300 s routes, 30x on the two builder proxies, 12x on the snippet. Turning that into invocations needs the per-route request mix and PoP spread from Vercel analytics, which I do not have, and two things would make any number I invented false. 17 of the 24 routes were already mostly cache hits, so the base is far smaller than an uncached premise suggests; and the smoke-sampling and staging-warm changes now in flight have already moved the runtime cost picture. The honest claim is a several-fold cut in origin fills on ten endpoints, with the largest single line item,
/api/aggregate, still untouched and specified above as the next piece of work.Checks
pnpm validate(241 specs),pnpm typecheck,pnpm test(367 pass / 0 fail) all green.pnpm lintreports 5085 errors, identical with and without this branch (pre-existing, see CI onmain); the changed files lint clean on their own.