Skip to content

feat: independent bandwidth throughput measurement (ADR-015, part of #73) - #90

Merged
flo2517 merged 5 commits into
mainfrom
worktree-agent-a66abc082a0237643
Aug 7, 2026
Merged

feat: independent bandwidth throughput measurement (ADR-015, part of #73)#90
flo2517 merged 5 commits into
mainfrom
worktree-agent-a66abc082a0237643

Conversation

@flo2517

@flo2517 flo2517 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Implements ADR-015 (added in this PR, docs/adr/015-bandwidth-throughput-measurement.md), the core "independent bandwidth measurement" slice of #73: a real, independently-verified bandwidth throughput probe for the Network Validator's challenge loop, replacing the generic SolveChallenge-based liveness check that dimension previously used.

This is a substantial slice of #73, not the whole thing — leaves #73 open for its explicitly-out-of-scope remainder: WireGuard overhead accounting, regional endpoint selection, workload-level rate limit enforcement, and the adversarial test suite (congestion, asymmetric links, spoofed results, partitions).

What changed

Protocol (protocol/proto/openinfra/agent/v1/agent.proto): new MeasureBandwidth RPC and MeasureBandwidthRequest/MeasureBandwidthResponse messages, exactly as specified in ADR-015 §1. Regenerated via make proto; generated Go committed.

Agent (provider-agent/crates/agent-api): implements measure_bandwidth — validates probe_id/payload bounds (MAX_BANDWIDTH_PROBE_BYTES = 8 MiB, distinct from and larger than MAX_CHALLENGE_PAYLOAD), generates a random (not zeroed) download_payload, computes SHA256(upload_payload), times server_processing_ms from right after into_inner() to just before building the response, and signs a domain-separated construction consistent with solve_challenge's existing signing style:

BANDWIDTH_PROBE_DOMAIN ++ be_u32(len(probe_id)) ++ probe_id
  ++ upload_payload_hash (32 bytes, fixed)
  ++ be_u32(len(download_payload)) ++ download_payload
  ++ be_u32(server_processing_ms)

Rate limiting: implemented real per-caller extraction (not the coarser global fallback ADR-015 allowed as a documented alternative). BandwidthRateLimiter is a fixed-window counter (10 calls/60s) keyed by the caller's raw Ed25519 public key, extracted from its mTLS leaf certificate via tonic::Request::peer_certs() — the same identity agent-cli's mtls.rs allowlist verifier already establishes trust on, reused here purely as a rate-limit key. The extraction logic is duplicated (not shared) from mtls.rs since agent-api cannot depend on agent-cli. Callers this handler can't identify share one fallback bucket rather than bypassing the limiter.

Validator (control-plane/internal/networkvalidator): ChallengeClient.MeasureBandwidth (new bandwidth.go) sends/requests an 8 MiB payload each way in one round trip, verifies the Agent's signature and hash (re-deriving the exact signed-byte construction from the Rust side), estimates ingress/egress Mbps by subtracting the Agent's server_processing_ms from the validator's own wall-clock round trip and splitting the remainder proportionally by each direction's byte count (a documented approximation — a single unary gRPC call doesn't expose separate write/read timing), and scores pass/fail (10,000 or 0 bps) against the provider's declared bandwidth with a 70% tolerance (ADR-015 §5's own suggested value, adopted as the actual threshold).

Declared bandwidth is threaded through the existing agent-endpoint discovery path: the dashboard's GET /api/v1/agent-endpoint/{provider_id} response now includes bandwidth_ingress_mbps/bandwidth_egress_mbps, read from the same Redis heartbeat cache the overview endpoint's capability display already reads (the same live directory data the scheduler uses, per ADR-015 §5). Fields are omitted (not zero-valued) when no fresh heartbeat data exists, so "nothing declared" is distinguishable from "declared 0 Mbps"; the tolerance check treats both as "nothing to verify against" and passes trivially either way.

run.go is wired so the Network dimension now calls MeasureBandwidth instead of (not in addition to) the generic Challenge()/SolveChallenge path every other dimension still uses — a deliberate, documented behavior change to already-shipped code (#85), confirmed by a loop-level test.

A real bug caught by testing: gRPC's default 4 MiB per-message limit (both grpc-go and tonic) rejected an 8 MiB probe outright. Fixed by raising the limit on both sides — agent-cli's tonic server (.max_decoding_message_size/.max_encoding_message_size, sized off agent-api's newly-public MAX_BANDWIDTH_PROBE_BYTES) and this package's grpc-go dial options (bandwidthMessageSizeLimit).

Key implementation decisions

  • Signed byte layout (agent-api's bandwidth_signed_bytes, mirrored exactly by the Go side's bandwidthSignedBytes): BANDWIDTH_PROBE_DOMAIN ++ be_u32(len(probe_id)) ++ probe_id ++ upload_payload_hash (32 bytes, fixed) ++ be_u32(len(download_payload)) ++ download_payload ++ be_u32(server_processing_ms). Mirrors solve_challenge's existing convention (domain constant, then length-prefixed/fixed-width fields) so both RPCs share one consistent signing style.
  • Rate limiting: real per-caller extraction via mTLS peer certificates (see above), not the coarser global fallback — this turned out to be feasible since tonic::Request::peer_certs() is already reachable from an agent-api handler once compiled into the agent-cli binary (feature unification supplies tonic's "tls" feature).
  • Probe size / tolerance: MAX_BANDWIDTH_PROBE_BYTES = 8 MiB (ADR-015 §3's own example), and the validator always sends/requests the full 8 MiB in both directions (maximizes timing resolution). Tolerance is 70% in both directions (ADR-015 §5's example, adopted as the actual threshold) — generous enough to absorb this measurement's documented coarseness (one HTTP/2 stream, no multi-connection saturation, no slow-start removal, proportional time-split approximation) while still catching a materially false declaration.

Explicit scope boundaries

  • blockchain/ is untouched (git diff --stat -- blockchain/ is empty) — evidence submission's on-chain shape is unchanged, only how Network-dimension evidence is produced.
  • Does not implement WireGuard overhead accounting, regional endpoint selection, workload-level rate limit enforcement, or the adversarial test suite — all explicitly out of scope per ADR-015, tracked in the remainder of Independent, abuse-resistant bandwidth measurement and enforcement (remainder of #30) #73.

Verification

  • make proto: clean, generated Go bindings committed and matching (git diff --exit-code -- protocol/generated passes).
  • provider-agent/: cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace — all genuinely run, all clean (13 new tests in agent-api, all passing).
  • control-plane/: gofmt -l . (clean), go build ./..., go vet ./..., go test ./... — all genuinely run and clean, including against this session's real running dev-stack Postgres/Redis containers (OPENINFRA_TEST_DATABASE_URL/OPENINFRA_TEST_REDIS_URL) for the Postgres/Redis-gated dashboard tests.
  • blockchain/: untouched, confirmed via git diff --stat -- blockchain/ (empty). blockchain's own cargo clippy could not be run in this sandbox at all (missing libclang/llvm-config, a pre-existing, already-documented environment limitation unrelated to this change).
  • Live full-stack verification (rebuilding/redeploying the provider-agent container against the running dev stack) was not attempted: no provider-agent container was running in this session's stack to begin with, and standing one up plus a matching control-plane rebuild for the new dashboard field was judged a significant detour beyond this task's scope, per the task's own "don't block on this" guidance.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

florianjeandenans-tech and others added 4 commits August 7, 2026 13:42
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds ProviderAgentService.MeasureBandwidth and its request/response
messages exactly as specified in ADR-015 sec1. Regenerated Go bindings
via make proto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements agent-api's measure_bandwidth handler: validates probe_id
(MAX_CHALLENGE_ID) and payload/requested-size bounds (new
MAX_BANDWIDTH_PROBE_BYTES = 8 MiB), generates a random (not zeroed)
download_payload, computes SHA256(upload_payload), times
server_processing_ms after into_inner() and before building the
response, and signs a domain-separated construction consistent with
solve_challenge's existing signing style:

  BANDWIDTH_PROBE_DOMAIN ++ be_u32(len(probe_id)) ++ probe_id
    ++ upload_payload_hash (32 bytes, fixed)
    ++ be_u32(len(download_payload)) ++ download_payload
    ++ be_u32(server_processing_ms)

Adds a per-caller rate limiter (BandwidthRateLimiter, fixed window,
10 calls/60s) scoped to just this RPC, keyed by the caller's raw
Ed25519 public key extracted from its mTLS leaf certificate via
tonic::Request::peer_certs() -- real per-caller extraction, not the
coarser global fallback ADR-015 allowed, mirroring agent-cli's
mtls.rs allowlist verifier's identical extraction logic (duplicated,
not shared, since agent-api cannot depend on agent-cli). Callers this
handler cannot identify share one fallback bucket rather than
bypassing the limiter.

Threads a new bandwidth_rate_limiter field through AgentGrpcServer's
construction in agent-cli/src/main.rs.

Tests: valid probe succeeds and its hash/signature verify against a
real keypair; oversized upload_payload / requested_download_bytes
rejected; empty probe_id rejected; download_payload length exactly
matches the request; two concurrent calls sharing one probe_id do not
cross-talk; the rate limiter blocks a caller once its window budget is
exhausted and a distinct caller has an independent budget; the raw-
key extraction helper is verified against a real rcgen-generated
self-signed certificate.

cargo fmt --check, cargo clippy --workspace --all-targets -D warnings,
and cargo test --workspace all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… loop (ADR-015)

Adds ChallengeClient.MeasureBandwidth (internal/networkvalidator/
bandwidth.go): sends an 8 MiB random upload payload and requests an
8 MiB download payload in one round trip, verifies the Agent's
signature/hash (bandwidthSignedBytes reproduces agent-api's exact
signed-byte construction), estimates ingress/egress Mbps by
subtracting the Agent's reported server_processing_ms from the
validator's own wall-clock round trip and splitting the remainder
proportionally by direction's byte count (estimateThroughputMbps --
a documented approximation, per ADR-015 sec2's own accepted
coarseness), and scores pass/fail (10_000 or 0 bps) against the
provider's declared ResourceCapability.Bandwidth with a 70% tolerance
(bandwidthToleranceBps, ADR-015 sec5's own example value).

Wires this into run.go: the Network dimension now calls
MeasureBandwidth instead of the generic SolveChallenge-based
Challenge() every other dimension still uses -- a deliberate,
documented behavior change to already-shipped code (#85).

Threads declared bandwidth capacity through: the dashboard's
GET /api/v1/agent-endpoint/{provider_id} response (internal/
dashboard/agentendpoint.go) now includes bandwidth_ingress_mbps/
bandwidth_egress_mbps, read from the same Redis heartbeat cache
(openinfra:heartbeat:<provider_id>) the overview endpoint's
capability display already reads -- the same live directory data
the scheduler uses, per ADR-015 sec5. Omitted (not zero-valued) when
no fresh heartbeat capability data exists yet, so "nothing declared"
is distinguishable from "declared 0 Mbps"; MeasureBandwidth's
tolerance check treats both as "nothing to verify against" and
passes trivially either way. AgentEndpoint (endpoint.go) carries the
decoded fields through to the challenge loop.

Also fixes a real bug caught by the new tests: gRPC's default 4 MiB
per-message limit (both grpc-go and tonic) rejects an 8 MiB probe
outright. Raises the limit on both sides: agent-cli's tonic server
(ProviderAgentServiceServer::max_decoding/encoding_message_size, sized
off agent-api's newly-public MAX_BANDWIDTH_PROBE_BYTES) and this
package's grpc-go dial options (bandwidthMessageSizeLimit).

Tests: a fake Agent (extended fakeAgentServer, shared with
challenge_test.go) proves score 10_000 when measured throughput
comfortably exceeds a low declared tolerance and 0 against an
unreachably high declared figure; tampered upload_payload_hash and
tampered signature both score 0; an unreachable Agent scores 0 with a
reason, never a Go error; passesBandwidthTolerance/
estimateThroughputMbps are unit-tested directly; the end-to-end loop
test (run_test.go) now asserts exactly one MeasureBandwidth call and
confirms SolveChallenge is still called exactly once for each of the
other four dimensions and never for TYPE_NETWORK. Dashboard tests
cover the new field being omitted without a Redis client and
populated from a real heartbeat cache entry (Postgres/Redis-backed,
gated on OPENINFRA_TEST_DATABASE_URL/OPENINFRA_TEST_REDIS_URL per this
package's existing convention).

gofmt -l ., go build ./..., go vet ./..., and go test ./... all pass
(both with and without the Postgres/Redis-backed integration tests
enabled, against this session's running dev-stack containers).
blockchain/ is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@flo2517

flo2517 commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Independently reviewed and re-verified before merging (this session's established standard for Network Validator PRs — not taking the implementation's own report on faith):

Byte-layout cross-check. Compared bandwidth_signed_bytes (Rust) against bandwidthSignedBytes (Go) directly, field by field, against the actual solve_challenge/signedChallengeBytes convention already shipped in main — identical domain-prefix-then-length-prefixed-fields construction, correctly mirrored on both sides.

Independently re-ran everything, not just read the PR's own report:

  • control-plane: gofmt -l ., go build ./..., go vet ./..., and go test ./internal/networkvalidator/... ./internal/dashboard/... (against this session's real Postgres/Redis/local-chain containers) — all clean, all genuinely executed here.
  • provider-agent: cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace — all clean, 13/13 new agent-api tests passing.
  • protocol: buf lint clean, buf generate produces a zero diff against the committed generated Go — genuinely in sync, not stale.
  • git diff --stat -- blockchain/ confirmed empty.

Security properties checked directly, not assumed:

  • Rate limiting happens before any payload parsing/hashing work (checked the handler body's ordering).
  • Payload/size bounds (MAX_BANDWIDTH_PROBE_BYTES) enforced before any allocation beyond the bound.
  • gRPC message-size limits raised consistently on both the Rust server side and the Go client dial options — a mismatch here would have silently broken the whole RPC, checked both files side by side.
  • caller_public_key's unknown-caller fallback shares one bucket rather than bypassing rate limiting — correct fail-safe direction.
  • Real end-to-end signature-tampering tests (not just unit tests of the pure function) on both the Rust handler and the Go verification path.

One non-blocking design note for future hardening, not a defect: passesBandwidthTolerance auto-passes when a provider has no declared bandwidth in its heartbeat cache (declaredMbps <= 0). This is honestly reasoned in the code comment ("nothing to catch as a false declaration") and in practice heartbeats always populate ResourceCapability.Bandwidth from real sysinfo data, so it's a narrow edge case (stale/missing heartbeat), not a live gaming vector — but it's worth tracking alongside #73's already-flagged adversarial-test-suite remainder, since a provider with degraded heartbeat data currently gets a free pass on this dimension rather than an honest "unscored" state.

Merging.

@flo2517
flo2517 merged commit 52fbddd into main Aug 7, 2026
4 checks passed
@flo2517
flo2517 deleted the worktree-agent-a66abc082a0237643 branch August 7, 2026 12:41
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.

2 participants