Broker Overview
What the Broker Does
Section titled “What the Broker Does”The FORA Broker is the optional demand-side router in the FORA protocol. It sits with or near the AI agent, queries multiple Exchanges in parallel, compares offers, enforces budget, and selects the best deal. It is the “DSP” of metered resource access.
The Broker is optional. Agents can talk directly to Exchanges using the same DiscoverResources/ExecuteTransaction/UsageReport RPCs. The Broker adds value through:
- Multi-exchange comparison — fan out DiscoverResources to N Exchanges, pick cheapest
- Budget enforcement — per-request, per-session, per-Exchange cumulative tracking
- Resource deduplication — same article from two Exchanges, compare price
- Attestation-aware selection (v1.0) — prefer offers with higher attestation levels (Level 2 > Level 1 > Level 0)
- Reporting relay — forward UsageReports to the correct Exchange, track
report_idfor dispute chain - Dispute relay (v1.0) — forward
DisputeRequestmessages to the correct Exchange - Selection transparency — logged rationale for every selection decision
The Exchange does not know or care whether the request came from a Broker or directly from an agent. The protocol is identical.
Component Architecture
Section titled “Component Architecture”The Broker is composed of five internal components. Each is a Go interface with a concrete implementation. No inter-component communication uses the network; everything is in-process function calls.
Component Responsibilities
Section titled “Component Responsibilities”| Component | Responsibility | State |
|---|---|---|
| Exchange Registry | Knows which Exchanges exist and their health status. Bootstraps from Exchange Manifest (/.well-known/fora.json) instead of HEAD probes — extracting endpoints, capabilities, and accepted verifiers in a single fetch | Long-lived, refreshed periodically |
| Supply Discovery | Fans out DiscoverResources RPCs to N Exchanges concurrently | Per-request, stateless |
| Selection Engine | Deduplicates, ranks, filters, and selects the best offer | Per-request, stateless |
| Budget Tracker | Tracks cumulative spend per session/period and enforces limits | Per-session in-memory, per-period in Redis |
| Reporting Relay | Forwards UsageReports to the winning Exchange, tracks obligations | Per-transaction, short-lived |
Internal Call Flow
Section titled “Internal Call Flow”Resolve is discovery-only. It gathers offers across Exchanges, selects the best per URI, and returns them as a DiscoveryResponse (one OfferGroup per requested URI). It does not execute or deliver — committing to an offer is a separate ExecuteTransaction call on the Exchange (see Execution below).
func (o *Broker) Resolve(ctx context.Context, req *forav1.DiscoveryRequest) (*forav1.DiscoveryResponse, error) { // 1. Resolve which Exchanges to query exchanges := o.registry.Resolve(req.Constraints)
// 2. Fan out DiscoverResources to all Exchanges (multi-URI: all URIs in one query per MP) responses := o.discovery.FanOut(ctx, exchanges, req)
// 3. Build one OfferGroup per requested URI: dedup, rank, and budget-filter // the gathered offers for that URI. A URI with no viable offer is not an // error — its group carries absence_reason and an empty offers list // (mirrors OfferGroup from DiscoverResources). Non-OK errors are reserved // for malformed requests, auth failures, and internal faults. groups := make([]*forav1.OfferGroup, 0, len(req.Uris)) for _, uri := range req.Uris { selection, err := o.selector.SelectForURI(ctx, uri, responses, req.Constraints, o.budget) if err != nil { groups = append(groups, &forav1.OfferGroup{ Uri: uri, AbsenceReason: o.absenceReason(err).Enum(), }) continue } groups = append(groups, &forav1.OfferGroup{ Uri: uri, Offers: selection.Offers, // full signed Offers, forwarded unchanged }) }
// 4. Return the offers to the agent. The agent (or the Broker, on the agent's // behalf) commits separately via ExecuteTransaction — see below. return &forav1.DiscoveryResponse{ OfferGroups: groups, }, nil}Execution (separate step)
Section titled “Execution (separate step)”After the agent picks an offer from the DiscoveryResponse, committing to it is a distinct exchange on the execute path. The execute target is Offer.exchange — the canonical Exchange domain carried inside the signed Offer bytes, so a relaying Broker cannot redirect execution without invalidating the signature.
// Execute commits to a chosen offer. Called by the agent directly, or by the// Broker on the agent's behalf. The per-transaction result — transaction_id,// billing_id, delivery_method, retrieval_endpoint, agent_identity_hash,// expires_at — is carried by TransactionResponse, not by DiscoveryResponse.func (o *Broker) Execute(ctx context.Context, offer *forav1.Offer, req *forav1.DiscoveryRequest) (*forav1.TransactionResponse, error) { txn, err := o.discovery.Execute(ctx, offer.Exchange, offer, req) // routes to Offer.exchange if err != nil { return nil, err }
// Track budget and reporting obligation off the transaction result. o.budget.Record(ctx, req.SessionID(), txn.Cost) o.reporter.Track(txn)
return txn, nil}Mixed-Exchange commits: fan-out is projection
Section titled “Mixed-Exchange commits: fan-out is projection”When the agent commits to offers from more than one Exchange in one go, there is still no new RPC: the flow is Resolve for discovery, then one ExecuteTransaction per Exchange named in the chosen set. What holds the set together is TransactionRequest.agent_request_acceptance — a detached Ed25519 signature by the agent over the complete ordered list of (offer signature, exchange) references plus the requester and the idempotency key.
The sequence:
- The agent (the holder of the signing key) commits to the whole chosen set at once: it signs one
AgentRequestAcceptancecovering every selected offer, in order, across all Exchanges. - Whoever executes — the agent directly, or the Broker on its behalf — sends one
ExecuteTransactionto each Exchange in the set. Each subrequest carries only the items addressed to that Exchange, plus the unchanged acceptance envelope, plus the same idempotency key. - Each per-Exchange subrequest is a new HTTP request its sender authors and RFC 9421-signs. When the Broker executes, the Broker is the authenticated wire caller of every subrequest; the agent’s authorization travels in the body as the detached acceptance signatures, which survive projection because they never depended on the HTTP envelope. See Projected execute requests for who signs what and how the Exchange resolves the acceptance verification key.
- Each Exchange verifies the acceptance signature and requires its subrequest to equal the complete in-order projection of the signed set addressed to itself — so the Broker cannot drop, reorder, append, or split the items for that Exchange without being refused.
A single-Exchange commit is the degenerate case: the projection is the whole set, and the SDK clients attach the acceptance there too.
Request Signing and the Forwarding Signature Stack
Section titled “Request Signing and the Forwarding Signature Stack”FORA uses a two-layer signing model: the agent’s request signature proves who originated the request, and the forwarding signature stack proves which intermediaries forwarded it.
Request Authentication
Section titled “Request Authentication”The Requester message carries identity and entitlements only (id, domain, type, scopes, optional delegation) — it has no signature field. The request is authenticated by an RFC 9421 HTTP Message Signature (alg=ed25519) computed over the whole HTTP request and carried in the headers. The Exchange verifies it against the public key published in the signer’s WBA directory at {domain}/.well-known/http-message-signatures-directory, with the domain resolved from the covered Signature-Agent header.
The Broker passes Requester through unchanged — it never modifies the requester’s identity, scopes, or delegation — and adds its own forwarding signature in the headers (see below).
Forwarding Signature Stack (RFC 9421)
Section titled “Forwarding Signature Stack (RFC 9421)”Multi-hop forwarding is carried entirely in the HTTP headers, not in the message body. Each forwarding party adds one labeled RFC 9421 HTTP Message Signature covering the request and the prior hop’s signature. The ordered set of these labeled signatures is the forwarding chain — there is no intermediaries field on ResourceQuery.
Each labeled signature, via its RFC 9421 signature parameters, effectively records:
keyid— the intermediary’s RFC 7638 thumbprint, resolved against its canonical domain (for public key lookup)created— timestamp of when the intermediary forwarded the request- the covered components — the request plus the preceding hop’s
Signaturevalue, chaining the hops together
When the agent queries an Exchange directly, there are no forwarding signatures. When the request passes through a Broker, the Broker adds one labeled signature to the stack.
// Broker adds one labeled RFC 9421 signature to the outgoing request headers when forwarding.func (o *Broker) signHop(req *http.Request, priorSig string) { // Covers the request line/headers AND the prior hop's Signature value, // chaining this hop to the one before it. label := o.domain // e.g., "broker.example.com" sigInput, sig := o.rfc9421Sign(req, priorSig, label) // Ed25519, keyid=o.thumbprint, created=now req.Header.Add("Signature-Input", sigInput) req.Header.Add("Signature", sig)}Exchange Verification
Section titled “Exchange Verification”The Exchange verifies both layers:
- Requester signature — verified against the public key in the agent’s WBA directory at
{domain}/.well-known/http-message-signatures-directory(domain from the coveredSignature-Agentheader, key selected by thekeyidthumbprint) - The forwarding signature stack — each labeled RFC 9421 signature is verified against the key its
keyidthumbprint selects from the key set the Exchange is configured with (Signature-Agentstays the agent’s directory, so a Broker’s key is not discovered through it), and the chained coverage is checked hop-by-hop.
All signatures must pass. The Exchange counts the number of forwarding hops in the stack against max_hops / max_intermediary_hops and rejects requests that exceed the limit.
The Broker publishes its keys in its WBA directory (the JWK Set at {domain}/.well-known/http-message-signatures-directory) — RFC 7517 Ed25519 JWKs, each identified by its RFC 7638 thumbprint (the RFC 9421 keyid). Its fora.json manifest uses the same format as agents (role=ROLE_AGENT). An Exchange verifies the Broker’s signatures against keys it has loaded from that directory into its configured key set. The covered Signature-Agent header stays the agent’s directory and does not point at the Broker’s, so the Exchange must know the Broker — for example, through registration — before it can verify the hop.
Profile-Based Routing
Section titled “Profile-Based Routing”The Broker uses domain profiles to route queries to the right Exchanges.
How It Works
Section titled “How It Works”- Agent declares profiles —
DiscoveryRequest.supported_profileslists which domain extension profiles the agent understands (e.g.,["fora-academic-v1"]) - Broker filters Exchanges — the Exchange Registry checks each Exchange’s
WellKnownManifest.supported_profilesand routes queries only to Exchanges that support at least one of the requested profiles - Broker forwards profiles — the declared profiles are forwarded in
ResourceQuery.supported_profiles, so the Exchange knows which profile-specificextfields to include in offers - Exchange responds with profile data — the Exchange includes profile-specific metadata (e.g., retraction status, citation counts for academic content) only when the caller declares support
Example: Academic Research Agent
Section titled “Example: Academic Research Agent”An agent working on a literature review sends:
{ "supported_profiles": ["fora-academic-v1"]}The Broker’s Exchange Registry contains three Exchanges:
- academic-mp.example.com —
supported_profiles: ["fora-academic-v1", "fora-legal-v1"] - news-mp.example.com —
supported_profiles: ["fora-news-v1"] - general-mp.example.com —
supported_profiles: [](no profiles)
The Broker queries only academic-mp.example.com (profile match) and general-mp.example.com (no profile restriction — always included). It skips news-mp.example.com (no matching profile).
Scope Forwarding, Token Narrowing, and Quota Aggregation
Section titled “Scope Forwarding, Token Narrowing, and Quota Aggregation”Scope Forwarding
Section titled “Scope Forwarding”The Broker forwards Requester.scopes to every Exchange it queries. Scopes control what the requester can access — the Exchange filters its catalog to resources matching these scopes. The Broker does not modify scopes; it passes them through as-is.
Delegation Narrowing (only when the Broker is a delegated holder)
Section titled “Delegation Narrowing (only when the Broker is a delegated holder)”By default the Broker is a pure relay: it passes Requester.delegation through unchanged and the agent remains the holder — the agent’s RFC 9421 request signature is what the Exchange matches against the delegation’s cnf (the Broker’s hop signature is provenance only). A relay cannot narrow a delegation it does not hold: only a key named in the chain’s cnf can issue a valid child JWT.
Narrowing is therefore possible only when the agent has explicitly delegated to the Broker’s key (the Broker is the terminal holder in the chain). In that case the Broker issues a child JWT — scope ⊆ parent, bound (cnf.jkt) to the Broker’s key — and signs the request itself, so the binding now matches the Broker’s signature:
- Original delegation (agent → Broker):
scope: academic:*, max_spend_cents: 50000 - Narrowed for Exchange A:
scope: academic:read, max_spend_cents: 10000
The child JWT is signed by the key its parent named in cnf (scope ⊆ parent); the Exchange verifies the full JWT chain back to the original principal. This is an opt-in delegation step, not the default forwarding behavior.
SubscriptionQuotaInfo Aggregation
Section titled “SubscriptionQuotaInfo Aggregation”When multiple Exchanges return SubscriptionQuotaInfo on their offers or transaction responses, the Broker MAY aggregate this quota information before returning it to the agent. This gives the agent a consolidated view of remaining quota across Exchanges, enabling proactive throttling without the agent needing to track per-Exchange quota independently.
Three Discovery Modes
Section titled “Three Discovery Modes”Agents can discover content through the Broker in three ways, depending on how much they know about what they need. All three modes feed into the same Selection Engine — the Broker compares offers identically regardless of how they were discovered.
1. Specific URIs
Section titled “1. Specific URIs”The agent already knows which resources it wants. It sends DiscoveryRequest.uris with one or more specific content identifiers (DOIs, URLs, etc.). The Broker forwards these as a ResourceQuery to each Exchange and collects offers.
{ "uris": ["doi:10.1038/s41586-024-07386-0", "doi:10.1126/science.adq3854"]}This is the simplest mode — the agent has exact references and wants the best price across Exchanges.
2. Structured Search
Section titled “2. Structured Search”The agent knows what kind of content it needs but not the exact URIs. It sends DiscoveryRequest.search_filters with profile-specific key-value pairs. The Broker maps these to Exchange-specific query parameters and discovers matching resources.
{ "search_filters": { "academic.topic": "CRISPR gene therapy", "academic.year_min": 2023, "academic.peer_reviewed": true }}Filter keys are profile-specific: academic.topic, news.category, legal.jurisdiction, medical.specialty, etc. The Broker translates these into the native query format of each Exchange it contacts.
3. Natural Language
Section titled “3. Natural Language”The agent describes what it needs in free text. It sends DiscoveryRequest.query with a natural-language description. The Broker interprets the query and searches across Exchanges on the agent’s behalf.
{ "query": "Recent peer-reviewed studies on mRNA vaccine efficacy in immunocompromised patients"}This mode is useful for exploratory research where the agent cannot formulate structured filters. The Broker resolves the intent into Exchange queries.
Combining Modes
Section titled “Combining Modes”All three modes can be combined in a single request. For example, an agent can send specific URIs it already knows about, plus a search query to discover additional relevant resources:
{ "uris": ["doi:10.1038/s41586-024-07386-0"], "query": "Related studies on CRISPR delivery mechanisms", "search_filters": { "academic.year_min": 2022 }}The Broker merges results from all modes, deduplicates across them, and feeds everything into the Selection Engine for comparison and ranking.
Discovery Extension Point (v2)
Section titled “Discovery Extension Point (v2)”The Broker’s internal SupplySource interface is the extension point for content discovery beyond Exchange queries. In v1, all URIs come from the agent’s request. In v2, the Broker can discover URIs on the agent’s behalf:
// SupplySource abstracts where content offers come from.// v1: only ExchangeSource (queries an Exchange).// v2: SearchSource (Exa, Tavily), RecommendationSource, etc.type SupplySource interface { // Query returns offers from this source matching the request. Query(ctx context.Context, req *forav1.ResourceQuery) (*forav1.ResourceResponse, error)
// Domain returns the source identifier (exchange domain or service name). Domain() string
// Kind returns the source type for selection policy routing. Kind() SupplySourceKind // Exchange, SearchEngine, Archive, etc.}The flow: search/recommendation produces URIs, the Broker looks up an Exchange for each URI’s domain (via fora.json), the Exchange provides pricing/offers, and the standard transaction flow continues. Discovery is upstream of transactions — it produces URIs, not offers. The OfferGroup.discovery_method field tells the agent how each URI was found.
This positions the Broker as the natural integration point for agentic search — agents that need content don’t just request known URLs, they ask “find me articles about X” and the Broker discovers and prices the matching offers, returning them for the agent to commit to via a separate ExecuteTransaction call.
Request Lifecycle (End-to-End)
Section titled “Request Lifecycle (End-to-End)”Full request lifecycle from agent to content delivery:
Key Design Decisions
Section titled “Key Design Decisions”| Decision | Rationale |
|---|---|
| Broker is optional | Agents can query Exchanges directly. The Broker adds multi-exchange intelligence but is not required by the protocol. |
| Same protocol, transparent proxy | The Exchange cannot distinguish Broker-mediated requests from direct agent requests. No special Exchange support needed. |
| Signed offer pass-through | Exchange signatures cover pricing fields. The Broker cannot inflate prices without invalidating the signature. |
| Advisory budget enforcement | The Broker pre-filters transactions by budget, but the Exchange is the authoritative enforcement point. No overspend occurs because the Exchange is the gatekeeper. |
| Ed25519 + forwarding signature stack | Requester signs the HTTP request with an RFC 9421 HTTP Message Signature (covering @method/@target-uri/content-digest); Broker adds one labeled RFC 9421 HTTP Message Signature (covering the request plus the prior hop’s signature) to the header stack. Exchange verifies both layers and counts hops against max_hops / max_intermediary_hops. Public keys discoverable via the WBA directory (the JWK Set at /.well-known/http-message-signatures-directory). |
| Partial results are normal | If 3 of 5 Exchanges respond within the deadline, the Broker proceeds with 3 responses. Availability of any individual Exchange does not block the system. |
Go Module Structure
Section titled “Go Module Structure”github.com/FORA-Protocol/protocol/broker/ cmd/ broker/ # Sidecar binary entrypoint main.go pkg/ broker/ # Core broker logic (used by all deployment modes) broker.go # Broker struct + Resolve config.go # Configuration loading registry/ # Exchange Registry registry.go # ExchangeRegistry interface + in-memory impl crawler.go # fora.json crawler health.go # Health checker discovery/ # Supply Discovery (parallel fanout) fanout.go # FanOut implementation clientpool.go # Connect client pool management selection/ # Selection Engine engine.go # SelectionEngine interface + default impl pipeline.go # Filter, dedup, rank stages policy.go # Pluggable selection policies budget/ # Budget Tracker tracker.go # BudgetTracker interface + in-memory impl reporting/ # Reporting Relay relay.go # ReportingRelay interface + impl obligations.go # Obligation tracking + deadline checker metrics/ # Prometheus metrics metrics.go # All metric definitions internal/ testutil/ # Test helpers