Skip to content

28 hub share cards were being generated and never used - #2738

Merged
Flotapponnier merged 1 commit into
devfrom
fix/hub-og-cards
Sep 30, 2026
Merged

Flotapponnier merged 1 commit into
devfrom
fix/hub-og-cards

Conversation

@Flotapponnier

Copy link
Copy Markdown
Collaborator

pageMetadata pinned openGraph.images and twitter.images to the root /opengraph-image and /twitter-image. The comment above it called this 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 was built, is served at its own URL, and was named in no meta tag. Checked on production: /perps/opengraph-image, /rpc/opengraph-image and /team/opengraph-image all answer 200 with a distinct PNG while the pages advertise the root one. /capital shipped a 78 KB card this week that nothing has ever pointed at.

Found by an SEO audit of today's diff.

Two things the plan had wrong, found by rendering the pages

I verified this in a dev server rather than reasoning from the precedence docs, and both assumptions I started with were wrong.

1. 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. Three callers are in that position, so each gains a file:

Route Card it now uses Before Net
/rwa site card (re-export) site card identical, 62,240 B both sides
/rpc-map site card (re-export) site card identical, 62,240 B both sides
/perps/[asset] perps hub card site card upgrade, right subject

So this is dedupe with no visible change on two of them, and an improvement on the third.

2. Route segment config cannot be re-exported. export { revalidate } from "../opengraph-image" fails with Next.js 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 comment saying why so nobody tidies them back into the re-export.

Verified after the change

Dev render, og:image and twitter:image both pointing at the route's own segment on /rwa, /rpc-map, /perps/eth, /capital, /team and /rpc, each with that card's real alt text and dimensions. All five image routes answer 200 image/png: /capital 78,895 B, /perps/eth 66,985 B, /rwa and /rpc-map 62,240 B, root 62,240 B.

pnpm typecheck, eslint and bun test src/ (368 tests) clean.

Note for the next hub

Every caller of pageMetadata now needs an opengraph-image.tsx in its own segment. Adding a hub without one ships it with no share card and nothing will complain, so that is stated in the helper's comment.

Bench pages, product pages, compare pages and chain pages were never affected: they build metadata without openGraph.images and have always used their own cards.

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.
@Flotapponnier

Copy link
Copy Markdown
Collaborator Author

Status change worth flagging: the 2026-09-30 release put /capital on production while this was open, so this is now production-facing rather than staging-only.

Confirmed live on production just now:

  • https://openchainbench.com/capital emits og:image = https://openchainbench.com/opengraph-image, the root card.
  • https://openchainbench.com/capital/opengraph-image answers 200 image/png, 78,895 bytes, and nothing on the site references it.

So the bespoke card that shipped with the hub this week is being generated and served on production and has never appeared in a meta tag. Same for the other 27.

@Flotapponnier
Flotapponnier merged commit 81dd013 into dev Sep 30, 2026
2 checks passed
@Flotapponnier
Flotapponnier deleted the fix/hub-og-cards branch September 30, 2026 12:53
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant