Skip to content

fix(reputation,qos,gateway): health-check bench loop, circuit-breaker gate, batch responses, plus cardinality/solana/archival follow-ups - #528

Merged
oten91 merged 35 commits into
mainfrom
fix/cardinality-followup-f5-f6
Aug 25, 2026
Merged

fix(reputation,qos,gateway): health-check bench loop, circuit-breaker gate, batch responses, plus cardinality/solana/archival follow-ups#528
oten91 merged 35 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-040ae42-rc and has been
validated in production
(earlier cuts of the branch ran as sha-064bc62-rc and
sha-45e00a2-rc; sections 6–8 below cover what landed since). 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 one operator 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.

6. Circuit-breaker failure-rate gate

db817520 aef31dd6 1a9ca5cc a7063b59 d82b1e1d

Hysteresis. The gate used one threshold to break a domain and nothing but TTL expiry
to restore it, so a host whose true failure rate sat just above the line was removed every
time it was let back in, with escalation holding it out longer each cycle. Measured on six
relay-miner hosts behind one operator: the four marginal ones (within 1.7 points of the
80% line) spent 69–92% of a six-hour window removed from the pool, identically in both
environments, while answering 40 consecutive probes with zero errors at the same latency as
the host carrying the service. A domain that broke recently must now be clearly worse to
break again (threshold + 0.15).

The denominator was blind to hedge-race successes. Failures reach the gate from every
path, but a success only counts where the returning path calls RecordSuccess, and the
hedge-race branch never did. With a hedge delay configured every first attempt returns
through it — including the overwhelming majority where the hedge never fires — so the gate
saw 5% of a high-volume operator's successes and 26% fleet-wide, and read a low-volume host
at 30–66% failure where the relay counters read ~21%. No threshold, hysteresis included, can
hold against a rate inflated past it by construction. Tested through the real retry loop.

path_circuit_breaker_outcome_total{service_id, domain, outcome} exposes both sides of
the fraction the gate actually computes, per hostname — the gate keys on hostname while
path_relays_total keys on eTLD+1, and the blended figure cost four wrong hypotheses in one
investigation. Two labels by design, registered with the cardinality guard.

7. Traffic shape and classifier fixes

d7e4d81a 7ace2a41 45e00a24

GET /admin/request-sample. Every quality signal rewards whoever answers fastest, and
an endpoint fronted by a cache answers a repeated request in sub-millisecond time without
touching a node. Whether a fast operator is fast or merely cached has to be read from the
traffic, and nothing recorded its shape. One request in N is fingerprinted on method +
compacted params (ids excluded) and counted per service in fixed windows; the endpoint
reports uniqueness, top-1 share, and per-method uniqueness — block-height calls are
legitimately repetitive, account lookups are not. Bounded table, per-pod, two gauges keyed
on service_id only.

Solana's account-index exclusion is a capability limit, not a fault: -32010 "<key> excluded from account secondary indexes" is node configuration, and another operator serves
the identical call from its index. It matched nothing in the catalogue, so a dapp polling
three getProgramAccounts queries continuously charged one operator a breaker failure and a
reputation penalty on every poll. Added as an allowlisted phrase, deliberately not an
archival pattern.

path_observation_pipeline_total mislabelled every no-fault error as major_error
it re-derived reputation_signal from the observation's error type instead of the signal
actually recorded. path_relays_total, labelled from the real signal, disagreed about the
same relays; two observables disagreeing about one state, and the pipeline label was the
wrong one.

8. Batch responses and client cancellations (2026-08-24/25)

9137cdbb da13f402 796ffc7d 51ffb44a 43abf62e 040ae428

Started from an operator asking why they were "broken" for batch_transport on one
service. The label misleads: PATH never sends a multi-request relay — batches are split and
each item is its own relay. batch_transport means one item of a client batch failed
before any HTTP response existed.

Every one of those breaks was context canceled — the client hung up mid-batch, and the
batch item loop stamped the abort of our request on whichever supplier was holding the
item. It hit every operator serving the service at once. The single-request loop already
returned on a done context before reaching MarkBroken; the batch loop now does the same
(9137cdbb). Measured: breaks 116/h → 0 on canary with the control unchanged.

Worth recording honestly: the user-facing effect over 40 minutes was neutral. The
cancels are not uniform across operators — they track p99 tail latency (0.23s on the best
operator, 1.0–2.3s on the worst), because context canceled is the client's timeout
firing on the supplier's tail — so the old behaviour was accidentally benching the one slow
operator for ~20 minutes at a time. Reputation does not bench it because these errors are
no-fault. The signal belongs in reputation's latency path, not the breaker; that is the
follow-up, and this A/B is its evidence.

A failed batch item threw away the whole batch. An item that failed every attempt has
no body; the assembler dropped it, the length check saw N−1 for N, and the client got a
single id:null -32603 "batch response length mismatch" in place of the N−1 answers already
relayed and paid for. Every retained log line of the failure was that shape. Per JSON-RPC
2.0 each request object gets a response object, so the item nothing answered now gets an
error object carrying its own typed id, next to the successes (da13f402, EVM and Cosmos
via the shared validator; 796ffc7d for NoOp, which had the quiet form — the item simply
vanished from the array with HTTP 200).

Three more shapes surfaced on canary and are fixed in the same helper:

  • A one-element batch that retries runs down the single-request path, which records
    every response it saw; the batch assembler collected all of them, saw 2 for 1, and
    replaced a request that had succeeded with a 500. Keep the latest per id (51ffb44a).
  • An id-less item is not a notification once it has been through PATH — it is relayed
    alone with "id":null written out and the node answers it. da13f402 had excluded such
    items from the expected count and rejected every batch carrying one with "expected N, got
    N+1"; caught on canary within the hour, reproduced with a two-item probe (500 on canary,
    200 on the control), fixed in 43abf62e. A test written from the spec instead of from
    what suppliers actually return; the replacement test uses the production shape.
  • A response that is not a well-formed Response — an error given as a bare string, an
    id type the parser rejects — is still an answer to some item. Attribution now reads only
    the id; an unreadable id is a wildcard like a null id (040ae428). The mismatch error now
    lists the response ids, bounded, so any survivor names its shape in the log.

Validation

Canary ran each cut against mainnet as an untouched control, then mainnet followed after a
12-hour soak. Marshal-failure log lines in the retained tail: 242 on the control, 0 on
canary
over 12h; 0 on both since mainnet rolled. Fleet relay success 0.926 vs 0.927,
5xx and rps equal, 0 restarts, heap on the known ~30 MiB/h curve. Probed end-to-end,
tallied by the serving environment: an id-less two-item batch 8/8 → 200 with both answers;
a one-element batch forced to retry (an archival call most suppliers refuse) → 3 of 5 500s
on the control, 200 with the supplier's own error on the fix.

Two shapes are not forceable from outside — an item with no body, and an unreadable id —
and rest on the log evidence plus the new ids diagnostic.

Every fix has a test that fails with the fix reverted; each was checked.

Also worth knowing

  • path_requests_total{status_code="error"} is derived from the endpoint observation
    (backend status 0 with an error set) — per-relay transport failure, not what the client
    received. No metric records the client-facing batch outcome; the Error-level log line is
    the only evidence, and a counter for synthesized batch errors (service_id only) would be
    a reasonable addition.
  • A batch where every item fails still returns the empty-batch response (nothing, 200) —
    the emptiness check runs before the fill. Rare; separate item.
  • Solana's batch context already answered per id; it turns numeric-looking string ids into
    ints and says "malformed response" for a missing body. Cosmetic, unchanged.

oten91 and others added 30 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.
The gate uses one threshold to break a domain and nothing but TTL expiry to
restore it. A domain whose true failure rate sits just above that threshold is
therefore removed every single time it is let back in, forever, and escalation
holds it out for progressively longer each cycle — while a domain far worse is
treated identically.

Measured in production: six relay-miner hosts behind one operator domain,
success rates against the 80% line:

  marginal:          79.7%  79.3%  78.3%  72.5%
  genuinely broken:  58.6%  49.7%

The four marginal hosts sit within 1.7 points of the line and flap. Fraction of
a six-hour window spent removed from the pool, measured identically in two
independent environments:

  92.3% / 92.3%    87.7% / 89.5%    69.2% / 71.8%

