Skip to content

fix(reputation,qos): stop health-check probes benching endpoints, plus cardinality/solana/archival follow-ups - #528

Open
oten91 wants to merge 21 commits into
mainfrom
fix/cardinality-followup-f5-f6
Open

fix(reputation,qos): stop health-check probes benching endpoints, plus cardinality/solana/archival follow-ups#528
oten91 wants to merge 21 commits into
mainfrom
fix/cardinality-followup-f5-f6

Conversation

@oten91

@oten91 oten91 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #527. The branch grew past its original scope: it now carries the F5/F6
cardinality follow-up plus four other lines of work that were deployed and validated
together over the last two weeks.

Every commit here is live on canary and mainnet as sha-064bc62-rc and has been
validated in production.
That is the main argument for reviewing it as one unit —
these commits were never exercised separately, so splitting them would put untested
combinations into main.


1. Metric cardinality (the branch's original purpose)

e3f5c7e2 — bound histogram labels, remove supplier from aggregate metrics.

Two rounds of a production cardinality incident converged on one rule: only a label's
value set bounds it.
A sanitizer bounds a value's shape, never the set. A cardinality
guard bounds the live registry, never the number of distinct series Prometheus retains —
path_supplier_signal_total sat at 26% of its cap while being one of the two largest
series sources in the job (6,523 tuples live in 10 minutes against 60,674 distinct over one
pod's 7.7h life).

A label on a histogram costs roughly 12× what it costs on the counter beside it.
path_relay_latency_seconds_bucket was 31.6% of all gateway series because it carried
status_code × reputation_signal, a 20× pair no dashboard ever queried from the
histogram. Outcome taxonomy now lives on the counter; the histogram keeps topology labels.

Per-supplier questions are served by GET /ready/<service>?detailed=true — a point lookup
instead of ~74K retained timeseries. Three metrics keep supplier deliberately, where the
address is the actionable payload rather than a way of naming an operator.
Test_SupplierLabelIsGone enforces the rest.

2. Solana sync allowance and health checks

2304bc9b 2d49d8e5 ad729c3f 5c1c8d60 0e856cc4 068ff990

Solana's ValidateEndpoint had no sync allowance at all — sync_allowance: 750 was
configured but never implemented. During the 2026-08-18 surge (200 → 2500 rps, ~2.5
blocks/s) only the freshest endpoint stayed valid, producing near-total lock-in on one
operator while others sat at score 100 with no traffic. Health checks cannot correct this;
they run at the same per-endpoint rate on every operator.

Also here: kava CometBFT checks routed to comet_bft, slower xrplevm websocket probes, a
missing solana health observation no longer scored as a fault, and relays that never
received an HTTP status no longer counted as successes.

3. Go, dependencies, CI

d3e0273c e6e5530e fa146acd c10e5be6 — poktroll v0.1.35, Go directive to 1.26.6 for
the stdlib security fixes, drop the libsecp CGO variant, build release platforms
concurrently.

The Go bump takes govulncheck from 12 reachable findings to 6, all of which are
Fixed in: N/A (three withdrawn lib/pq advisories, openpgp, two cosmos x/crisis init-only).
The net/http CVE was genuinely reachable via router.go ListenAndServe. The Dockerfile
Go patch is deliberately left floating so future patches are picked up; the directive sets
the floor.

4. Heuristic and reputation detectors

dd710d38 4931c6f4 6ef6ca1a 22e9dff1

An endpoint returning zero-length payloads at ~0.2% of its traffic held a reputation score
of 100 all day, and neither existing mechanism could reach it:

  • Additive scoring is outvoted by volume. At one violation per 1000 requests the endpoint
    earns +998 and loses −25, so the score returns to its ceiling however long the behaviour
    continues. Raising the per-event penalty to FATAL (−50) does not change the sign.
  • The critical-rate detector is tuned for "unambiguously broken" (30%), and more
    fundamentally CriticalRateEWMAAlpha's ~20-request memory cannot represent a sub-1%
    rate at all
    — the EWMA can only be 0 or ~0.05 there. No threshold change to that
    detector could have worked.

So 22e9dff1 adds a second detector rather than retuning the first, with a much longer
window (alpha 0.001, ≈1000 requests) and a threshold three orders of magnitude lower.

Two classifier bugs fixed alongside it: a "(method=X)" suffix made every exact-match case
in classifyHeuristicErrorAsSignal unreachable, so an empty payload degraded to
unknown_payload_error (MINOR) — only the HasPrefix("error_indicator_") case survived,
which is why it hid. And getProgramAccounts returning an empty array is a valid success,
not a fault.

5. Archival promotion and health-check contamination (today)

54659fb6 — geth PBSS pruned state

Geth's path-based state scheme reports metadata is not found, <block>. Every archival
pattern in PATH used hash-based-scheme wording (missing trie node, state has been pruned), so a PBSS node's honest "I do not retain that state" matched nothing, at four
separate sites.

The deeper defect: IsArchival returned true for any successful eth_getBalance /
eth_call / eth_getCode / eth_getStorageAt / eth_getTransactionCount without
reading the block parameter
. Those are also the ordinary way to read current state, and a
pruned node answers them perfectly — so the archival pool was polluted by construction and
marked archival for 8h. targetsHistoricalBlock now gates the promotion.

Known residual: the DataExtractor interface carries no perceived chain tip, so a numeric
block a few blocks back still reads as archival. Closing that needs an interface change
across all four extractors.

d8f4c3c1 — health-check probes were feeding both rate detectors

Both volume-independent rate detectors are wrapped in if !signal.IsHealthCheck, so a
probe cannot bench an endpoint on its own — a strict or flaky check must not cool an
endpoint that serves user reads perfectly. That guard was intact. The stamp was not.

IsHealthCheck was set at only three call sites, all in the health-check executor. One
probe also reaches reputation through the protocol layer twice more — the relay itself via
requestContext, and Apply{HTTP,WebSocket}Observations on that same relay's observations
— and neither stamped it. The field doc on requestContext.isHealthCheck stated outright
that it "does not affect reputation signals or observations", which is why the omission
read as deliberate.

The result is a self-sustaining loop rather than a one-off penalty: a benched endpoint
receives no user traffic, so probes become its only signal, so its rate EWMAs are entirely
probe-derived, so it re-benches itself on the next probe failure.

This predates the new detector. The critical-rate detector has been contaminated since it
shipped; 22e9dff1 only made it visible by tripping at a much lower threshold.

Apply{HTTP,WebSocket}Observations now take isHealthCheck as a required parameter rather
than defaulting it — the receiving protocol layer cannot distinguish synthetic observations
from real ones, so each caller states it at compile time.

Scope is narrow on purpose: only the rate detectors exclude probes. A probe still moves the
additive score — that is how a benched endpoint recovers when it receives no user traffic —
and still increments the counters, so no rate's denominator changes shape.
TestHealthCheckSignals_StillMoveTheAdditiveScore guards that.

064bc628 — each rate cooldown escalates against its own history

Score.InvalidRateCooldownCount is documented as kept separate from RateCooldownCount so
the two detectors escalate independently. The counters were separate; the timestamp they
escalated against was not — both compared against the shared Score.CooldownUntil, which
the strike system also writes.

Note the sign: time.Since() on a cooldown still in force is negative, hence always below
DefaultMaxCooldown. Any bench in force, from any mechanism, made the next trip of either
detector read as consecutive.

Each detector now records the end of the cooldown it set and escalates against that.
CooldownUntil is unchanged and remains the only field selection reads.

b395d183 — the "historical state" pruned-state wordings

Found by probing rather than by reading. Sending a block 27M deep to endpoints PATH had
marked archival returned two wordings that missed every pattern in
archivalErrorIndicators by a single word:

gnosis: "historical state is not available"   -- "state not available" misses on "state IS not"
poly:   "historical state <hash>"             -- "historical data" misses on "historical STATE"

Both fell through to the "some other error" branch, which returns an error rather than
false, so an endpoint that had just failed an archival query was never demoted out of the
archival pool. The bare "historical state" prefix covers both, and is already present in
qos/heuristic/indicators.go — the two catalogues had drifted, so this realigns them.

31617122 — an unverified archival mark no longer outlives a verified one

The two sources of archival status had drifted 16× apart, in the damaging direction:

health-check mark   30m
user-traffic mark    8h

The health-check path pins an exact expected historical value in the rules file, so a node
that ignores the block parameter and answers from current state fails it. The user-traffic
path cannot pin a value — the query is whatever a client sent — so it grants archival status
on any successful archival-method call, and 54659fb6 notwithstanding it still trusts a
successful response, which is what a fabricating node always produces.

Measured in production: four endpoints on one operator were marked archival while returning
current state for every block asked, including one 256× past the chain tip. The archival
health-check rule for the service they served had been deleted for failing every endpoint —
which was the rule working correctly, that service has no archival nodes — leaving only the
unverified 8h path to promote them.

Both paths now share gateway.ArchivalStatusTTL. The old comment on the 8h constant claimed
it "matches health check archival TTL"; it did not, and the false comment is probably why
the drift went unnoticed. The test reads the stored expiry back through
UpdateFromExtractedData rather than comparing constants, so re-hardcoding a duration at
the call site fails it.

Bootstrapping is unaffected: promotion still happens via requests naming a numeric block
within the archival-required threshold, which route freely rather than being filtered to
already-archival endpoints.


Production validation

Deployed to canary at 07:18 UTC and mainnet at 08:57 UTC on 2026-08-20. Because the two
environments flipped at different times, the same metric collapsing twice — each time
following the build — rules out pod age and traffic composition.

before after
mainnet rate_cooldown_total 0.178/s 0.0006/s (297×)
canary rate_cooldown_total 0.005/s 0.0012/s
pool_collapse_guard{solana} on canary 143,090 / 14h ~35 / 85m
invalid-rate trips 10 services solana only, both envs

Guardrails flat throughout, judged against 6h ranges rather than point readings: fleet
success 95.7% mean (min 92.5, max 97.3), solana pool size unchanged, no cooldown spike.

Redis confirms the escalation fix directly. Mainnet DB2 went from 0 of 5,901 keys
carrying rate_cooldown_until to 292 of 297 sampled within five minutes of the flip, and
two solana endpoints that tripped post-deploy each recorded invalid_rate_cooldown_count = 1 with their own timestamp — a first offence, unescalated. For contrast, the pre-flip
distribution had 84 keys above zero with 35 at ≥ 6, i.e. benched the full hour every
time, topping out at 98.

Archival counts from /ready/<svc>?detailed=true moved as intended. The old build pinned
five services at exactly 100% — every endpoint of every operator archival, which is not a
plausible ground truth. The fixed build discriminates: on gnosis it keeps qspider.com at
12/12 while dropping two other operators to 0/17 and 4/17, where the old build had all
three at 100%.

Testing

go build, go vet and golangci-lint clean. Unit tests pass; reputation/storage needs
Docker for testcontainers-Redis and fails without it.

Every call site in the two reputation fixes was revert-checked individually — seven reverts,
seven confirmed test failures. Two traps found while writing those tests, both recorded in
comments:

  • The first escalation test passed against the revert. A fresh key plus a foreign bench
    in force does not discriminate, because incrementing a zero counter yields 1 — exactly
    what a correct reset yields. The discriminating case needs stale non-zero history and a
    foreign bench.
  • runAtRate(4000, 100) trips the detector five times, not once: a trip resets the EWMA and
    the loop continues. Any first-offence assertion has to drive one signal at a time and stop
    at the first trip. The pre-existing test never noticed because it only asserted
    IsInCooldown().

Tests assert on the signal reputation receives, from the production caller, rather than on
the flag the caller set — the flag was already true and proved nothing.

CI note: the xrplevm HTTP E2E check has been failing on this repo independently of this
branch. Please confirm it also fails on main before treating it as a blocker here.

Known open items

  1. moonbeam genuinely has no archival nodes. Answered by direct probing:
    moonbeam_archival was deleted from the rules file on 2026-08-07 for failing 100% of
    runs — which was the rule working correctly — and the single endpoint still marked
    archival there is one that ignores the block parameter. Impact is small: moonbeam draws
    1.7–3.5 archival_required rejections/s against poly's 761/s.
  2. Endpoints that ignore the block parameter cannot be detected by any success-based
    check
    , including the targetsHistoricalBlock gate added in 54659fb6, which verifies
    the request named a historical block and then trusts a successful response. Measured on
    one operator across two services: identical balances at block 1, block 5,000,000 and
    block 4,294,967,295. A design for detecting this is written up in
    DESIGN_NEGATIVE_HEALTH_CHECK.md and deliberately parked — the two commits above
    cover most of the exposure without a new check type. The cheapest future detector is
    eth_getBlockByNumber(H) asserting result.number == H, which is self-verifying and
    needs no external reference.
  3. Only 22 of 69 services have an archival health-check rule. On the rest, archival
    status comes exclusively from the unverified user-traffic path.
  4. poly_archival and xrplevm_archival assert expected_response_contains: "0x0", a
    substring matching a large share of hex values. Weak, and in the external rules file
    rather than this repo.
  5. InvalidRateThreshold = 0.005 was sized from one hour of data with a contaminated
    denominator.
    Now that the denominator is honest it fires ~31/hour on solana, correctly
    confined but not near-silent. Worth re-deriving from the real per-key rate distribution.
  6. User relays record a reputation signal twice — once in context.go and once via the
    observation path. Pre-existing double-count, not addressed here.

oten91 and others added 21 commits August 13, 2026 00:09
…om aggregate metrics

Follow-up to #527. PNF re-measured at 7.6h of pod age and found the per-pod
series accumulation rate statistically unchanged (40k/day/pod vs 37k pre-fix) —
the fleet improvement came from the rollout resetting every registry plus a
10 -> 6 replica cut, not from the growth stopping. Two findings, both confirmed
against production Prometheus before changing anything.

F5 — a label on a histogram costs ~12x what it costs on the counter beside it.

path_relay_latency_seconds_bucket was 341,840 series fleet-wide, 31.6% of the
entire gateway job and its largest source of ongoing growth. It carried
status_code (5 values) x reputation_signal (4) — a 20x pair, multiplied by ~12
series per tuple — and nothing queried either FROM THE HISTOGRAM: all eight
dashboard histogram_quantile expressions aggregate to at most
(domain, service_id, rpc_type, le), request_type appears only as a selector, and
no Prometheus rule references it.

Both labels are dropped from the histogram and kept on relays_total, which
carries the full outcome taxonomy at 1 series per tuple; join on
(domain, rpc_type, service_id, request_type) to correlate the two.

Measured on one mainnet pod: 2,731 tuples -> 1,061, so 27,310 bucket series ->
10,610. The ceiling matters more than the immediate drop. The live
(domain, service_id, rpc_type) universe is 403 combinations, so the tuple
ceiling was 403 x 4 request_type x 20 = 32,240 (~322K bucket series/pod) and the
metric was still climbing toward it (2,226 -> 2,732 tuples over 6h). Post-fix
the ceiling is ~1,600 tuples, ~16K series/pod: 20x lower.

F6 — a cardinality guard bounds the live registry, not the series stream.

Six metrics carried a raw supplier label, 303,309 series in a 10-minute window.
The supplier set is ~5,200 on chain, grows with the network rather than with our
traffic, and rotates every session, so these metrics minted multiples of their
live count in distinct series every day. Measured on one pod over 7.7h, live in
a 10m window vs distinct over the pod's life:

  supplier_reputation_score     4,510 -> 74,639   16.5x
  qos_filter_rejection_total    1,271 -> 24,708   19.4x
  supplier_signal_total         6,523 -> 60,674    9.3x
  hedge_supplier_outcome_total  4,306 ->  7,840    1.8x
  supplier_blacklist_total        806 ->    806    1.0x
  relay_latency_bucket         27,320 -> 27,320    1.0x  (control)

Two of these were guarded and honored their guards throughout. That is the point
of the finding: a metric can sit at 26% of its cap forever and still be among
the largest things in the TSDB. Eviction is not the cause and removing it would
not help — re-admitting an evicted tuple recreates the same label set, hence the
same series with a gap, never a new one, so the distinct-series count is
identical either way. Eviction only decides whether the cost also lands on pod
heap. Nothing on the registry side can bound this; only the label's value set
can.

  - path_supplier_reputation_score: REMOVED. Zero dashboard references, zero
    Prometheus rules. The per-operator reading already ships as
    path_reputation_mean_score (403 series/pod, 1.0x churn) and the per-supplier
    one as GET /ready/<service>?detailed=true, which returns score, strikes,
    latency, tier and cooldown per endpoint. Its publisher walked every
    service's sessions and did a GetScore per endpoint every 10s to feed it.
  - path_supplier_signal_total: REMOVED. Zero references. Its cardinality had
    already been cut once by collapsing 8 signal types to 3 severity classes,
    which fixed the multiplier and left the base — the base was the problem. The
    full taxonomy, not the collapse, is on relays_total's reputation_signal.
  - path_qos_filter_rejection_total: supplier -> domain. Fires ~9,500/s
    fleet-wide with the worst churn ratio of any gateway metric. Now bounded by
    (domain x service_id x reason).
  - path_hedge_supplier_outcome_total: supplier -> domain. Hedge asks an
    operator-level question; ~26 series/pod.
  - path_supplier_blacklist_total: supplier dropped, keeps its existing domain.
    The address is still in the WARN log at the call site.
  - path_supplier_exhausted_total: unchanged, 313 series fleet-wide. The
    allowance it reports is per (supplier, session), so the supplier is the
    subject rather than a way of naming an operator. Same for
    supplier_nil_pubkey_total and supplier_pubkey_cache_events_total, both zero
    series in production.

Testing

Every fix was revert-checked. Two tests were wrong on the first pass and both
were caught that way:

  - A registry walk for the supplier label passed on revert, because Gather()
    reports the labels of CHILD series and an unpopulated vec reports none. It
    now emits through each production Record* helper first and asserts the
    population happened.
  - The removed-metric test had the same hole; it now detects re-registration by
    collision instead, so a re-added vec that nothing populates is still caught.

A supplier -> domain re-key compiles silently when the call site keeps passing
the address — both are strings and the label name is right. The call-site tests
therefore assert on the emitted label VALUE through the production caller
(basicEndpointValidation, recordWinner/recordLoser): a bech32 address sanitizes
to the supplier_addr sentinel, which is the tell. Both revert-checked.

Also removes reputation.supplierFromKey, dead with its only caller.
…levm websocket probes

kava's `health` and `status` are CometBFT methods but were declared `type: json_rpc`.
The check type selects which of an endpoint's per-rpc-type URLs the relay targets, so
on a dual-stack chain both were delivered to the EVM JSON-RPC port, which answered
-32601 "the method health does not exist/is not available". `status` carries
critical_error, so kava's json_rpc mean score sat at ~10.1 while rest read 85.7. This
was a rule defect scored against the endpoints, not an endpoint fault.

comet_bft read 100 for the opposite reason: no check in this file was typed comet_bft,
so that rpc type was never exercised. 100 means never penalized, not healthy.

kava now checks each rpc type on its own transport: json_rpc gets eth_blockNumber
(sync_check) and eth_chainId asserting 0x8ae; comet_bft gets health and status; rest
keeps syncing. `status` opens at major_error rather than critical because comet_bft has
never run on any service here, and opening at -50 with cooldown on an unexercised rpc
type would mass-cool endpoints over a rule defect. Escalate once the false-positive
rate is known, matching how the websocket checks were staged.

Verified live before asserting the chain ID: evm.kava.io eth_chainId -> 0x8ae (2222),
eth_blockNumber -> 22100394; rpc.data.kava.io latest_block_height -> 22100395. EVM
height tracks CometBFT height 1:1, so the single perceived height per service is valid
for both sync checks.

Separately, xrplevm 10s -> 30s and xrplevm-testnet 15s -> 30s. Websocket checks are the
one check type not covered by backend-URL dedup, so an operator holding N registrations
behind one node URL takes N connect/close cycles per interval against the same socket
server, per environment. An operator reported the resulting flood; the log lines carry
our probe's own close reason and a 1000 Normal close, and their websocket server logs
every disconnect at Error level regardless of code. Nothing is failing. Interval is the
only lever available from config; the structural fix is extending backend-URL dedup to
websocket probes whose siblings share an identical websocket URL.

Also moved a stray comment that made xrplevm's websocket check read as hyperliquid's.
No behavior change; the file parses to the same 69 services and passes
ServiceHealthCheckConfig.Validate() before and after.
… endpoint selection

Solana's ValidateEndpoint compared an endpoint's block height against the perceived
height with no tolerance at all:

    if endpoint.BlockHeight < s.perceivedBlockHeight { ... reject }

The perceived height is a MAX over endpoint observations, and Solana produces a block
roughly every 400ms. Under a strict comparison only the most recently observed endpoint
can ever be valid: every other endpoint's newest report is, by construction, older than
the one that just raised the bar.

That closes a starvation loop. An endpoint carrying user traffic re-reports its height
continuously and stays valid; an endpoint refreshed only by health checks (~0.35/s per
endpoint against ~2.5 blocks/s) trails permanently and is filtered out, which denies it
the traffic that would have refreshed it. Observed in production on 2026-08-18 during a
~10x traffic surge: the selection pool collapsed to a single operator
(path_selection_pool_operators = 1.00, against 6.77 on eth) and stayed there while
alternative endpoints held a reputation score of 100, zero cooldown and tier 1 and
received no traffic at all. The per-operator concentration cap could not help — with one
operator in the pool there is nothing to reshape.

Health-check tuning cannot fix this. Both operators already receive the same per-endpoint
check rate, and outrunning the chain would need more than one check per endpoint per
block; even then the max-plus-strict-comparison race is re-lost every block.

The allowance was already configured for the service and already consumed by the health
check's own sync check. The health check executor applies it by asserting the QoS
instance to `interface{ SetSyncAllowance(uint64) }`, which EVM, CosmosSDK and NoOp
implement and Solana did not — so the configured value reached one consumer and was
silently dropped for endpoint selection.

Changes:

- ServiceState gains an atomic syncAllowance plus getSyncAllowance/SetSyncAllowance,
  promoted to the Solana QoS through the embedded *ServiceState (the same shape as
  SetMaxOperatorShare on *EndpointStore).
- ValidateEndpoint uses qos.MinAllowedBlockNumber, and takes the endpoint address so a
  rejection can be attributed.
- defaultSolanaBlockNumberSyncAllowance = 750 (~5 minutes of Solana). Unlike EVM and
  CosmosSDK, 0 means "not configured" and falls back to the default rather than
  disabling the check: defaulting to 0 would restore the strict comparison during the
  startup window and whenever external rules fail to load.
- Solana now records path_qos_filter_rejection_total (block_height_lag,
  block_height_unknown, invalid_response), keyed on domain and computed lazily so the
  passing path does not parse an address on every endpoint of every selection pass.
  Previously this exclusion was reported only through a Warn log, invisible at the log
  level production runs.

Tests assert through SelectMultipleWithArchival rather than on the state fields, and the
exclusion cases keep one endpoint valid on purpose so the least-stale fallback does not
run and mask the result. Both revert checks were performed: restoring the strict
comparison fails the trailing-endpoint tests, and un-exporting SetSyncAllowance fails the
interface-reachability test.

The perceived-epoch comparison is left strict. It has the same shape, but epochs turn
over roughly every 2.5 days rather than every 400ms.
…uccesses

path_requests_total is recorded per endpoint observation, from the backend's HTTP status.
When a relay never receives one the observation carries 0, and that was unconditionally
defaulted to 200:

    statusCode := int(endpointObs.GetEndpointBackendServiceHttpResponseStatusCode())
    if statusCode == 0 {
        statusCode = 200 // Default success
    }

Status 0 is two different outcomes. With no error set the relay succeeded and the status
simply was not recorded. With an error set the relay failed before any HTTP status could
exist: a timeout, a refused or reset connection, an unreachable host, a signature or
payload validation failure. Collapsing both onto 200 counted every transport failure as a
success against the endpoint that produced it.

The distortion is largest exactly where it matters most. Measured on solana 2026-08-18: an
operator generating 242 relay errors/s — 5 second timeouts, path_relay_latency_seconds P95
of 7.6s — reported 0/s non-200 here and read as roughly 95% successful, while an operator
returning honest HTTP error codes in 50ms read as roughly 43%. The supplier-quality panel
ranked the two backwards, and any alert keyed on this metric was blind to the failure mode
that hurts users most: an endpoint that accepts the connection and then never answers.

A relay with no HTTP status and an error set is now recorded under a distinct "error"
status category rather than 200. That is the vocabulary path_relays_total already uses for
the same outcome, so the two metrics can be compared without a translation table; the
literal is promoted to metrics.StatusCategoryError and the existing relay and health-check
call sites now share it.

"error" is deliberately not folded into 5xx. A backend answering 500 is reachable and
answering; one that never answers is not, and the two call for different responses.

The relay's own domain attribution was already correct — it comes from the endpoint URL of
each individual endpoint observation, not from the request's primary supplier.

Dashboards and alerts that compute a success rate as 200 over total will move for any
service whose endpoints time out. That movement is the correction, not a regression.

Tests drive processEndpointObservation itself, since the defect was in how that function
derives the label — a test supplying its own status would prove nothing. Revert-checked:
restoring the default-to-200 fails
Test_RequestStatus_TransportFailureIsNotCountedAsSuccess.
…d value

The fallback was set to 750 on the basis that it matched the sync_allowance configured for
solana. It does not — pnf_path_rules.yaml carries 1500 (the 750 in the comment there is the
original 5-minute formula, since superseded).

The whole point of the fallback is that an unloaded config behaves like a loaded one, so a
mismatch defeats it: a pod that had not yet loaded external rules would apply a tighter
bound than the same pod a second later, and tighter here means endpoints silently leaving
the selectable pool.

Also records what the value now costs to change. It was sized as a health-check gate, where
being generous only risks probing a stale endpoint; it now also decides which endpoints are
selectable, so lowering it is a routing change rather than a check-strictness change.
…validator reads

Solana's endpoint validation requires a getHealth observation and a block height. The
health-check pipeline supplied neither, so an endpoint whose observations came only from
health checks was rejected as never-observed — and rejected means no user traffic, which
was the only other source of those observations. The configured probes ran, passed, and
populated nothing that selection consults.

Two independent reasons the pipeline produced nothing:

1. UpdateFromExtractedData never set SolanaGetHealthResponse. Health checks reached QoS
   through the generic ExtractedData path, which carried a block height and no health
   status, so errNoGetHealthObs was permanent.

2. ExtractBlockHeight only parsed the getEpochInfo shape (`result.blockHeight`), while the
   configured probe is getBlockHeight, whose result is a bare number. That response was
   unparseable, so health checks contributed no block height either.

Changes:

- ExtractedData gains SyncCheckPerformed, mirroring ArchivalCheckPerformed. Without it,
  "not syncing" (the zero value) is indistinguishable from "never checked", which is
  exactly the distinction needed to record a health observation rather than assume one.

- Solana's IsSyncing is gated on the request naming getHealth. It previously ran every
  response through a `result == "ok"` test, so a getBlockHeight response — a bare number —
  was reported as SYNCING. With SyncCheckPerformed derived from whether IsSyncing errors,
  an ungated version would also mint a health observation out of a response containing
  none, which is worse than having no observation at all.

- ExtractBlockHeight accepts a bare numeric result when the request names getBlockHeight.
  Gated on the method, which is what separates it from the absoluteSlot fallback the
  surrounding comment forbids: that one guessed at a field inside a getEpochInfo result and
  guessed the slot. Accepting a bare number from any response would reopen the poisoning
  hole, since getSlot answers with a bare number too.

- UpdateFromExtractedData records the health observation when the response was a getHealth
  response, and no longer returns early when a response carries health but no block height.
  The per-endpoint Redis write is guarded on having a block height — a health-only
  observation carries 0, and writing that would clobber a real height across every replica.

- Epoch 0 is no longer fatal, in validateBasic or in ValidateEndpoint. The only source of a
  real epoch is a getEpochInfo response from user traffic; the health-check path leaves it
  at 0 by construction. Treating that as invalid recreated the same trap: an endpoint
  benched for a field nothing routinely supplies, and therefore never given the traffic
  that would supply it.

- The epoch comparison gains one epoch of tolerance. Same max-versus-strict shape as the
  block height check: perceivedEpoch is raised by whichever endpoint reports first, so at a
  rollover every other endpoint is briefly an epoch behind through no fault of its own.
  Epochs last roughly 2.5 days, so this costs almost nothing and removes a cliff that would
  otherwise empty the pool for a few seconds every couple of days.

- Filter rejection reasons are split: health_unknown, unhealthy and epoch_lag join the
  existing set. The first version folded "no health observation" and "no epoch info" into
  one block_height_unknown bucket, so telling them apart required inferring from the
  ABSENCE of a sibling series — the diagnosis that mattered rested on a negative.

Tests drive the real pipeline (ExtractedData.ExtractAll then UpdateFromExtractedData) rather
than hand-filling the endpoint struct, since the defect was in which fields that pipeline
populates. Revert-checked four ways: removing the health record, the getBlockHeight shape,
the IsSyncing method gate, or the epoch tolerance each fails its own tests.
poktroll v0.1.35 declares `go 1.26.5` (v0.1.34 declared 1.25.8), so the module's own go
directive has to move with it. Transitives pulled in by the upgrade: shannon-sdk to
20260812141256, x/crypto 0.53 to 0.54, x/sync 0.21 to 0.22, x/sys 0.46 to 0.47, x/term 0.44
to 0.45, x/text 0.38 to 0.40, plus santhosh-tekuri/jsonschema/v6 as a new indirect.

CI needs no change: every workflow step resolves its toolchain with `go-version-file: go.mod`
rather than a pinned version, so all five follow the directive automatically. 1.26.5 is a
released toolchain, so setup-go can resolve it.

Docker needs no change either, but only by luck of tagging. Dockerfile, Dockerfile.race and
Dockerfile.local all build on the floating `golang:1.26-alpine` tag, which currently resolves
to 1.26.6 and therefore already satisfies the new directive. Dockerfile.release and
Dockerfile.release.glibc have no Go builder stage at all — they consume a prebuilt binary on
top of alpine and distroless respectively.

makefiles/debug.mk was pinned to golang:1.25-alpine, a full minor behind everything else, and
is bumped here for consistency. To be clear about what this is NOT: that target runs
`go tool pprof` against a remote pprof endpoint inside a throwaway container and never
compiles this module, so the go directive does not apply to it and the pin was not going to
break the upgrade. It was simply already stale.

portal-db/sdk/go (go 1.22.5) and its example (go 1.23) are left alone: separate modules, no
poktroll dependency, not built by the root CI Go steps, and versioned independently via
auto-version-bump.yml.

Build, vet and the full unit suite pass. The only failures are the Redis testcontainer tests,
which require a local Docker daemon and fail identically before this change.
… fixes

Supersedes the 1.26.5 directive set in the previous commit. poktroll v0.1.35 requires
1.26.5, which is what that commit matched; 1.26.5 is also the last release before seven
stdlib advisories were fixed.

Fixed in 1.26.6 (also 1.25.13 and 1.27.0-rc.3):

  CVE-2026-56853  net/http      ReadHeaderTimeout not applied during the unencrypted
                                HTTP/2 check
  CVE-2026-56860  net/url       quadratic complexity in resolvePath
  CVE-2026-56862  crypto/tls    unbounded post-handshake messages
  CVE-2026-46600  net           panic parsing an invalid SVCB or HTTPS RR
  CVE-2026-33818  encoding/asn1 unbounded recursion depth
  CVE-2026-56859  encoding/xml  unbounded recursion depth
  CVE-2026-56858  html/template Javascript regexp context tracking

This is not theoretical for this repository. govulncheck against 1.26.5 reports twelve
vulnerabilities that our code actually calls, with traces landing in our own files:

  net/http ReadHeaderTimeout  router/router.go:182 Start -> http.Server.ListenAndServe
  x/net/idna punycode         network/http/http_client.go:195 SendHTTPRelay -> Client.Do
  encoding/asn1 recursion     gateway/health_check_executor.go:470
  crypto/tls                  websockets/connection.go:187, reputation/storage/redis.go:66,
                              network/http/http_client.go:195

The first is the public listener, so the affected path is the one carrying production
traffic. These are denial-of-service rather than remote execution, but they sit on the
request path of an internet-facing gateway.

No other file needs to change. CI resolves its toolchain from this directive via
go-version-file, and the Dockerfiles build on the floating golang:1.26-alpine tag, which
already resolves to 1.26.6.

Deliberately NOT pinning the Dockerfiles to an explicit patch version. A floating minor tag
picks up 1.26.7 and everything after it automatically, whereas a pin freezes the build on a
known-vulnerable toolchain until somebody remembers to move it. The correct pairing is a
floating tag for the ceiling and this directive for the floor, so a too-old toolchain is a
hard error rather than a silent vulnerable build.

Unaffected by this change, and pre-existing: govulncheck also reports golang.org/x/crypto/
openpgp (GO-2026-5932) and two cosmos-sdk x/crisis findings (GO-2023-1881, GO-2023-1821),
all with no fixed version. They reach us only through init() chains in the Shannon and
cosmos dependency tree, and the x/crisis pair are consensus-module bugs a gateway never
exercises.

Build, vet and the unit suite pass under the 1.26.6 toolchain.
CGO with the ethereum_secp256k1 (libsecp) build tag was measured on mainnet in June and
rejected: fleet CPU per relay +38%, p999 +45%, threads per pod +48%, success rate down 2.6
points, 5xx roughly doubled. Root cause is upstream poktroll #1822 — cgocall pins an OS
thread, so under this gateway's goroutine concurrency the thread count balloons and
scheduler contention swamps libsecp's faster field representation. Nothing has been deployed
with it since; every running image is the CGO-disabled build.

CI nevertheless kept building it on every push, and kept pushing it to the registry as
sha-<x>-rc-cgo, latest-cgo and the semver -cgo tags. A binary measured as a significant
regression therefore sat in ghcr one deploy-tag typo away from production, which is the more
important half of this change.

Measured cost of the removal, from the timestamps in a real build log:

  Install cross toolchains for CGO      4m47.0s   (apt gcc-aarch64-linux-gnu + libc6-dev-arm64-cross)
  CGO=1 linux/amd64                     1m39.5s
  CGO=1 linux/arm64                     1m43.2s
  Build and push Docker image (cgo)     0m16.0s
                                        -------
                                        8m25.7s

The build job drops from 13m53s to roughly 5m27s. Separately, the unit test job stops
running the whole suite a second time under -tags ethereum_secp256k1, which was 7m44s of its
17m54s.

Note the two workflows are independent and main-build does not run on pull requests, so a PR
sees the 7m44s and a push to main sees both. These are not additive into a single
wall-clock number.

Scope is deliberately narrow. This removes the libsecp variant only:

- Dockerfile.race keeps CGO_ENABLED=1. It is a different use — the race detector — and
  e2e_test_race is what caught the hedge-path data race. Worth noting the comment there
  claiming CGO is required for -race is not accurate on current Go: CGO_ENABLED=0 go build
  -race links the shipped TSan .syso and succeeds. Left alone regardless, because that image
  builds on alpine/musl and nothing here tested it.
- Dockerfile.local still builds with CGO and the libsecp tag, so local Tilt development runs
  a different binary from production. Left for a separate change.
- release_build_cgo and Dockerfile.release.glibc are retained but no longer invoked, so a
  future retest stays one command away if the upstream thread-pinning issue is ever fixed.

Dockerfile.release is unaffected: its BINARY_SUFFIX arg defaults to empty, so it consumes
path-linux-<arch> exactly as release_build_nocgo produces it.
release_build_nocgo compiled each platform in sequence: 2m02.5s for linux/amd64 then 2m01.5s
for linux/arm64, measured from a real build log. The two share nothing at runtime, so they
can overlap.

Do not expect a halving. go build already parallelises internally and saturates the
available cores, so the gain comes from overlapping each target's largely single-threaded
link phase rather than from the compile phase, and it will be smaller on a CPU-starved
runner. The honest range on a 4-vCPU runner is somewhere between roughly neutral and a
noticeable improvement; the next CI run will say which, since the make target echoes a line
per platform and Actions timestamps them.

Failure propagation is explicit and hand-rolled because it has to be: `set -e` does not fire
for a background job, so without collecting each PID and checking its status this target
would report success while a cross-compile had failed, leaving a missing or stale binary in
release/ for the image build to package. Verified both directions — a bogus platform makes
the target exit non-zero with a clear message, and the valid case still exits 0.
Follow-up to the health-observation change, which inverted the behaviour of a state it did
not anticipate.

Solana's health checks run two probes, getHealth and getBlockHeight, and they land at
different moments. Between them an endpoint holds a block height and no health observation.
That state is not rare: every endpoint passes through it after every restart and between
check cycles.

Before that change, ExtractBlockHeight could not parse a getBlockHeight response, so nothing
was stored and an unprobed endpoint stayed ABSENT from the endpoint store — where
filterValidEndpoints waves it through as fresh. Once block heights began being stored, the
same endpoint became present-but-incomplete, and a nil SolanaGetHealthResponse was fatal. So
learning more about an endpoint made it less selectable, which is backwards.

Measured on canary within three minutes of the deploy: solana filter rejections went from
~1.1k/s to ~12.6k/s, and the selectable pool halved from 14.4 to 7.2 endpoints. User-facing
impact was nil — canary sat at 21.2% solana errors against mainnet's 24.7% — because the
least-stale fallback absorbed the smaller pool. The mechanism was wrong even though the
outcome looked fine, and the fallback it leaned on ignores the concentration cap.

A missing health observation now passes validation. An observation that reports the node
unhealthy still rejects, immediately below: that is a measurement, and it fails. This is the
same principle already applied to Epoch in the original change — absence of a measurement is
not evidence of badness — which simply was not carried across to the health field two lines
above it.

Note the nil guard is load-bearing for the unhealthy case as well, not only for the removed
one. Result is promoted from the embedded *SolanaGetHealthResponse, so reading it without a
nil check dereferences a nil pointer.

errNoGetHealthObs is removed, since nothing returns it any more, along with its now-dead
metric reason mapping. QoSFilterReasonHealthUnknown stays in the metrics vocabulary: splitting
that reason out of block_height_unknown is what made this pool collapse legible within minutes
of the deploy that caused it, rather than being inferred from the absence of a sibling series.

Tests reproduce the intermediate state with mock probe data rather than asserting on the
struct: feed only the getBlockHeight probe, then require that selection still returns the
endpoint. Revert-checked — restoring the fatal nil case fails
Test_PartiallyProbedEndpoint_StaysSelectable, and Test_ObservedUnhealthy_IsStillRejected
guards the opposite direction.
getProgramAccounts returns a top-level array and legitimately returns []
whenever its filters match nothing — routine with dataSize/memcmp filters.
It was missing from emptyArrayValidMethods, so every correct empty response
was classified as a broken supplier, retried twice, and returned to the
client as a 500.

Measured on mainnet: 7803 of 7803 jsonrpc_invalid_empty_array detections in
a 60s sample across three gateway pods were getProgramAccounts, and solana
relay success collapsed from 1472/s to 133/s while errors rose to 454/s.

All five solana operators failed at once at nearly identical rates (72-81%)
on both gateway builds, while health checks — which never call
getProgramAccounts — kept succeeding. Independent operators do not fail
identically at the same second; that uniformity is what identified the
classifier rather than the supply as the fault. The endpoints were penalised
for returning correct data, leaving 41 solana endpoints in cooldown.

Also adds getInflationReward, getSlotLeaders and getConfirmedBlocksWithLimit,
which return top-level arrays for the same reason.

getMultipleAccounts and getTokenAccountsByOwner appeared in the same logs but
are deliberately NOT added: both wrap their array in {context, value}, so a
bare "result":[] from them is genuinely malformed and must stay a detection.

Tests assert through Analyze, the entry point the four production call sites
use, not through ProtocolAnalysis. Revert-checked: the new cases fail without
the fix, while the guards covering still-flagged methods and populated results
pass either way.

Note the shape of this bug for the follow-up: the default for an unknown
method is to flag it, so the allowlist must enumerate every array-returning
method across EVM, Solana, Sei, trace and debug namespaces, forever. Anything
missed becomes an incident like this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An endpoint returning a signature-valid RelayResponse with an empty body passed
every check PATH performs — signature verification, ValidateBasic, and unmarshal.
Only a content heuristic caught it, and its signal never reached the reputation
system intact. One endpoint produced ~800 empty responses in a two-minute sample
across four gateway pods while holding a reputation score of 100.

Three defects, found in that order:

1. The reason string never matched. context.go builds the error as
   "heuristic detected %s (method=%s)", so the classifier received
   "empty_response (method=getTokenAccountsByOwner)" and compared it with exact
   equality. Every exact-match case in classifyHeuristicErrorAsSignal —
   empty_response, small_no_result, html_error_page, bad_gateway,
   rest_error_field, rest_code_message_error — was therefore unreachable for any
   request carrying a JSON-RPC method, and those responses fell through to the
   default unknown_payload_error at MINOR. Only the HasPrefix("error_indicator_")
   cases survived, because prefix matching tolerates the suffix, which is why the
   gap stayed invisible: the surviving cases covered the common errors. The
   method suffix is now stripped before matching.

2. empty_response was weighted MINOR (-3), the same as a passing
   blockchain_error, while protocol_error is CRITICAL (-25). No RPC type PATH
   forwards has a valid zero-length response, and the relay is signed and
   settleable regardless of content, so this is a protocol violation and is now
   CRITICAL. small_no_result deliberately stays MINOR: a short response missing a
   "result" field is ambiguous — a truncated read or a terse upstream error —
   unlike an empty body, which has no valid reading.

3. Raising the severity would have created a new false positive. 204, 205 and 304
   carry no body by definition, and an empty payload on those was already being
   reported as empty_response — harmless at MINOR, a critical penalty for correct
   behaviour once weighted as a violation. The heuristic now exempts them, so the
   branch only ever sees a promise of content that was not delivered.

Tests assert through classifyErrorAsSignal and Analyze, the entry points the relay
path calls, not through the classifier branches directly — the parse defect is
invisible to a test that constructs the reason by hand. Each change was
revert-checked independently: reverting the parse fix fails 3 tests, reverting
only the severity fails 2, reverting the 204 exemption fails 3 subtests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sync_allowance for solana is 1500 blocks (~10 min), but getHealth returns "ok"
only while the node is inside its own --health-check-slot-distance, default 128
slots (~51s). Past that it answers -32005 "Node is behind by N slots", which does
not contain "ok", so the check failed at critical (-25).

Two rules in the same block therefore disagreed about what "behind" means, and
the stricter one always won — which made the configured 1500 unreachable for
health-check purposes. Worse, the threshold that decided the bench is set by the
node operator, not by us, so the same rule benched different suppliers at
different points.

getBlockHeight keeps critical_error and sync_check: true. That is the sync gate
that honours the configured allowance, and it is measured against a like
quantity: PATH's perceived height is blockHeight (418,347,236 observed) and
getBlockHeight returns blockHeight, not absoluteSlot (440,308,279) — the two
differ by ~22M on Solana because of skipped slots.

getHealth at major still catches a hard-down node while a node drifting a few
hundred slots, and serving fine, is no longer a critical fault. This also brings
solana in line with the file's usual shape (near: status critical, block major);
it was one of only 12 of 69 services with every check at critical.

Not yet live: gateways fetch these rules from pocket-network-resources, so this
takes effect when it is published there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An endpoint returning zero-length payloads at ~0.2% of its traffic held a
reputation score of 100 all day. Neither existing mechanism can reach that:

- Additive scoring is outvoted by volume. At one violation per 1000 requests
  the endpoint earns +998 and loses -25, so the score returns to its ceiling
  no matter how long the behaviour continues. Raising the per-event penalty to
  FATAL (-50) leaves the sign unchanged.
- The critical-rate detector is tuned for "unambiguously broken" (30% of
  requests), and more fundamentally CriticalRateEWMAAlpha's ~20-request memory
  cannot represent a sub-1% rate at all — the EWMA can only be 0 or ~0.05
  there. No threshold change to that detector could have worked; the quantity
  is not measurable at that alpha.

So this adds a second detector rather than retuning the first. The two measure
different things and share no threshold: a 5xx is a transient the network is
expected to absorb, while a structurally invalid response is never legitimate
at any rate and warrants a threshold three orders of magnitude lower with a
correspondingly longer window.

  InvalidRateEWMAAlpha       = 0.001   (~1000-request memory)
  InvalidRateThreshold       = 0.005   (0.5% structurally invalid)
  InvalidRateMinObservations = 1000    (>= 1/alpha, or the EWMA has not converged)

The threshold is sized from production rather than intuition. Measured
2026-08-19 over one hour, fleet-wide: the two offending domains ran 0.216% and
0.065% of all their relays and ~0.85% of the affected service, while every
other domain sat at 0.00003% or exactly zero, sustained across 48 hours. 0.5%
is far above that noise floor and below the observed offender.

Routing is via an explicit Signal.IsProtocolViolation flag set by the producer,
following the IsHealthCheck precedent, rather than string-matching on Reason —
the reason string carries a "(method=...)" suffix that already defeated exact
matching once. Health-check probes are excluded for the same reason they are
excluded from the critical-rate detector: a hard bench must reflect what users
receive. The escalation counter and metric are kept separate from the
critical-rate detector's so a trip of one cannot be misread as the other.

path_reputation_invalid_rate_cooldown_total is expected to be near zero
fleet-wide. A broad nonzero rate means the threshold is mistuned, not that the
fleet degraded; that is the rollback signal and it is stated in the help text.

Revert-checked, which caught two defects tests alone did not:
- The detector was first nested inside the critical-rate trip branch, so it
  only ran when that fired — never, at these rates. The tell was RecentInvalidRate
  pinned at 0 while RecentCriticalRate moved.
- Removing the producer flag broke no test, because four of the five new tests
  are negative assertions that pass trivially when the detector is dead. Added
  an explicit assertion that the classifier sets the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es to archival

Geth's path-based state scheme reports unavailable historical state as
"metadata is not found, <block>". Every archival pattern PATH matched was
hash-based-scheme wording ("missing trie node", "state has been pruned"), so a
PBSS node's honest "I do not retain that state" matched nothing at four sites,
each failing differently:

- errorIndicators: the analyzer classified it as jsonrpc_valid_error and
  returned ShouldRetry=false, so the request was never re-tried on an endpoint
  that retains the state and the client received the -32000 verbatim.
- IsArchivalRelatedError: without it, recognising the error would then
  circuit-break the whole domain over what is a capability mismatch.
- capabilityLimitationSubstrings: the same, on the hedge_failed path where the
  structured AnalysisResult is lost and only the error string survives.
- EVM archivalErrorIndicators: the check fell through to its "some other error"
  branch and returned an error rather than false, so the endpoint that had just
  failed an archival query was never demoted out of the archival pool that kept
  sending them.

The deeper defect is why an archival request reached a pruned node at all.
EVMDataExtractor.IsArchival returned true for any successful response to
eth_getBalance / eth_call / eth_getCode / eth_getStorageAt /
eth_getTransactionCount, never reading the block parameter. Those are also the
ordinary way to read current state -- eth_getBalance(addr, "latest") is among
the most common calls on the network -- and a pruned node answers them
perfectly, so it was marked archival and cached for 8h. The archival pool was
polluted by construction.

targetsHistoricalBlock now gates the true return on the request naming a
historical block. An omitted parameter (which clients default to "latest"), the
latest/pending/safe/finalized tags, block hashes and EIP-1898 objects are
inconclusive; "earliest" and numeric hex still prove archival.

Known limitation, documented at the call site: the DataExtractor interface
carries only (request, response), not the perceived chain tip, so a numeric
block a few blocks behind the tip still reads as archival. The tag case was the
whole of the pollution in practice.

TestEVMDataExtractor_IsArchival asserted archival=true on a request carrying no
params at all -- the exact shape a pruned node answers -- so it encoded the bug.
Its request now names a historical block; the error cases are unchanged because
the error branch runs before the gate.

Each of the five changes was revert-checked individually.

Note for review: this shrinks the archival pool, which is the intent, but a
service leaning on falsely-promoted endpoints will start failing archival
requests at selection rather than at the endpoint. Watch
path_qos_filter_rejection_total and the archival counts in
/ready/<service>?detailed=true after deploy.
…etectors

Both volume-independent rate detectors are wrapped in `if !signal.IsHealthCheck`
so a probe can never bench an endpoint on its own: a strict or flaky check can
fail an endpoint that serves user reads perfectly, and must not be able to cool
it out of rotation. That guard was intact. The stamp was not.

Only the health-check executor's own three RecordSignal call sites ever set the
flag. A probe ALSO reaches reputation through the protocol layer, twice — once
from the relay itself via requestContext, and once from Apply*Observations on
the same relay's observations — and neither stamped it. The field doc on
requestContext.isHealthCheck said outright that the flag "does not affect
reputation signals or observations", so the omission read as deliberate.

The result is a self-sustaining loop rather than a one-off penalty. A benched
endpoint receives no user traffic, so probes become its only signal, so its rate
EWMAs are entirely probe-derived, so it re-benches itself on the next probe
failure. Measured on canary 2026-08-20 against a control environment running the
previous build: every solana endpoint tripping roughly twice an hour against a
~12-endpoint pool, with the pool-collapse guard firing 19.3x the control to keep
the service served at all. Ten services tripped the invalid-rate detector where
the threshold was sized for one. Fleet success rate was unaffected in both
environments — the guard was absorbing it, which is why nothing alerted.

Corroborating: over the same 14 hours, zero user-traffic relays fleet-wide were
recorded with a payload-heuristic fault (path_relays_total{status_code=
"heuristic_error"} = 0, and status_code="200" with reputation_signal=
"critical_error" is 100% request_type="health_check"), while the detector fired
2,867 times.

Stamped at every site that records a signal for a relay the executor issued:
the HTTP relay success, error and latency-penalty paths, plus the HTTP and
WebSocket observation paths. Apply{HTTP,WebSocket}Observations take the verdict
as a required parameter rather than defaulting it, so each of the two callers
has to state which it is at compile time — the receiving protocol layer cannot
tell synthetic observations from real ones.

Scope is deliberately narrow: only the rate detectors exclude probes. A probe
result still moves the additive score, which is how a benched endpoint recovers
when it is receiving no user traffic, and it is still counted in SuccessCount /
ErrorCount so no rate's denominator changes shape.

Tests assert on the signal reputation RECEIVES, from the production caller, not
on the flag the caller set — the flag was already true and told us nothing. Each
of the five call sites was revert-checked: reverting it alone fails a test.
Score.InvalidRateCooldownCount is documented as "kept separate from
RateCooldownCount so the two detectors escalate independently and a trip of one
cannot be misread as a trip of the other". The counters were separate; the
timestamp they escalated against was not. Both compared against the shared
Score.CooldownUntil, which the strike system also writes, so the intent in that
comment was never implemented.

Note the sign: time.Since() on a cooldown still in force is negative, hence
always below DefaultMaxCooldown. Any bench in force, from any mechanism, made
the next trip of either detector read as consecutive. On a churn-heavy service —
exactly the population these detectors exist for — an endpoint whose own history
had long since aged out resumed its stale count instead of resetting, and a
first offence was benched at the escalated duration immediately.

Each detector now records the end of the cooldown it set (RateCooldownUntil,
InvalidRateCooldownUntil) and escalates against that. CooldownUntil is unchanged
and remains the only field selection reads; these two are escalation arithmetic
only, never a second gate. Both persist to Redis so escalation survives a
restart and is consistent across replicas; absent on older records, which leaves
them zero and costs at most one non-escalated trip.

The first version of the test for this passed against a revert, because the
scenario it set up did not discriminate: incrementing a zero counter yields 1,
the same value a correct reset yields. It needed a stale non-zero history plus a
foreign bench in force, which is the shape production actually reaches. Asserted
on the bench duration the endpoint receives, not on the counter.
Measured against production by sending a deep historical block to endpoints PATH
had marked archival, then reading what came back. Two live wordings both missed
every entry in archivalErrorIndicators by a single word:

  gnosis: "historical state is not available"
  poly:   "historical state <hash>"

"state not available" does not match "state IS not available", and "historical
data" does not match "historical STATE". Both therefore fell through to the
"some other error" branch, which returns an error rather than false, so the
endpoint that had just failed an archival query was never demoted out of the
archival pool and kept receiving them. Identical failure to the geth PBSS entry
added in 54659fb, on a different vendor's wording.

The bare "historical state" prefix covers both observed forms. It is already
present in qos/heuristic/indicators.go, where this error IS recognised — the two
catalogues had drifted apart, so this realigns them.

Found by probing rather than by reading: three archival-marked gnosis endpoints
returned this error for a block 27M deep while answering `latest` correctly, so
they were pruned nodes sitting in the archival pool. The test is table-driven
because the discriminating detail is the exact string; a single case would pass
on a pattern covering only one of the two wordings. Revert-checked — removing
the entry fails both cases.
The two sources of archival status had drifted 16x apart, in the direction that
does the most damage:

  health-check mark   30m   gateway/health_check_executor.go
  user-traffic mark    8h   qos/evm/qos.go

The health-check path pins an exact expected historical value in the rules file,
so a node that ignores the block parameter and answers from current state fails
it. That verified mark expired in 30 minutes.

The user-traffic path cannot pin a value — the query is whatever a client
happened to send — so it grants archival status on any successful call to an
archival method. 54659fb tightened it to require that the request targeted a
historical block, but it still trusts a successful response, which is exactly
what a fabricating node always produces. That unverified mark lasted 8 hours.

Measured in production: four endpoints on one operator were marked archival
while returning current state for every block asked, including one 256x past the
chain tip. The archival health-check rule for the service they served had been
deleted for failing every endpoint — which was the rule working correctly, that
service has no archival nodes — leaving only the unverified 8h path to promote
them.

Both paths now share gateway.ArchivalStatusTTL, so they cannot drift again. The
old comment on the 8h constant claimed it "matches health check archival TTL",
which is probably why nobody noticed; a comment cannot hold this invariant.

Bootstrapping is unaffected: promotion still happens through requests naming a
numeric block within the archival-required threshold, which route freely rather
than being filtered to already-archival endpoints.

The test asserts the stored expiry through UpdateFromExtractedData rather than
comparing constants, so re-hardcoding a duration at the call site fails it.
Revert-checked.
Five issues, all from ad729c3, all failing `Run linter` on the PR:

- `errInvalidGetEpochInfoEpochZeroObs` was declared and never used. Vestigial:
  ValidateEndpoint's own comment explains that epoch 0 is deliberately NOT fatal —
  the health-check path builds a SolanaGetEpochInfoResponse carrying only a block
  height, so its Epoch is 0 by construction, and rejecting on that would re-create
  the trap the same commit had just closed. The check was removed on purpose; the
  error string was left behind.

- Four redundant embedded-field selectors in health_observation_test.go
  (`q.ServiceState.ValidateEndpoint`, `q.ServiceState.perceivedEpoch`,
  `stored.SolanaGetEpochInfoResponse.Epoch`). Promotion makes the qualifier
  unnecessary and the compiler rejects it if it were ambiguous, so the shorter
  form cannot change which field is read.

No behaviour change. `golangci-lint run --timeout 5m --build-tags test` over the
whole repo is clean, and qos/solana tests pass.
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