Skip to content

Relay proto: well-known endpoint resolver + RAMP* prefix purge - #14

Closed
KonstantinMirin wants to merge 9 commits into
feature/stateless-offer-redemptionfrom
feature/ramp-102-relay-proto
Closed

Relay proto: well-known endpoint resolver + RAMP* prefix purge#14
KonstantinMirin wants to merge 9 commits into
feature/stateless-offer-redemptionfrom
feature/ramp-102-relay-proto

Conversation

@KonstantinMirin

@KonstantinMirin KonstantinMirin commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Relay proto: well-known endpoint resolution, items-only execute, RAMP* prefix purge

Brings the two-phase agent→Broker→Exchange relay onto the modern protocol shape: an agent reaches an arbitrary Exchange from a signed Offer.exchange + standard well-known alone, the execute contract collapses to a single items-only mode, and the redundant RAMP* prefixes inside the ramp.v1 namespace are purged.

Why this exists / motivation

A signed Offer.exchange (the discovery-remodel field from PR #11) tells an agent which Exchange issued an offer, but the protocol had no key-free way to turn that domain into a reachable address. WellKnownManifest.endpoint (field 12) was defined but inert — nothing read it. A Broker relaying an agent's ExecuteTransaction therefore still depended on an out-of-band endpoint hint (the ext['ramp.broker.offer_routes'] / X-RAMP-Exchange-Endpoint placeholder the platform ships today). That breaks the moment offers from more than one Exchange appear in a single discovery result: routing must derive from the signature-covered Offer.exchange, not from a side channel the agent or broker can tamper with.

This PR closes that gap on the protocol/SDK side. Routing now flows purely from the signed Offer.exchange: resolve the host's /.well-known/ramp.json, read its self-advertised endpoint, dial that. The pre-seeded registry becomes a trust overlay (an Allow hook), never the source of the endpoint — the endpoint always comes from the Exchange's own manifest.

Two contract simplifications ride along, both pre-release with no backward-compat obligation:

  • Items-only execute. TransactionRequest/TransactionResponse carried a single-offer mode and a batch mode, gated by a offer_xor_items CEL rule, with the per-result data duplicated between top-level scalars and per-item entries. Two shapes for one concept is a permanent tax on every consumer (Exchange, Broker, MCP shim, SDK, conformance corpus). A single offer is just the degenerate 1-element items list, so the single-offer mode is removed outright.
  • RAMP* prefix purge. Inside a package already named ramp.v1, RAMPRequest/RAMPResponse stutter. They become DiscoveryRequest/DiscoveryResponse. The ramp.v1 namespace and its rampv1 / rampv1connect bindings are deliberately kept — those are the legitimate package identity, not redundant.

Stacking / dependency note

This PR is stacked, not based on main:

#14 (this) feature/ramp-102-relay-proto
   └─ base: feature/stateless-offer-redemption
        #13  stateless offer redemption (reflect full signed Offer in execute)
           └─ base: feature/ramp-sdk
                #12  Go L1 protocol helpers (sdk/go/ramphelpers) on L0
                   └─ … #10 → #8 → main
  • PR #13 is the direct base. It made execute reflect the full signed Offer as a stateless bearer token; the items-only collapse here builds directly on that reflected-offer shape.
  • PR #12 introduced the Go L1 helpers as sdk/go/ramphelpers; this PR renames that module to sdk/go/helpers.
  • PR #11 (open, parallel) is the discovery-remodel that models Offer.exchange and offer_groups; the endpoint resolver here is what finally makes Offer.exchange actionable.

Review #13 first; this PR's diff is cleanest read on top of it. Each parent must merge and the next re-base before this one merges.

Data flow — what this PR changes

The relay is: agent discovers signed offers → self-selects → signs an acceptance → Broker co-signs and relays the execute to the issuing Exchange → Exchange verifies and returns a signed URL. This PR changes the routing leg (how the Broker finds the Exchange) and the execute message shape.

agent: DiscoveryResponse.offer_groups → full signed Offers, each carrying Offer.exchange (signature-covered)
  └─ self-selects an offer; SignOfferAcceptance over the reflected execute body
       └─► Broker relays ExecuteTransaction
              │  ROUTING (new): host = Offer.exchange
              │    WellKnownEndpointResolver.ResolveEndpoint(ctx, host)
              │      └─ GET https://{host}/.well-known/ramp.json  (host-keyed cache, TTL, single-flight)
              │           └─ WellKnownManifest.endpoint (field 12)  ── dial THIS, never a hint header
              │      Allow overlay may refuse a host pre-network; ErrNoEndpoint ≠ transport failure
              │  SHAPE (new): TransactionRequest.items[] (min 1); single offer = 1-element list
              └─► Exchange verifies each item's reflected offer.signature → signed URL bound to agent
                    └─► TransactionResponse.items[] (per-offer results) + shared aggregate (total_cost, quota, …)

The key shift: the Exchange address is now derived inside the SDK from the signed offer, sharing the exact fetch/decode path the key resolver already uses, so the endpoint and key faces of a manifest cannot drift. No production endpoint hint is on the protocol's critical path anymore.

Concrete changes (reading the diff)

Proto — proto/ramp/v1/ramp.proto

  • TransactionRequest: delete offer = 8 and agent_acceptance = 9; delete the transaction_request.offer_xor_items message CEL; add (buf.validate.field).repeated.min_items = 1 to items = 7; reserved 8, 9.
  • TransactionResponse: delete the single-mode top-level result fields (transaction_id=3, billing_id=4, resource_title=5, cost=6, delivery_method=7, reporting_obligation=8, expires_at=9, subscription_id=12, subscription_unit_value=16, retrieval_endpoint=18); reserved 3,4,5,6,7,8,9,12,16,18. Per-result data lives only in items[] (TransactionResultItem); the top level keeps the shared aggregate (ver, agent_identity_hash, items, total_cost, subscription_quota, ext). Per-item denials remain in-body as partial results of a successful request.
  • RAMPRequest/RAMPResponseDiscoveryRequest/DiscoveryResponse (the Resolve RPC signature and doc comments follow).

SDK — new sdk/go/helpers/endpointresolver.go

  • WellKnownEndpointResolver with ResolveEndpoint(ctx, host) → endpoint. Host-keyed (unlike the single-URL WellKnownKeyResolver): per-host cache, TTL freshness, single-flight coalescing — because a Broker resolves an arbitrary signature-covered Offer.exchange per request.
  • Shared fetchWellKnownDoc + wellKnownDoc decode the whole manifest once, serving both the key face and the endpoint face (jscpd-zero — no duplicated HTTP/status/decode logic).
  • ErrNoEndpoint distinguishes a reachable-but-endpointless manifest from a transport/decode failure; the Allow overlay refuses a disallowed host before any network call.
  • WellKnownOptions gains Scheme (defaults to https).

SDK — wide mechanical rename sdk/go/ramphelpers/ → sdk/go/helpers/ (package helpers)

  • Pure path/package rename across the L1 helper set (acceptance, offer, sign, verify, keyresolver, multisig_chain, signedurl, thumbprint, … and their tests). Diff shows these as git renames with import/package-line churn only — no behavior change. sdk/go/README.md and doc.go updated to the new path.
  • New tests: host-keyed resolver coverage (two-host isolation, cache hit, TTL refresh, non-200, decode failure, missing-endpoint); plus Offer.exchange-tamper anti-redirect and acceptance economics-rebound audit regressions.

Generated + docs

  • Regenerated gen/{go,ts,python} + gen/descriptor.binpb + conformance corpus (conformance/corpus/*.json, corpusgen) + Astro docs under website/. The conformance/SDK tests migrate off the removed fields and the offer_xor_items rule (items-min-1 replaces it).

Out of scope / follow-ups

  • Platform re-pin. The broker / MCP shim / exchange (agentic-content-access) re-pin their go.mod to this commit once merged, and migrate execute call sites from single-offer fields to items[] and from the endpoint hint to WellKnownEndpointResolver. Tracked separately on the platform side.
  • The Offer.exchange field and offer_groups themselves landed in PR #11; this PR consumes them, it does not introduce them.

…e body (RAMP-102)

Add AgentAcceptance{signature,signature_algorithm} carried on both
TransactionRequest (single mode, field 9) and TransactionItem (batch,
field 4), optional on the wire. It is the agent's DETACHED, content-bound
acceptance over an accepted Offer — topology-independent (survives broker
relays) and distinct from the transport RFC 9421 request signature.

The canonical signed bytes are pinned by a dedicated AgentAcceptancePayload
message {offer_sig, requester_id, requester_domain, idempotency_key} that is
never sent on the wire: signer and verifier deterministically marshal it to
derive byte-identical bytes, so the contract cannot drift. Registered as an
out-of-band reachability root for that reason.

Regenerated gen/{go,ts,python}+descriptor, validation corpus, and the
reference page; ci-local green.
…AMP-56)

Extend L1 signing/verification from single 'sig1' to N labels, where
single-sig is exactly the N=1 case of one shared code path (byte-for-byte
identical sig1 output, pinned by golden signature-base tests).

- sigbase.go: CoveredComponent/ComponentParam + renderComponent; widen
  sigParams.Covered to []CoveredComponent; chainLinkValue re-encodes the
  referenced predecessor member canonically (whitespace-drift proof).
- sign.go: AppendSignature(signer) chains sig(N+1) over the prior label via
  a 'signature';key='sigN' covered link; BrokerKeyIDPrefix; shared
  signWithParams set/append modes.
- verify.go: multi-label parsers, enforceSignatureChain (structural) + per-sig
  Ed25519 (crypto) binding, VerifyMultisigRequest, MaxSignatures hop bound,
  ErrTooManyHops/ErrBrokenSignatureChain.
- keyresolver.go: VerifyMultisigRequestResolved (per-hop key resolution).
- context.go: pure context plumbing (multisig slot + single-slot fallback);
  no Middleware (L1 stays IO-free per ADR-020).

Re-expressed from the proven platform internal/httpsig core; does not fork it.
ci-local green; existing single-signer tests unchanged.

NOTE: VerifiedRequest.Signature now carries the per-label base64 value (was the
whole Signature header) — platform consumers re-pinning should account for it.
…AMP-102)

The {verified,rejected} primitive for the agent's detached offer acceptance.
Both sign and verify go through canonicalAcceptancePayload — a deterministic
marshal of the AgentAcceptancePayload proto message {offer_sig, requester_id,
requester_domain, idempotency_key} — so the agent signer (R8/MCP) and the
Exchange verifier (R4) derive byte-identical bytes and cannot drift. Mirrors
the offer.go SignOffer/VerifyOffer family (raw Ed25519 key, hex signature).

Fail-closed on an unsigned offer (empty offer signature would let the
acceptance float free of a concrete offer). Tamper of offer_sig/requester/
domain/idempotency or a wrong key -> ErrAcceptanceSignatureInvalid.
…MP-56)

The Broker's BrokerService/Resolve becomes discovery-only on the modern two-
phase flow: it discovers and ranks Exchange-signed Offers but does not execute
(the agent originates the execute itself via the relay route). Add
repeated OfferGroup offer_groups = 2 to RAMPResponse to carry the ranked
discovered offers; the execute-shaped fields stay for the legacy path but are
not populated by a discovery-only Resolve. Additive; regen + ci-local green.
…MP-101)

Cherry-picks the single Offer.exchange field from PR #11 (feature/discovery-
offer-list) onto the relay line so multi-Exchange fan-out (RAMP-102 R10) can
route the per-offer ExecuteTransaction from the offer itself instead of the
X-RAMP-Exchange-Endpoint transport header. exchange = 8 (free; matches PR #11).
As an ordinary Offer field it is inside the signed bytes (canonicalOfferPayload
clears only signature/signature_algorithm), so an intermediary cannot redirect
the execute to a different Exchange without invalidating the offer. Regen +
ci-local green.
…e (RAMP-102)

Close the defined-but-inert WellKnownManifest.endpoint gap so an agent/broker
can reach an arbitrary Exchange from a signed Offer.exchange + standard
well-known alone, and purge the redundant RAMP* prefixes inside the ramp.v1
namespace (ramp.v1 / rampv1 / rampv1connect kept as the legitimate binding).

- sdk: host-keyed WellKnownEndpointResolver (ResolveEndpoint(ctx, host) ->
  WellKnownManifest.endpoint) sharing a single full-manifest fetch/decode with
  WellKnownKeyResolver (jscpd-zero); WellKnownOptions gains Scheme; ErrNoEndpoint
  distinguishes a reachable-but-endpointless manifest from transport failure.
- sdk: rename module path sdk/go/ramphelpers -> sdk/go/helpers (package helpers).
- proto: RAMPRequest/RAMPResponse -> DiscoveryRequest/DiscoveryResponse; regen
  gen/{go,ts,python} + descriptor + conformance corpus + docs.
- tests: host-keyed resolver coverage (two-host isolation, cache, TTL, non-200,
  decode, missing-endpoint); offer Exchange-tamper + acceptance economics-rebound
  audit regressions.
…proto

Final hop of the cascade — main + protocol-unification + SDK types + L1 +
stateless-offer-redemption flow into the relay-proto branch. Clean auto-merge of
ramp.proto (RAMP-102 relay changes coexist); gen/ and both corpora regenerated
and unchanged.
…P-102)

Remove single-offer mode from the execute contract (epic 6afpc, pre-release, no
backward-compat). A single offer is now the degenerate 1-element items[].

- TransactionRequest: delete offer=8 + agent_acceptance=9 + the offer_xor_items
  message CEL; add repeated.min_items=1 to items=7; reserve 8,9.
- TransactionResponse: delete the single-mode top-level result fields
  (transaction_id=3, billing_id=4, resource_title=5, cost=6, delivery_method=7,
  reporting_obligation=8, expires_at=9, subscription_id=12,
  subscription_unit_value=16, retrieval_endpoint=18); reserve them. Per-result
  data lives only in TransactionResultItem; the top level keeps the shared
  aggregate (ver, agent_identity_hash, items, total_cost, subscription_quota,
  ext).
- regenerate gen/go + descriptor + validation corpus; migrate the conformance +
  SDK tests off the removed fields/rule (items-min-1 replaces offer_xor_items).

ci-local.sh green (buf lint, zero gen/corpus drift, go build/vet/test, doc
conformance, docs guards).
@KonstantinMirin KonstantinMirin changed the title RAMP-102: relay proto + well-known endpoint resolver + RAMP* prefix purge Relay proto: well-known endpoint resolver + RAMP* prefix purge Jun 23, 2026
…02-relay-proto

# Conflicts:
#	conformance/corpus/cases.json
#	conformance/descriptor_invariants_test.go
#	conformance/reachability_test.go
#	conformance/validate_test.go
#	gen/descriptor.binpb
#	gen/go/ramp/v1/ramp.pb.go
#	gen/go/ramp/v1/rampv1connect/ramp.connect.go
#	gen/python/wire/models.py
#	gen/ts/wire/schemas.ts
#	proto/ramp/v1/ramp.proto
#	sdk/go/ramphelpers/constants_test.go
#	website/src/content/docs/components/broker/deployment.mdx
#	website/src/content/docs/components/broker/overview.mdx
#	website/src/content/docs/components/broker/selection-engine.mdx
#	website/src/content/docs/protocol/authentication.mdx
#	website/src/content/docs/protocol/discovery-paths.mdx
#	website/src/content/docs/protocol/extension-profiles.mdx
#	website/src/content/docs/protocol/scenario-walkthrough.mdx
@KonstantinMirin

Copy link
Copy Markdown
Contributor Author

Superseded by and included in #17. This PR's commits are ancestors of feature/go-sdk-extraction — every commit here is already contained in #17, which is the authoritative consolidation of the SDK-extraction line against main. Closing as obsolete; the branch is retained so these commits stay diffable.

legendko added a commit that referenced this pull request Jul 9, 2026
SDK extraction (go/ts/python L1+L2) on snake_case wire; consolidates #12/#13/#14/#15
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