against 0.2% / 11.3% for the high-volume host used as a control. While still
marked broken, one of the marginal hosts answered 40 consecutive probes with
zero errors at 226ms — matching the 223ms of the host carrying nearly all of
that service.

A domain that broke recently must now be clearly worse to be removed again:
threshold + 0.15, i.e. 35% failure rather than 20%. That is well above where the
marginal hosts live and well below the genuinely broken ones, so the two
populations separate. A first break is unaffected.

The margin shares its "broke recently" predicate with escalation, so a domain
can never be escalated for an episode the margin let pass, and it lapses with
escalationMemory rather than granting permanent leniency.

An attempt-count floor was tried alongside this and dropped: it spared a host
with no successes at all, which is the shape of the one genuinely dead host in
the same pool.

Tests assert through GetBrokenDomains, not shouldBreak, so they cannot pass on a
version whose verdict never reaches selection. Revert-checked: the flap test
fails without the margin. The other three are guardrails — badly-broken domains
still re-break and still escalate, a host with zero successes still breaks, and
the margin lapses with escalation memory.
The failure-rate gate keys on the full hostname; path_relays_total keys on
eTLD+1. An operator running several relay miners under one registrable domain
therefore reports a single blended success rate, so a domain whose hosts range
from 50% to 80% is indistinguishable from one where every host sits at 65%.

Those two call for opposite responses — replace the bad hosts, versus the
operator has a systemic problem — and separating them cost four wrong
hypotheses during a production investigation, purely because the number did not
exist at the granularity the decision is made at. Every hypothesis was tested
against a blended figure and every one of them looked plausible.

path_circuit_breaker_outcome_total{service_id, domain, outcome} records both
sides of the fraction the gate computes. A numerator alone is not enough: that
is precisely the pre-rate-gate behaviour where any single failure read as a
100% failure rate.

Cardinality is two labels by design. The sibling circuit_breaker_events_total
carries the same service_id x domain pair plus reason_category x event and
reached 233,269 series — the cross product, not a leaking label. A 2-value
outcome keeps this near a twelfth of that, it is registered with the same
cardinality guard, and the guard is added to packageGuards so its eviction
sweep actually runs. The guard bounds the live registry rather than retained
series, so the churn diagnostic still applies if this ever looks cheap while
the TSDB disagrees.

Counts what the gate sees, not every relay: while a domain is broken MarkBroken
returns before the gate, so failures are not counted and the host goes absent
rather than reading as healthy. Documented on the metric, because a host
showing a suspiciously good rate may simply have been broken for most of the
window.

Revert-checked: collapsing the key to eTLD+1 fails the test, which asserts one
host's failures are never charged to the sibling sharing its domain.
…gate

The gate divides failures by (failures + successes). Failures reach it from
every path — each failed attempt re-enters the retry loop, which calls
MarkBroken — but a success only counts if the path it returned on calls
RecordSuccess, and the hedge-race success branch never did. On a service with
a hedge delay configured, EVERY first attempt goes through the racer, including
the overwhelming majority where the hedge never fires, so the gate's denominator
was fed almost exclusively by retries and batch items.

Measured on one environment over one hour: the operator carrying ~92% of a
service produced 680,893 successful first-attempt relays and the gate counted
36,287 of them. Fleet-wide the gate saw 26% of successful relays. The
high-volume operator survives this — 150 failures against even 36k successes is
far under the threshold — but a low-volume operator whose traffic is mostly
hedges is judged on a fraction dominated by its failures: the gate read 30-66%
per host where the relay counters read ~21%, which is why the hysteresis margin
added in the previous commit never got a chance to hold. The rate it sees is
inflated past the margin by construction.

RecordSuccess is now called on both hedge-race success branches (single relay
and batch item) for the winning endpoint's domain, the same way the normal path
already does. The loser's outcome is still not recorded either way, matching the
existing asymmetry for failures.

Test drives the real retry loop with a hedge delay and asserts the gate's window
for the winning domain holds exactly one success, with the normal path as the
control. It fails on the previous commit (0 successes on the hedge path) and
passes on this one.
…verse traffic

Every quality signal PATH has — latency, success rate, hedge wins, the reputation
score — rewards whichever endpoint answers fastest. An endpoint fronted by a cache
answers a repeated request in sub-millisecond time without touching a node, so
against repetitive traffic it wins every race and accumulates every reward, while
against unique traffic it is an ordinary node. Whether a fast operator is fast or
merely cached therefore cannot be read from the operator; it has to be read from
the traffic. Nothing recorded what the traffic looked like: the method is a label,
the params never were, and a thousand account lookups for a thousand accounts and a
thousand for the same account were one number.

One request in N (PATH_REQUEST_SAMPLE_RATE, default 100, 0 disables) is
fingerprinted — JSON-RPC per item on method + compacted params with the id
excluded, so rotating ids cannot make repetition read as diversity; anything else on
HTTP method + path + body — and counted per service in fixed windows
(PATH_REQUEST_SAMPLE_WINDOW, default 10m), keeping the last completed window. The
table is bounded (PATH_REQUEST_SAMPLE_MAX_FINGERPRINTS, default 5000); past it new
fingerprints are counted in table_overflow but not stored, so the uniqueness ratio
stays honest and a large overflow is itself the answer.

GET /admin/request-sample lists one row per service; /{serviceId}?window=previous
&top=N returns uniqueness (distinct/sampled), top-1 and top-N share, per-method
sampled vs distinct — block-height calls are legitimately repetitive, account and
transaction lookups are not, so the verdict is per method — and the most repeated
fingerprints with a 200-byte payload snippet. Per pod, in memory.

Two gauges, path_request_sample_uniqueness and path_request_sample_top1_share, carry
the last completed window per service_id only — no method, no fingerprint — so the
cardinality is the service list. Sampled, so read as ratios, never counts.

What it cannot tell: requests, not clients — there is no client identity behind the
edge — so repetition cannot be attributed to a sender; and a low ratio is a property
of the traffic, not evidence against any operator.

Tests cover id/formatting invariance, batch items, 1-in-N, the bounded table with
overflow counted, window rotation publishing the gauges, nil-sampler no-op, REST
fingerprints, and the endpoint's 503/404/400/200 contract.
…y limit

Solana returns -32010 "<key> excluded from account secondary indexes; this RPC
method unavailable for key" when the node was started without a secondary account
index for that program (or with it excluded). It cannot serve getProgramAccounts for
that key; another operator serves the identical call from its index in under half a
second. That is node configuration, not a fault.

The phrase matched nothing in the catalogue, so the analyzer classified the response
as a generic JSON-RPC error: the request was retried — correct — and the domain was
also charged a circuit-breaker failure and a reputation penalty — wrong. One dapp
polls three getProgramAccounts queries continuously (it is a quarter of the service's
traffic in the request sample), so an operator without the index paid that penalty on
every poll and two of its four endpoints sat at score 0.

Added as an error indicator, a capability-limitation case, and a substring for the
hedge_failed fallback — the same three places every other capability phrase lives.
Deliberately not an archival pattern: an index exclusion must not route through the
archival filters. Allowlisting the specific phrase keeps this an allowlist of codes
rather than "any error object is exempt", which would pay a supplier to serve nothing.

Tests assert the production analyzer retries, classifies as capability-limited and
not archival, and that the string fallback recognises the wording. All three fail on
the parent commit.
…servation pipeline

path_observation_pipeline_total's reputation_signal label is re-derived from the
observation's error type rather than read from the signal reputation actually
recorded. Every protocol path that records an error with type UNSPECIFIED —
capability limitation, over-servicing, session mismatch, a heuristic verdict that
was not a fault — pairs it with a SUCCESS signal, and the protocol's own observation
consumers read UNSPECIFIED as success outright. The reporter was the one place that
read the pair as MAJOR.

Seen the moment a new capability phrase was catalogued: the affected service's
observation pipeline lit up with major_error while path_relays_total, which is
labelled from the real signal, reported the same relays as ok with an error status,
and the mean score tracked the control environment exactly. Two observables
disagreeing about one state; the pipeline label was the wrong one. The same artifact
has been mislabelling every archival exemption since they were added.

UNSPECIFIED with an error recorded now labels as ok, matching path_relays_total and
the protocol consumers. Typed errors keep their severity.
…h item

When the client hangs up while a JSON-RPC batch is in flight, net/http
cancels the request context and every in-flight item fails with
"context canceled" on whichever supplier it was sitting on. The batch
item loop stamped each of those on the supplier's domain as a
batch_transport failure. Measured 2026-08-24 on one batch-heavy
service: 43 of 43 circuit breaks over two hours, both environments,
were this shape, spread across every operator serving the service.

The single-request retry loop already returns on a done context before
reaching MarkBroken; the batch item loop now does the same, and skips
MarkBroken on a transport error when the request context is done. A
genuine transport error with a live request context still counts.

Tested through the real batch item path; fails on revert.
…whole batch

A batch item that fails every attempt has no response body. The batch
assembler dropped it, the length check saw N-1 responses for N
requests, and the client got a single id:null -32603 "batch response
length mismatch" in place of the N-1 answers that had already been
relayed. Measured on one batch-heavy service at ~7-8% of all requests
(request-level status "error", 5xx = 0); every retained log line of the
failure was that shape, 60% short by exactly one.

Fill each request that has no response with a JSON-RPC error object
carrying that request's id — what the single-request path already
returns and what a node does. Matching is by id value so same-value ids
are counted, not collapsed; a null-id response still stands in for one
missing request; notifications are never filled and are no longer
counted as expecting a response.

Shared in qos/jsonrpc, so the EVM and Cosmos batch paths both change.
Tested at the jsonrpc unit and through the EVM context with the same
UpdateWithResponse calls the gateway makes; both fail on revert.
oten91 added 4 commits August 24, 2026 23:15
The pass-through QoS reassembled a batch from whatever responses had
bytes, so an item that failed every attempt vanished from the array:
the client got N-1 response objects and HTTP 200, with nothing for the
id it was waiting on. Same defect as the EVM/Cosmos length mismatch,
in its quiet form.

Record each item's id at split time and fill any id without a response
with an error object carrying that id, through the same helper the
JSON-RPC batch validator uses (exported as FillMissingBatchResponses).
An item whose id cannot be read is treated as a notification and is
still passed through untouched. A batch where nothing answered keeps
returning the 500 it does today.

Tested through ParseHTTPRequest and the same UpdateWithResponse
sequence the gateway makes; fails on revert.
…ds more than one

A one-element batch is a batch, but with one payload it runs down the
single-request path, which records every response it saw: the attempt
that failed and then the retry that succeeded, or all N answers of a
parallel fan-out. The single path returns the latest and is fine; the
batch assembler collected all of them, saw 2 responses for 1 request,
and replaced a request that had succeeded with an id:null -31001 and
HTTP 500. Seen live as "expected 1 responses, got 2".

Reconcile before validating: for each id keep the last as many
responses as there are requests carrying that id, then fill any request
still without one. Same-value ids keep one response each. The helper is
renamed ReconcileBatchResponses; the pass-through QoS uses it too.

Fails on revert at the jsonrpc unit and through the EVM context.
The batch fill treated a request without an id as a notification and
left it out of the expected count. It is not one once it has been
through PATH: every batch item is relayed on its own with "id":null
written out, and the node answers it with a null-id response. Every
batch carrying such an item was rejected with "expected N, got N+1" —
seen on two services within the hour of the fill shipping to canary,
and reproduced with a two-item batch against canary (500) and the
control environment (200, both answers).

Every request in the batch expects exactly one response, id or not. A
null-id response is matched to an id-less request first; only those
beyond that are the wildcards the id validation already allowed. An
id-less item that gets no answer is filled with a null-id error.

Fails on revert with the exact production shape.
…t a well-formed Response

A supplier's answer does not always unmarshal as a Response — an error
given as a bare string, an id of a type the parser rejects — but it is
still an answer to some item. The fill skipped such responses, took
the item they belonged to for missing, and added an error object on
top: the batch came out one long ("expected N, got N+1") and was
rejected whole. Residual on canary after the earlier fills: 4 in 51
minutes, all exactly +1.

Read only the id when attributing. A response with a readable id is
matched like any other; one with no readable id stands in for a
missing request the way a null-id response already does; invalid JSON
is dropped (it could not be placed in the array anyway) and the
request filled. The id validation reads ids the same way.

The length-mismatch error now lists the response ids (bounded), which
is the only thing that has ever told this failure's shapes apart in
production.

Fails on revert on both the unreadable-id and string-error shapes.
@oten91 oten91 changed the title fix(reputation,qos): stop health-check probes benching endpoints, plus cardinality/solana/archival follow-ups fix(reputation,qos,gateway): health-check bench loop, circuit-breaker gate, batch responses, plus cardinality/solana/archival follow-ups Aug 25, 2026
Operators commonly disable block_results at the node; every reachable
pocket supplier answers it with -32603 "block_results disabled", so the
pocket shard failed on every run of this branch and of main alike. The
other eight CometBFT methods stay.
@oten91
oten91 merged commit 4606957 into main Aug 25, 2026
11 of 12 checks passed
@oten91
oten91 deleted the fix/cardinality-followup-f5-f6 branch August 25, 2026 16:43
oten91 added a commit to pokt-network/sage that referenced this pull request Aug 26, 2026
…esis, batch item errors, solana index phrase

Fifth PATH catch-up: the 15 commits that landed in pokt-network/path#528
after our 2026-08-20 pass (sections 6–8 of that PR: circuit-breaker gate,
traffic sampler, batch responses). Five ported, the rest recorded N/A.

Circuit breaker: the failure-rate gate had one threshold to break a domain
and nothing but TTL expiry to restore it, so a host sitting just above 20%
was removed on every readmission, forever, with escalation holding it out
longer each cycle. PATH measured four marginal hosts spending 69–92% of a
six-hour window out of the pool while one of them answered 40 consecutive
probes clean. A domain that broke within escalationMemory must now be
clearly worse to break again (threshold + 0.15); the predicate is shared
with escalation so a domain can never be escalated for an episode the
margin let pass. Revert-checked: the flap test fails at margin 0.

sage_circuit_breaker_outcome_total{service_id, domain, outcome} exposes the
gate's own numerator and denominator per hostname, via a breaker hook so it
counts exactly what the gate counts (a broken domain is absent, not
healthy). The relay counters key on service; without this one bad host
behind an operator is indistinguishable from an operator bad everywhere.

Batch: a failed item was answered with {"error":{...}} — no jsonrpc, no id —
and an empty body with a bare null. Neither is a response object a client
can match to its request. Each such item now gets a JSON-RPC 2.0 error
response carrying the request's own id with its JSON type intact (an
id-less item gets null, as a node would), and the message goes through
domain.ClientMessage like the single-request path so the cause chain no
longer names the operator's fullnode. Payload.JSONRPCID replaces the
router's private extractJSONRPCID so both paths read the id the same way.
PATH's four other batch fixes (count mismatch, one-element retry, id-less
count, attribution by id) are N/A: SAGE assembles by index and never
matched responses to requests by id.

Retry: once the request context is done, stop. Each further attempt selected
and signed a relay that failed on arrival with the same context error, and
was recorded against a supplier for the client's hang-up. PATH's batch loop
had this bug on its circuit breaker; SAGE's breaker never sees transport
errors (only heuristic verdicts reach it), so the exposure here was wasted
signings plus reputation, not breaks.

Heuristic: Solana -32010 "<key> excluded from account secondary indexes" is
node configuration, not a fault — another operator serves the same call
from its index. It matched nothing, so the -32000-range default charged the
supplier a minor penalty on every poll of a dapp that never stops polling.
Added to the blockchain-attributed list at Tier 2 and to the Tier 3
indicators; deliberately NOT in capabilityLimitationPatterns, which the EVM
archival demotion path also reads.

N/A, recorded so the next pass does not re-derive them: hedge-race
successes missing from the gate (CircuitBreak sits inside Retry/Hedge here
and runs per attempt); observation-pipeline signal label (no such metric);
e2e block_results (not in SAGE's method set); go.mod bumps (already
aligned); GET /admin/request-sample (feature, parked for the admin pass).

Full -short -race suite, go vet, gofmt, golangci-lint clean.
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