Proto: FORA v1
Source: proto/fora/v1/fora.proto
Services
Section titled “Services”ExchangeService
Section titled “ExchangeService”The core protocol. Both AI agents and Brokers are valid clients.
| RPC | Request | Response | Description |
|---|---|---|---|
DiscoverResources | ResourceQuery | ResourceResponse | Discover available resource offers matching the query. Steps 2-3 in the FORA flow. |
ExecuteTransaction | TransactionRequest | TransactionResponse | Commit to an offer and receive delivery information. Steps 4-5 in the FORA flow. |
ReportUsage | UsageReport | UsageReportResponse | Submit a post-usage report for a completed transaction. Step 7 in the FORA flow. |
DisputeTransaction | DisputeRequest | DisputeResponse | Signal a resource dispute for a completed transaction. Filed by the agent when delivered resource does not match what was promised (hash mismatch, resource unavailable, wrong resource). The Exchange records the dispute and initiates resolution. Resolution mechanics (refund, credit, re-delivery) are implementation- specific — this RPC standardizes the dispute signal, not the outcome. |
RequestDomainVerification | DomainVerificationRequest | DomainVerificationChallenge | Request a domain verification challenge for provider onboarding. Used by fora-cli to prove domain control before pushing signing keys. Follows the ACME HTTP-01 pattern (Let's Encrypt). |
ConfirmDomainVerification | DomainVerificationConfirmation | DomainVerificationResult | Confirm domain verification and register a signing key. Called after the challenge token is placed at the provider's domain. |
Register | RegisterRequest | RegisterResponse | Create the calling agent's account with the Exchange. The caller's identity is proven by the request signature — the Exchange derives who is registering from the verified signature, never from the request body. Registering again for the same agent returns the same billing_ref (idempotent by design), which is why this RPC carries no idempotency_key; the repeat is answered from the stored account record and runs none of the account-creation gates — see "Repeat registration" in the Agent Account Registration section header. A refused registration travels as a non-OK transport error carrying ErrorDetail.registration_failure. |
GetAccountStatus | GetAccountStatusRequest | GetAccountStatusResponse | Read-only check of whether the calling agent's account is active. Identity comes from the request signature, so the request carries no identifying field. |
CatalogService
Section titled “CatalogService”Optional RPC for providers/CMS/third-party intelligence providers to push content metadata. A push is all-or-nothing at both validation tiers: a hard rejection anywhere in the submission refuses the entire submission and persists nothing, and the refusal names the entries that failed so the publisher can fix and resubmit the whole set. That per-entry detail is reporting, not partial acceptance.
| RPC | Request | Response | Description |
|---|---|---|---|
PushResources | PushResourcesRequest | PushResourcesResponse | Push or update resource entries in the Exchange catalog. |
RemoveResources | RemoveResourcesRequest | RemoveResourcesResponse | Remove resource entries. |
RefreshCatalog | RefreshCatalogRequest | RefreshCatalogResponse | Trigger a full catalog refresh from configured sources. |
BrokerService
Section titled “BrokerService”The Broker entry point. Resolve(DiscoveryRequest) → DiscoveryResponse is discovery-only: a client sends the URIs/query it wants resolved and receives offer_groups (one OfferGroup per URI, each carrying the full signed Offer) or a typed absence_reason with empty offer_groups when nothing licensable was found. “No result” is a successful answer; malformed requests, auth failures, and internal faults are non-OK transport errors carrying an ErrorDetail. Resolve does not deliver inline — the per-transaction result (transaction id, billing id, retrieval endpoint, …) is returned on TransactionResponse via the separate execute path, routed by Offer.exchange.
| RPC | Request | Response | Description |
|---|---|---|---|
Resolve | DiscoveryRequest | DiscoveryResponse | Resolve runs the broker discovery flow for the requested URIs/query: it fans out to one or more Exchanges and returns the merged offers. It is pure discovery — it selects and returns offers, never executes a transaction, so it neither charges nor produces transaction denials. A denial is raised only when the agent later calls ExchangeService.ExecuteTransaction on a selected offer, and rides there on TransactionResponse.DenialReason. A result returns OK with offers populated on DiscoveryResponse.offer_groups (one OfferGroup per requested URI). A request that ran but yielded nothing licensable (not in catalog, no offers, entitlement/budget absence, upstream temporarily unavailable) returns OK with DiscoveryResponse.absence_reason set and empty offer_groups — "no result" is a successful answer, mirroring DiscoverResources (ADR-019 §2). Here "authz" means resource entitlement (→ OK + absence); transport authentication failures are a different axis and, like malformed requests and internal faults, are non-OK transport errors carrying an ErrorDetail. |
Messages — Supply Discovery
Section titled “Messages — Supply Discovery”ResourceQuery
Section titled “ResourceQuery”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
requester | Requester | 3 | Requester identity — who is making this request, what scopes they have, and optional delegation chain. |
uris | repeated string | 8 | Resource URIs being queried. |
acceptable_restrictions | repeated AcceptableRestriction | 9 | The limits this query operates within, per restriction axis (function, geography, user-type, …) — see AcceptableRestriction. Advisory selection inputs the Exchange/Broker MAY pre-select offers against (convenience, not enforcement); the agent self-selects and bears compliance. |
deadline | optional Duration | 6 | Maximum time the caller will wait for a response. Exchange SHOULD prioritize speed over completeness when tight. Absent = "0.5s" default (proto-JSON encodes Duration as seconds). |
supported_profiles | repeated string | 7 | Domain extension profiles the caller understands. Declares which ext field vocabularies the caller can parse and act on. The Exchange SHOULD include profile-specific ext fields in Offers when the caller declares support. The Exchange MAY skip expensive metadata computation (e.g., retraction checking, consolidation verification) when the caller does not declare the relevant profile. Absence means "send all available metadata" — Exchange MUST NOT withhold ext fields solely because the caller omitted this field. Values match the Exchange's WellKnownManifest.supported_profiles entries. Examples: ["fora-news-v1", "fora-academic-v1", "fora-legal-v1"] |
exchange | string | 10 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header for the full contract, including the recipient's duty to reject a request that names someone else. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
AcceptableRestriction
Section titled “AcceptableRestriction”The limits a query operates within on one restriction axis, in the same RestrictionKind vocabulary the terms use. The Exchange/Broker MAY pre-select offers whose term restrictions fall within these (convenience, not enforcement). Used in ResourceQuery and DiscoveryRequest.
| Field | Type | Number | Description |
|---|---|---|---|
axis | RestrictionKind | 1 | Which axis (same enum as Restriction.kind): FUNCTION / GEOGRAPHY / USER_TYPE / OTHER. |
values | repeated string | 2 | The values the query operates within on this axis — same token vocabulary as the terms (e.g. FUNCTION ["ai-train"], GEOGRAPHY ["US", "EU"]). |
ResourceResponse
Section titled “ResourceResponse”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
exchange | string | 3 | Canonical domain of the responding Exchange, in the shape "Request recipient" defines in the file header. The response counterpart of the recipient field on the request: it names who answered. |
offers | repeated Offer | 4 | Flat list of offers (for single-URI queries). |
offer_groups | repeated OfferGroup | 5 | Offers grouped by requested URI (for multi-URI batch queries). When populated, offers SHOULD be empty to avoid ambiguity. |
rate_limit | optional RateLimitInfo | 6 | Rate limit status for this caller. Present when the Exchange enforces per-caller rate limits on discovery. Enables agents/Brokers to throttle proactively rather than hitting hard limits. Particularly important when a Broker fans out the same batch query to multiple Exchanges — mid-batch rate limiting can cause partial results if not signaled early. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
OfferGroup
Section titled “OfferGroup”| Field | Type | Number | Description |
|---|---|---|---|
uri | string | 1 | The URI this group of offers is for (echoed from ResourceQuery.uris). |
offers | repeated Offer | 2 | Zero or more offers for this URI. Empty = resource not available. |
discovery_method | optional DiscoveryMethod | 3 | How this URI was discovered by the Broker (v2 extension point). v1: always DISCOVERY_METHOD_EXCHANGE (Broker queried an Exchange). v2: may include DISCOVERY_METHOD_SEARCH (URI found via search engine like Exa), DISCOVERY_METHOD_RECOMMENDATION, etc. The Broker discovers URIs through any source, then routes through Exchange for pricing/transaction. The discovery method does not affect the transaction flow — it's metadata for the agent to understand how the resource was found. |
absence_reason | optional OfferAbsenceReason | 4 | Why no offers are available for this URI. Present when offers is empty. Enables agents/Brokers to distinguish "resource not in catalog" from "resource blocked for your use case" without trial-and-error transactions. Analogous to OpenRTB nbr codes and Shutterstock per-item error metadata in batch responses. |
restriction_filters | repeated RestrictionKind | 5 | When absence_reason = RESTRICTION_FILTERED, the restriction axes that drove the convenience pre-filter, in the same RestrictionKind vocabulary the terms use (e.g. [GEOGRAPHY] when the requester's stated geography matched no term). Advisory diagnostics, not an enforcement verdict. |
RateLimitInfo
Section titled “RateLimitInfo”Rate limit status modeled after IETF RateLimit header fields.
| Field | Type | Number | Description |
|---|---|---|---|
limit | int32 | 1 | Maximum requests allowed in the current window. |
remaining | int32 | 2 | Requests remaining in the current window. |
reset_at | Timestamp | 3 | When the current window resets (UTC). After this time, remaining resets to limit. |
window | optional Duration | 4 | Duration of the rate limit window (e.g. 60s = per-minute limit). |
Messages — Requester Identity
Section titled “Messages — Requester Identity”Requester
Section titled “Requester”Identity and entitlements only — who is asking and what they’re entitled to. What they’re asking for (uris) and the limits they’ll accept (acceptable_restrictions) live on the ask (ResourceQuery / DiscoveryRequest), not here. Used in ResourceQuery and DiscoveryRequest.
| Field | Type | Number | Description |
|---|---|---|---|
id | string | 1 | Unique requester identifier (e.g., "agent-research-bot-001"). |
domain | string | 2 | Domain the requester belongs to. It carries the same bare-host shape "Request recipient" defines in the file header, for the same structural reason: a scheme, path or query smuggled in here would choose what gets fetched, not merely from where. It is NOT how a verifier finds this requester's keys: those live in the WBA directory, and verification resolves that directory from the COVERED Signature-Agent header, never from this self-asserted value. |
type | RequesterType | 3 | What kind of entity is making this request. |
name | optional string | 4 | Human-readable name (e.g., "Acme Research Assistant"). |
scopes | repeated string | 6 | Entitlement scopes. Declare what the requester can access. The Exchange filters its catalog to resources matching these scopes. Resources outside the scopes are not returned — the requester never learns they exist. This is the enforcement mechanism for both enterprise RBAC and open-market subscription entitlements. Scope format: colon-separated segments, "{domain}:{permission}" or "{profile}:{permission}", optionally multi-segment ("dist:US:CA"); matching is segment-wise per the rule below (no implicit hierarchy). Examples: "credit:read" — can access credit reports "subscription:marketdata-2026" — has active MarketData subscription "academic:" — full access to academic resources "internal:reports" — can access internal reports "" — unrestricted (public Exchange default) Matching is SEGMENT-WISE (":" separated). A granted scope G covers a required scope R iff, segment by segment, each G segment equals the corresponding R segment or is ""; a terminal "" matches all remaining segments. There is NO implicit prefix match, and a grant NARROWER than the requirement does not cover it (G must be equal-to-or-broader than R). Examples: "dist:" covers "dist:US" and "dist:US:CA"; "dist:US:" covers "dist:US:CA" but not "dist:EU"; bare "dist" covers only "dist"; granted "dist:US:CA" does NOT cover required "dist:US"; "*" covers everything. This same rule governs LicenseTerm.scopes — one algorithm protocol-wide. When empty, Exchange applies its default access policy (typically returns all publicly available resources). |
delegation | optional Delegation | 7 | Optional delegation — present when the requester acts on behalf of another entity (user, organization, upstream agent). |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Delegation
Section titled “Delegation”Scoped, time-limited, spend-capped credential. The credential itself is opaque bytes carried in token, and token_format selects how to interpret them: "jwt" (the default and sole format; the field stays open for a future one). The format is never encoded as a prefix inside the token bytes. The token is a holder-bound JWT: a chain of cnf-linked JWTs (RFC 7800 cnf.jkt = RFC 7638 thumbprint) where each child is signed by the key its parent named and narrows scope, and the holder proves possession by signing the request (RFC 9421).
| Field | Type | Number | Description |
|---|---|---|---|
principal_domain | string | 1 | Who granted this delegation (domain for public key lookup). |
principal_id | string | 2 | Principal's identifier (e.g., "user@acme.com", "marketdata.example.com"). |
scopes | repeated string | 3 | Scopes granted by this delegation. MUST be a subset of the principal's own scopes (attenuation — can only narrow, not widen). |
expires_at | Timestamp | 4 | When this delegation expires. Exchange MUST reject expired tokens. |
max_spend_cents | optional int64 | 5 | Maximum spend in currency minor units (e.g., cents for USD). Exchange tracks cumulative spend against this cap. |
max_accesses | optional int32 | 9 | Maximum number of accesses allowed under this delegation. Exchange tracks cumulative access count against this cap. Deny with DENIAL_REASON_QUOTA_EXCEEDED when count >= limit. For subscriptions with "10,000 accesses/month", this carries the ceiling. |
quota_period | optional Duration | 10 | Quota reset period. How often the access/spend counters reset. Example: 30 days for monthly subscriptions — "2592000s" on the wire (proto-JSON encodes Duration as seconds; "720h" is not accepted). When absent, the quota is lifetime (bounded only by expires_at). |
token | bytes | 6 | Token bytes. A JWT (base64url-encoded JWS). |
token_format | string | 7 | Token format: "jwt" (default). Empty is treated as "jwt". The field stays open for a future format. |
revocation_uri | optional string | 8 | Optional: URI for real-time revocation checking. Exchange MAY check this for high-value transactions. Not checked for routine low-value access (performance tradeoff). |
issuer | optional string | 11 | Token issuer. OIDC issuer URL or GNAP grant server URL. Exchange uses this for JWT validation (OIDC discovery → JWKS) or GNAP token introspection. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Delegation claims. The claim vocabulary carried inside the token is not re-listed here — the authoritative registry is the Delegation-Claims Profile table in authentication.mdx. The one claim worth restating: every delegation MUST carry a holder binding (the cnf claim, cnf.jkt = the RFC 7638 thumbprint of the holder key) — the request-signing key MUST hash to cnf.jkt, or the token is rejected. Without it the token is bearer-usable. Everything else (scope, spend cap, expiry, etc.) is optional and defined in that single source of truth.
Narrowing example: An agent can further restrict (but never widen) a delegation by issuing a child JWT:
- Original:
scope: credit:*, max_spend_cents: 100000 - Attenuated:
scope: credit:read, max_spend_cents: 20000, uris: duns:123*
Multi-hop forwarding (no message — HTTP layer)
Section titled “Multi-hop forwarding (no message — HTTP layer)”There is no in-message hop chain. Multi-hop forwarding (Agent → Broker → … → Exchange) is a stack of RFC 9421 HTTP Message Signatures: each forwarding party adds one labeled signature, and each signature covers the request plus the prior hop’s signature, so the ordered set of signatures is the chain (tamper-evident, order-bound). The Exchange resolves each keyid (an RFC 7638 thumbprint) in the signer’s WBA directory at {domain}/.well-known/http-message-signatures-directory, verifies every signature, and enforces RequestConstraints.max_hops / WellKnownManifest.max_intermediary_hops by counting them. Responses do not retrace the chain — the terminal Exchange returns directly to the originating agent (bound by agent_identity_hash).
Messages — Offers and Pricing
Section titled “Messages — Offers and Pricing”| Field | Type | Number | Description |
|---|---|---|---|
offer_id | string | 1 | Unique identifier for this offer, assigned by the Exchange. Opaque to the caller: not derived from the resource, its URL, or any other field, and carries no meaning beyond identifying this offer. Two offers for the same resource have different offer_ids. |
title | optional string | 2 | Resource title (human-readable, for display/logging). |
pricing | Pricing | 3 | Pricing for this offer. An offer represents a single licensing arrangement: each projected LicenseTerm yields its own offer, so this is that term's pricing (the authoritative copy lives in terms[].pricing). Used for cross-exchange comparison and Broker ranking. A resource with multiple alternative terms (e.g. dual-licensed) produces multiple separate offers, one per term — never one offer with a "headline" picked among them. |
delivery_method | DeliveryMethod | 4 | How resource will be delivered. |
reporting | optional ReportingObligation | 5 | Post-usage reporting requirements for this offer. |
expires_at | optional Timestamp | 6 | When this offer expires (ISO 8601). |
identity | optional ResourceIdentity | 7 | Resource identity for cross-exchange deduplication. Enables Brokers to recognize the same resource offered by different Exchanges and compare pricing. |
exchange | string | 8 | REQUIRED. Bare host of the Exchange that issued this offer (e.g. "exchange.example" or "exchange.example:8081"), in the form "Request recipient" defines in the file header. This is the execute-routing target: the agent, or a relaying Broker, sends the ExecuteTransaction call for this offer to this Exchange, and a Broker relaying a mixed batch groups the items by this value. Because it is an ordinary Offer field it falls inside the signed bytes (see signature below — the signature covers every field except signature / signature_algorithm), so an intermediary cannot redirect the execute call to a different Exchange without invalidating the offer, and it is what retires the X-FORA-Exchange-Endpoint transport header. It is also the audience statement of an ExecuteTransaction, which is why TransactionRequest carries no top-level exchange: on receipt, an Exchange MUST reject the request unless EVERY item's offer.exchange names its own domain. Presence is enforced because an empty value is unroutable — a relaying Broker has nothing to group or dial on, and the swap-protection above is vacuous when the signed bytes carry no recipient at all. |
signature | string | 9 | REQUIRED. Hex-encoded detached Ed25519 signature over the canonical serialization of the ENTIRE Offer — every field, including pricing, terms (the full licensing payload), expires_at, and exchange. Only signature and signature_algorithm are excluded from the signed bytes. expires_at is signed so the offer's validity window is integrity-protected: a relaying Broker cannot extend (or shorten) the TTL of a signed offer to replay it outside the window the Exchange intended. CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes are: signed_payload = JCS( protojson(msg with signature + signature_algorithm cleared) ) i.e. render the message to canonical proto-JSON with the PINNED option set below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic protobuf BINARY marshaling is explicitly NOT canonical across languages and versions (protobuf's own caveat), so it cannot be a cross-language signing primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS, Python) without a protobuf binary codec, so a broker/exchange/client in any language signs and verifies byte-identically. This same definition applies to the agent offer-acceptance signature (AgentAcceptance.signature). PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector — whatever these options render MUST be byte-identical across all languages): - enum values as NAME strings (not numbers); - int64 / uint64 / fixed64 as decimal STRINGS; - bytes as standard (padded) base64; - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules (RFC 3339 string for Timestamp); - unpopulated fields are OMITTED (never emitted as defaults); - field naming is snake_case (the proto field name, UseProtoNames=true), the naming every SDK target shares — wire, corpus, and signed form are all snake_case; - google.protobuf.Struct (ext) → a plain JSON object; JCS then sorts its keys recursively, so the Struct case needs no special handling. UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or PRESERVES it, and the rule follows from which: - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a canonicalizer CANNOT reproduce the signed bytes of a message carrying unknown fields — what it renders silently drops part of what the signer covered. It MUST refuse the message rather than emit the reduced bytes, and a verifier built on it MUST reject rather than verify over them. The refusal binds at EVERY depth: a nested message and each element of a repeated or map field carries its own unknown-field set. - PRESERVING (a canonicalizer that carries unrecognized members through): it reproduces the signed bytes faithfully, so there is nothing to refuse. Either way an APPENDED field cannot pass: an omitting canonicalizer refuses the message, and a preserving one renders the appended member into bytes the signer never covered, so the signature fails. Without the refusal the omitting case would fail OPEN — an intermediary could add unknown fields to an already-signed message and leave its signature verifying, smuggling unauthenticated content through a message the recipient treats as verified. Extensions therefore ride in ext / ext_critical, which are defined fields and inside the signed bytes — never as undeclared field numbers. Because the signature covers terms, pricing, expires_at, and exchange, an intermediary (Broker) cannot tamper with price, restrictions, quotas, obligations, the expiry, the execute-routing target, or any licensing term without invalidating it. Agent SHOULD verify the signature (RFC 2119) against the Exchange's public key, and MUST reject an offer whose expires_at is in the past. |
signature_algorithm | string | 10 | JOSE/JWA algorithm identifier (RFC 8037 §3.1). Always 'EdDSA' for Ed25519. Advisory only: this field is cleared before the canonical payload is signed, so it is not covered by the signature. |
subscription_id | optional string | 11 | If set, this offer is available under an existing subscription/deal. No per-request billing — usage tracked against subscription quota. Pricing.rate = "0" for subscription offers (zero marginal cost). The Broker SHOULD prefer subscription offers when available. |
iab_categories | repeated string | 13 | IAB Content Taxonomy category codes. Enables agents to filter offers by topic (e.g., "only finance resources"). Uses IAB Content Taxonomy 3.1 codes. |
attestations | repeated ResourceAttestation | 14 | Signed attestations about the resource at this URI. Attestations provide cryptographic proof of resource properties from trusted parties (providers or verification vendors). Three verification levels determine what is independently verifiable: Level 0 (no attestations): Resource may carry identifiers (DOI, IPTC GUID) for identification, but nothing is cryptographically verifiable. Only CDN delivery failure is auto-disputable. Level 1 (self-attested): Provider signs own claims with Ed25519 key. Agent can independently verify content hash and token count. CDN delivery failure + content hash mismatch are auto-disputable. Level 2 (third-party attested): Independent verification vendor crawled the resource and attested to its properties. Agent trusts the attestation (does not re-verify hash). Token count discrepancy is auto-disputable when corroborated by CDN response size. Multiple attestations may be present (e.g., provider self-attestation plus a third-party verification). Agents choose which to trust. |
data_as_of | optional Timestamp | 16 | When the offered data was current. For dynamic resources (resource_mutability = DYNAMIC), this is the snapshot timestamp. Enables the Broker to evaluate freshness: "this credit report reflects data as of March 18" or "this drug database was updated today." Not set for STATIC resources (content doesn't change) or LIVE resources (content doesn't exist yet). The Broker compares this against RequestConstraints.max_data_age to filter stale offers. Example: agent requests max_data_age = 7 days, Broker drops offers where now() - data_as_of > 7 days. |
subscription_quota | repeated SubscriptionQuotaInfo | 17 | Subscription quota state, when this offer is under a subscription. Enables the agent to see remaining quota before committing. Multiple entries when the subscription has independent quotas (e.g., access count + spend cap). |
previews | repeated Preview | 18 | Lightweight previews for offer evaluation. The Exchange holds URLs (50–200 bytes each); the provider's CDN serves the actual bytes. Agents fetch previews only when evaluating offers — not on every discovery query. Multiple previews at different sizes allow agents to pick the cheapest fetch for their evaluation needs. Per content type: Image: watermarked thumbnail (150–450px JPEG) Video: short clip (10–30s MP4, watermarked) Audio: short clip (15–30s MP3, low-bitrate or watermarked) Text: snippet or abstract (first 200 words as text/plain) Data: sample records (1–3 rows as application/json) Stream: optional frame capture or none (streams are priced by time) Modeled after Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to 30s clip), IIIF (parameterized image URLs), and OpenRTB native (img.url + dimensions). |
terms | repeated LicenseTerm | 19 | Licensing terms for this offer, sourced from the publisher's ResourceEntry. Multiple terms when the resource has different arrangements by use case. See: Universal Licensing Core section. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
ResourceAttestation
Section titled “ResourceAttestation”Signed envelope of claims from a trusted party (provider or verification vendor) about content at a specific URI.
| Field | Type | Number | Description |
|---|---|---|---|
verifier | string | 1 | Canonical domain of the attesting party (e.g., "nytimes.com" for self-attestation, "doubleverify.com" for third-party attestation). Used to look up the verifier's attestation-signing keys in its WBA directory (WBAFile.keys) at https://{verifier}/.well-known/http-message-signatures-directory |
keyid | string | 2 | RFC 7638 JWK Thumbprint (the RFC 9421 keyid) of the verifier's attestation-signing key, resolved against the verifier's WBA directory (WBAFile.keys). Identifies which Ed25519 key signed this attestation. Enables key rotation: new keys are published with overlapping validity, new attestations use the new key's thumbprint, old attestations remain verifiable while the old key is still published. |
attested_at | Timestamp | 3 | When this attestation was created. Agents use this to assess freshness (e.g., "I accept attestations up to N hours old for breaking news"). |
uri | string | 4 | The resource URI this attestation covers. Must match the URI in the Offer or ResourceEntry this attestation is attached to. |
claims | Struct | 5 | Signed claims about the resource (max 4KB). A JSON object containing whatever properties the attesting party can determine about the resource. Recommended claim names for interoperability: estimated_quantity (integer): estimated consumption quantity (e.g., token count for text) word_count (integer): word count (estimated_quantity ~ word_count * 1.32 for text) language (string): ISO 639-1 language code iab_categories (string[]): IAB Content Taxonomy 3.1 codes content_hash (string): hash of content in "method:hexdigest" format hash_method (string): algorithm used for content_hash Vendors MAY add vendor-specific claims (e.g., brand_safety, sentiment). The protocol does NOT define "quality score" — it is inherently subjective. If a vendor provides a proprietary score, the vendor defines what it means via their WellKnownManifest ext["fora.attestation.claims_schema"]. |
signature | string | 6 | Ed25519 signature over JCS-canonicalized (RFC 8785) representation of {verifier, keyid, attested_at, uri, claims}. JCS (JSON Canonicalization Scheme) produces deterministic UTF-8 bytes: lexicographic key sorting, ECMAScript number serialization, strict string escaping, no whitespace. Each attestation is self-contained — new claim fields do not invalidate old attestations because the signature covers the specific claims instance. |
Three verification levels:
| Level | Condition | What’s Verifiable |
|---|---|---|
| 0 — None | attestations empty | CDN delivery failure only |
| 1 — Self-attested | verifier matches provider domain | Content hash + token count |
| 2 — Third-party | verifier is a verification vendor | Token count (with CDN corroboration) |
Pricing
Section titled “Pricing”| Field | Type | Number | Description |
|---|---|---|---|
model | PricingModel | 1 | Provider's pricing model. |
rate | string | 2 | Price in the provider's model, as an exact decimal string — e.g. "0.05" = $0.05 per article. NOT a float: money is decimal to avoid binary rounding and to allow arbitrary sub-cent precision (e.g. "0.0001234"). Denominated in currency. |
currency | string | 3 | ISO 4217 currency code (e.g. "USD", "EUR"). |
unit_cost | optional string | 4 | Normalized cost per unit — the universal comparison metric, exact decimal string. For text: cost per token. For video: cost per second. For data: cost per record. For APIs: cost per call. Denominated in the Exchange's base_currency (from its WellKnownManifest). |
estimated_quantity | optional int32 | 5 | Estimated quantity in the metering unit. For text: token count. For video: duration in seconds. For documents: page count. For data: record count. |
license_duration_months | optional int32 | 7 | License duration in months. How long the granted access remains valid. |
unit | optional string | 8 | Metering basis — the "per what" of PER_UNIT pricing. REQUIRED when model = PER_UNIT. Custom units namespace as "vendor:unit". Ignored for FREE / FLAT. The (fora.v1.vocab) entries below are the SOLE authored source of the registered bare tokens. A buf plugin reads them structurally and emits the pricingunits constants + IsRegistered; ingest enforces membership from those. The CEL is STRUCTURE ONLY (empty / bare-form / vendor:namespaced) — it never lists the tokens, so it cannot drift from the registry. |
metering | optional PricingMetering | 9 | How usage is tracked for billing reconciliation. Absent = PRICING_METERING_ONLINE (default real-time tracking). NONE = one-time perpetual sale; no ReportUsage required after ExecuteTransaction. OFFLINE_SELF_REPORTED = agent self-reports physical-world consumption. |
Pricing has no ext / ext_critical fields — the licensing core is closed.
ResourceIdentity
Section titled “ResourceIdentity”Layered content identification for cross-exchange dedup and integrity verification.
| Field | Type | Number | Description |
|---|---|---|---|
canonical_url | optional string | 1 | Provider's authoritative URL for this resource (rel="canonical"). Always available. Different per provider for syndicated content. |
doi | optional string | 2 | Digital Object Identifier — persistent, never changes. |
iptc_guid | optional string | 3 | IPTC NewsML-G2 globally unique identifier. Present when resource flows through news wire syndication (AP, Reuters). |
isni | optional string | 4 | International Standard Name Identifier for the creator. |
content_hash | optional string | 5 | Hash of the content. Interpretation depends on hash_method: "simhash-v1" → locality-sensitive hash, for fuzzy dedup (Level 1) "sha256" → exact-match integrity hash (Level 2) Level 1 (SimHash): computed by Exchange from extracted text. Agent verifies that fetched content is "substantially similar." Tolerates dynamic page elements. Level 2 (SHA-256): computed by provider from deterministic payload. Agent verifies exact match. Requires provider to serve consistent content (e.g., API endpoint, static HTML, structured JSON). Mismatch = dispute. Commands premium pricing. |
hash_method | optional string | 6 | Hash algorithm and verification level. Examples: "simhash-v1", "minhash-v1", "sha256", "sha384" |
resource_mutability | ResourceMutability | 8 | Signals whether this resource's content is stable, changes over time, or does not exist at offer time (live streaming). Drives hash verification behavior: STATIC: content_hash is stable. Agent SHOULD verify delivered content matches. DYNAMIC: content changes between offer and fetch (credit reports, drug databases). content_hash reflects state at offer generation time. Hash mismatch is expected and MUST NOT trigger automatic dispute. LIVE: content does not exist at offer time (streaming feeds, live broadcasts). content_hash is not applicable. The "resource" is the stream endpoint. Validated across 18 use cases: static content (articles, patents, legislation), dynamic data (credit reports, drug interactions, stock snapshots), and live streams (MarketData quotes, NPR broadcast, news monitoring feeds). |
c2pa_manifest | optional string | 7 | C2PA content credentials manifest URI. Points to a sidecar or embedded C2PA manifest for this resource. C2PA-aware agents MAY follow this URI to validate the full provenance chain (creator identity, transformation history, ingredient composition) using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely on c2pa_status and c2pa-bridged attestation claims instead. Formats: Sidecar: HTTPS URI to a .c2pa manifest file Embedded: same URI as canonical_url (manifest is inside the asset) Content Credentials Cloud: https://contentcredentials.org/verify?uri=... |
c2pa_status | optional C2PAStatus | 9 | Summary validation status of the C2PA manifest. Populated by the Exchange or a verification vendor after validating the C2PA manifest. Enables agents to filter for provenance-verified content without parsing JUMBF/COSE themselves. The full C2PA validation details (signer identity, trust list, action history, training/mining status) are carried in a ResourceAttestation with c2pa.* claims — see fora-c2pa-v1 profile. |
soft_binding | optional string | 10 | Soft binding hash — content-derived identifier that survives format transcoding (resolution changes, compression, PDF-to-text extraction). Extracted from C2PA soft binding assertion when present. Enables post-delivery verification when the hard binding hash breaks due to legitimate format conversion. Algorithm specified in soft_binding_method. Values are algorithm-specific (e.g., perceptual hash hex string, watermark identifier). |
soft_binding_method | optional string | 11 | Algorithm used for soft_binding. Examples: "phash-v1" (perceptual hash), "c2pa-watermark" (C2PA invisible watermark), "chromaprint" (audio fingerprint). |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
SubscriptionQuotaInfo
Section titled “SubscriptionQuotaInfo”Quota status for a subscription within the current billing period. Appears on Offer (field 17) as a pre-commit snapshot and on TransactionResponse (field 17) as the post-transaction remaining quota. The field is repeated to support multi-dimensional quotas (e.g. access count + spend cap).
| Field | Type | Number | Description |
|---|---|---|---|
subscription_id | string | 1 | Subscription this quota applies to. |
quota_limit | int32 | 2 | Total allowed in the current period. |
quota_used | int32 | 3 | Used so far in the current period. |
quota_remaining | int32 | 4 | Remaining in the current period. |
resets_at | optional Timestamp | 5 | When the quota counter resets (UTC). |
unit | optional string | 6 | What is being metered. Distinguishes access count quotas from spend quotas from burst limits. Standard values: "accesses", "tokens", "spend_cents", "burst" |
Preview
Section titled “Preview”Lightweight resource preview for offer evaluation. The Exchange populates preview URLs during catalog ingestion; assets are served by the provider’s CDN, not by the Exchange. Carried on Offer (field 18, repeated).
| Field | Type | Number | Description |
|---|---|---|---|
url | string | 1 | URL to a preview asset (thumbnail, clip, snippet, sample). Served by the provider's CDN, not by the Exchange. |
media_type | string | 2 | MIME type of the preview. Examples: "image/jpeg", "image/webp", "audio/mpeg", "video/mp4", "text/plain", "application/json" |
width | optional int32 | 3 | Dimensions in pixels (for images and video). |
height | optional int32 | 4 | Height in pixels (images and video) |
duration | optional int32 | 5 | Duration in seconds (for audio and video clips). |
size | optional string | 6 | Size category hint. Agents use this to select the right preview without fetching all of them. Standard values: "thumbnail" — smallest useful preview (100–150px or 5–10s) "preview" — mid-size for evaluation (300–500px or 15–30s) "sample" — larger / more detailed (for data: 1–3 sample records) |
Messages — Transaction
Section titled “Messages — Transaction”TransactionRequest
Section titled “TransactionRequest”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
idempotency_key | string | 2 | Idempotency key (REQUIRED). The server MUST dedupe on this: a replay returns the original result rather than re-executing. The transaction's durable identity is the Exchange-assigned transaction_id in the response. Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per (authenticated caller, key), never globally, so a key chosen by one caller cannot collide with another's cached result. |
requester | Requester | 4 | Requester identity — forwarded for authorization and audit. |
items | repeated TransactionItem | 7 | The offers committed in this request (REQUIRED, min 1), each carrying its own reflected signed Offer + detached acceptance. A single offer is the degenerate 1-element list. The Exchange verifies each item's offer.signature (which covers pricing, terms, and expires_at) over the presented bytes against its own key — stateless, self-contained bearer tokens, with no reconstruct-from-catalog. |
agent_request_acceptance | optional AgentRequestAcceptance | 8 | Optional for wire compatibility. When present, an Exchange verifies this before creating or serving request-level idempotency state. A Broker MUST forward it unchanged on every projected subrequest. Older clients that omit it retain per-item execution semantics but receive no request-level claim. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
TransactionResponse
Section titled “TransactionResponse”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
agent_identity_hash | string | 10 | Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK Thumbprint of the agent's Ed25519 request-signing key (see "Retrieval-URL identity binding" above). Shared across the request; set once. |
items | repeated TransactionResultItem | 13 | Per-offer results (one entry per committed item, in original order). |
total_cost | optional Cost | 14 | Aggregate cost across all items. |
subscription_quota | repeated SubscriptionQuotaInfo | 17 | Post-transaction quota state. Tells the agent how much quota remains after this transaction. Enables proactive throttling ("1 access left"). Multiple entries for multi-dimensional quotas. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
TransactionItem
Section titled “TransactionItem”A single offer commitment within a batch transaction.
| Field | Type | Number | Description |
|---|---|---|---|
offer | Offer | 3 | The FULL signed Offer for this batch entry, reflected back exactly as received at discovery. The Exchange verifies offer.signature over these presented bytes — stateless, no reconstruct-from-catalog. REQUIRED: every batch item carries its offer. |
agent_acceptance | optional AgentAcceptance | 4 | The agent's detached acceptance signature over this item's offer. Optional on the wire; the Exchange enforces presence per item at the service layer for relayed batches. Signed bytes = the canonical AgentAcceptancePayload form, with requester_* and idempotency_key taken from the ENCLOSING TransactionRequest and offer_sig = offer.signature. |
AgentAcceptance
Section titled “AgentAcceptance”The agent’s detached acceptance signature over an accepted Offer. It travels in the execute body alongside the reflected Offer and is independent of the transport (RFC 9421) request signature, so it survives any number of broker relays. The Exchange verifies it and binds the delivery URL to the agent’s key (RFC 7638 thumbprint). signature is a hex-encoded detached Ed25519 signature over the JCS-canonicalized (RFC 8785) proto-JSON of AgentAcceptancePayload — the same canonical signing form Offer.signature defines, reduced here to JCS(protojson(payload)) because the payload carries no signature fields to clear; signature_algorithm is EdDSA.
| Field | Type | Number | Description |
|---|---|---|---|
signature | string | 1 | Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload bytes (see the canonical-signing definition on Offer.signature). |
signature_algorithm | string | 2 | Signature algorithm; "EdDSA" for Ed25519. |
AgentRequestAcceptance
Section titled “AgentRequestAcceptance”The agent’s detached authorization of one complete ordered execute set. Its payload travels with the signature so a Broker can forward the same proof unchanged on every per-Exchange projection. A receiving Exchange verifies the agent signature and requires its subrequest to equal the complete in-order projection of signed items addressed to that Exchange before creating or serving request-level idempotency state.
| Field | Type | Number | Description |
|---|---|---|---|
payload | AgentRequestAcceptancePayload | 1 | The signed payload is carried because a projected subrequest does not carry offers addressed to other Exchanges and therefore cannot reconstruct the original complete set by itself. |
signature | string | 2 | Hex-encoded detached Ed25519 signature over the canonical payload bytes. |
signature_algorithm | string | 3 | Signature algorithm; "EdDSA" for Ed25519. |
AgentRequestAcceptancePayload
Section titled “AgentRequestAcceptancePayload”The signed field set: ordered AgentRequestAcceptanceItem references plus the requester identity and idempotency key. Canonical bytes use the same RFC 8785 JCS over canonical proto-JSON rule as Offer and AgentAcceptance signatures.
| Field | Type | Number | Description |
|---|---|---|---|
items | repeated AgentRequestAcceptanceItem | 1 | Complete original request order, before Broker fan-out. Capped at 256 — the same ceiling a discovery query's uris list carries, so one request can reference at most one offer per queried URI at the query cap. The Go verification helper enforces the same bound itself before doing any canonicalization work, because a verifier may run with wire validation off and the canonical rendering of an unbounded list is the expensive step an unauthenticated caller could otherwise buy for free. |
requester_id | string | 2 | |
requester_domain | string | 3 | |
idempotency_key | string | 4 |
AgentRequestAcceptanceItem
Section titled “AgentRequestAcceptanceItem”A signed request-set reference containing offer_sig and exchange. The offer signature binds the complete Offer; the explicit issuing Exchange lets each fan-out recipient derive the exact subset it must receive without copying every full Offer into every subrequest.
| Field | Type | Number | Description |
|---|---|---|---|
offer_sig | string | 1 | |
exchange | string | 2 |
AgentAcceptancePayload
Section titled “AgentAcceptancePayload”The canonical signing structure for AgentAcceptance. It is never sent on the wire — this message fixes the field set, and the byte layout is the canonical signing form defined on Offer.signature: RFC 8785 JCS over canonical proto-JSON with a pinned option set. Both halves are normative, so signer and verifier derive byte-identical signed bytes in any language without a protobuf binary codec, and the contract cannot drift between implementations. offer_sig is the accepted Offer.signature (which transitively binds pricing, terms, and expiry); requester_id, requester_domain, and idempotency_key come from the enclosing TransactionRequest.
| Field | Type | Number | Description |
|---|---|---|---|
offer_sig | string | 1 | The accepted Offer's signature (Offer.signature). Anchors the whole signed offer without re-serializing its terms/pricing/expiry. |
requester_id | string | 2 | Requester identity (Requester.id) the acceptance is bound to. |
requester_domain | string | 3 | Requester domain (Requester.domain) the acceptance is bound to. |
idempotency_key | string | 4 | The transaction's idempotency key — binds the acceptance to a single execute so it cannot be replayed under a different transaction. |
TransactionResultItem
Section titled “TransactionResultItem”Result for a single offer in a batch transaction.
| Field | Type | Number | Description |
|---|---|---|---|
offer_id | string | 1 | The offer_id this result is for. |
transaction_id | string | 2 | Exchange-assigned transaction identifier. |
billing_id | string | 3 | Billing record identifier minted by the Exchange's billing adapter for this transaction (not the account handle — see RegisterResponse.billing_ref). |
resource_title | optional string | 4 | Resource title echoed from the Offer. |
cost | Cost | 5 | Cost for this item. |
subscription_id | optional string | 6 | If under subscription, no per-request charge. |
subscription_unit_value | optional Cost | 11 | Computed per-unit cost for financial attribution on subscription transactions. Even when cost.amount="0" (subscription), this field carries the value of the access for accounting purposes (e.g., ASC 606 prepaid drawdown). |
denial_reason | optional DenialReason | 7 | Set if this specific item was denied (others may succeed). |
restriction_mismatches | repeated RestrictionKind | 13 | When denial_reason = RESTRICTION_NOT_SATISFIED, the restriction axes the request failed, in the same RestrictionKind vocabulary the terms use. |
expires_at | optional Timestamp | 8 | When retrieval_endpoint expires. |
retrieval_endpoint | optional string | 12 | Signed retrieval URL for this item. Bound to the requesting agent's identity via the parent TransactionResponse.agent_identity_hash (shared across all batch items); expires at expires_at. Absent if this item was denied or its delivery_method is not signed-URL-based. |
delivery_method | DeliveryMethod | 9 | How resource is delivered for this item. |
reporting_obligation | optional ReportingObligation | 10 | Reporting requirements for this item. |
Actual transaction cost.
| Field | Type | Number | Description |
|---|---|---|---|
amount | string | 1 | Exact decimal string (not a float), e.g. "19.99". Denominated in currency. |
currency | string | 2 | ISO 4217 |
unit_cost | optional string | 3 | Effective cost per unit (decimal string) |
Messages — Reporting
Section titled “Messages — Reporting”UsageReport
Section titled “UsageReport”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
idempotency_key | string | 2 | Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed report does not double-count usage. The report's durable identity is the Exchange-assigned report_id in UsageReportResponse. Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per (authenticated caller, key), never globally, so a key chosen by one caller cannot collide with another's cached result. |
transaction_id | string | 3 | Transaction ID from the delivery. |
billing_id | string | 4 | Billing record identifier from the delivery (TransactionResultItem.billing_id). |
usage | Usage | 5 | How the resource was actually used. |
timestamp | Timestamp | 6 | When the resource was used (ISO 8601). |
exchange | string | 8 | REQUIRED. Bare host of the recipient this report is addressed to (e.g. "exchange.example" or "exchange.example:8081") — the Exchange that issued the offer and therefore holds the reporting obligation. See "Request recipient" in the file header for the full contract. Promoted from optional: an absent or empty value used to skip the recipient check entirely, which made the check opt-in for the caller. |
assets | repeated UsageAsset | 9 | Assets that were delivered and used. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
ReportingObligation
Section titled “ReportingObligation”Requirements attached to a delivery.
| Field | Type | Number | Description |
|---|---|---|---|
required | bool | 1 | Whether post-usage reporting is required. |
window | optional Duration | 2 | Duration within which the report must be submitted (e.g. "86400s" = 24 hours; proto-JSON encodes Duration as seconds). |
endpoint | optional string | 3 | URL to submit the usage report to (if different from Exchange). |
required_fields | repeated string | 4 | Field names that must be present in the report. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
| Field | Type | Number | Description |
|---|---|---|---|
function | repeated string | 1 | How the resource was used. Standard values: "ai-train", "ai-input", "ai-index", "search", "display". Multiple allowed. CoMP-specific values available via fora-comp-v1 extension profile. |
subfn | repeated string | 2 | Sub-function detail. Standard values: "training", "rag", "grounding", "agent_view", "agent_actions". |
consumed_quantity | int32 | 3 | REQUIRED. Actual quantity consumed, in the metering unit from the Offer's Pricing. For text: tokens consumed. For video: seconds watched. For data: records accessed. Exchange cross-references against Offer.pricing.estimated_quantity. |
displayed_to_user | optional bool | 4 | Whether resource/output was displayed to a human. |
citation_included | optional bool | 5 | Whether citation was included as required by the offer terms. |
attribution | repeated AttributionDetail | 6 | Structured attribution details for each citation provided. |
consumed_unit | optional string | 8 | Metering unit for consumed_quantity. Must match the Offer's Pricing.unit. If omitted, defaults to "tokens". Same token format as Pricing.unit: a bare registered token or a vendor:namespaced token. |
AttributionDetail
Section titled “AttributionDetail”Structured attribution metadata for usage reporting.
| Field | Type | Number | Description |
|---|---|---|---|
displayed_url | optional string | 1 | URL displayed to the user as the attribution link. |
format | optional CitationFormat | 2 | How the citation was presented. |
visible_to_user | optional bool | 3 | Whether the attribution was visible to the end user. |
UsageAsset
Section titled “UsageAsset”A single asset included in the usage report.
| Field | Type | Number | Description |
|---|---|---|---|
uri | string | 1 | Asset URI |
title | optional string | 2 | Asset title |
package_id | optional string | 3 | Package identifier |
UsageReportResponse
Section titled “UsageReportResponse”Acknowledgment of a usage report. A successful response means the report was accepted; a rejection travels as a non-OK transport error carrying ErrorDetail.usage_report_rejection.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
report_id | string | 3 | Exchange-assigned report identifier. Required for the dispute chain — the agent must reference this report_id in DisputeRequest to prove that a usage report was filed before disputing. The complete evidence chain: Offer → Transaction (transaction_id, billing_id) → UsageReport → UsageReportResponse (report_id) → DisputeRequest (transaction_id + report_id) |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Messages — Dispute Resolution
Section titled “Messages — Dispute Resolution”DisputeRequest
Section titled “DisputeRequest”Agent signals a content delivery problem for a completed transaction.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
idempotency_key | string | 2 | Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed filing does not open a duplicate case. The dispute's durable identity is the Exchange-assigned dispute_id in DisputeResponse. Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per (authenticated caller, key), never globally, so a key chosen by one caller cannot collide with another's cached result. |
transaction_id | string | 3 | Transaction being disputed. |
billing_id | string | 4 | Billing record identifier from the disputed transaction (TransactionResultItem.billing_id). |
reason | DisputeReason | 5 | Reason for the dispute. |
description | optional string | 6 | Human-readable description of the issue. |
received_content_hash | optional string | 7 | Evidence: content hash of what was actually received. Exchange compares against the hash promised in ResourceIdentity. |
received_hash_method | optional string | 8 | Hash algorithm the agent used |
report_id | string | 9 | Must reference a filed UsageReport. The agent MUST file a UsageReport (via ReportUsage RPC) and receive a report_id BEFORE filing a dispute. This prevents fire-and-forget disputes and ensures the Exchange has the complete evidence chain: what was offered, what was transacted, what the agent reported using, and what the agent disputes. The dispute chain: Transaction → UsageReport → Dispute. |
exchange | string | 10 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. The dispute's subject identifiers above cannot stand in for it: transaction_id, billing_id and report_id are opaque and Exchange-scoped, so verifying one means a database lookup, while the recipient check must run before any lookup happens. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
DisputeResponse
Section titled “DisputeResponse”Exchange acknowledges and processes the dispute. A successful response means the dispute was accepted for processing; a refusal to file travels as a non-OK transport error carrying ErrorDetail.dispute_failure.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
dispute_id | optional string | 2 | Exchange-assigned dispute case identifier. |
estimated_resolution | optional Duration | 4 | Expected resolution timeline. |
status | DisputeStatus | 5 | Current lifecycle status of the dispute. Tracks progression through the three-tier resolution process: Tier 1 (automated, <1s): FILED → AUTO_RESOLVED or EVIDENCE_NEEDED Tier 2 (rule-based, <24h): UNDER_REVIEW → RESOLVED Tier 3 (pattern investigation, async): ESCALATED → SETTLED → FINAL Losing party may appeal: RESOLVED → APPEALED → back to UNDER_REVIEW. |
resolution | optional ResolutionType | 6 | Resolution outcome, populated when the dispute reaches a terminal state (RESOLVED, SETTLED, or FINAL). Absent while dispute is in progress (FILED, UNDER_REVIEW, ESCALATED, etc.). |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Messages — Domain Verification
Section titled “Messages — Domain Verification”DomainVerificationRequest
Section titled “DomainVerificationRequest”Request an ACME-style domain verification challenge.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
domain | string | 2 | The provider domain to verify (e.g., "techcrunch.com"). |
caller_id | optional string | 3 | Caller identity (registered with the Exchange). |
exchange | string | 4 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. Distinct from domain above, which is the provider domain being verified — the subject of the request, not its recipient. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
DomainVerificationChallenge
Section titled “DomainVerificationChallenge”Exchange returns a challenge token to be placed at the provider’s domain.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
token | string | 2 | Opaque challenge token. Provider must serve this at: https://{domain}/.well-known/fora-verify/{token} |
expires_at | Timestamp | 3 | When this challenge expires. Provider must confirm before this time. |
verification_url | string | 4 | The exact URL the Exchange will fetch to verify. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
DomainVerificationConfirmation
Section titled “DomainVerificationConfirmation”Confirm domain verification and register a signing key.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
domain | string | 2 | The domain being verified. |
token | string | 3 | The challenge token (echoed from DomainVerificationChallenge). |
signing_key | optional string | 4 | Optional: the delivery endpoint's verification key, registered atomically with the domain on successful verification. PUBLIC key material only. The Exchange signs delivery URLs with a private key it holds and never publishes; a delivery endpoint verifies with the public half and holds nothing secret. Where the Exchange has to sign with a key the provider generated -- a CloudFront trusted key group is the provider's own AWS resource -- the private half is provisioned to the Exchange out of band and never travels in this field. Format follows cdn_type: a PEM-encoded RSA public key for "cloudfront", or the base64url-encoded raw Ed25519 public key (the JWK "x" value) for "edge-ed25519". |
cdn_type | optional string | 5 | Which delivery-URL verification scheme this key is for: "edge-ed25519" (a code-capable edge that verifies the Ed25519 URL signature itself) or "cloudfront" (AWS CloudFront trusted key groups, RSA, verified natively by the CDN). One value per Exchange-side tenant signing scheme: "edge-ed25519" is ED25519, "cloudfront" is AWS_CLOUDFRONT_RSA. |
exchange | string | 6 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. Distinct from domain above, which is the provider domain being verified — the subject of the request, not its recipient. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
DomainVerificationResult
Section titled “DomainVerificationResult”A successful response means verification succeeded; a failure travels as a non-OK transport error carrying ErrorDetail.domain_verification_failure.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
key_id | optional string | 2 | If signing_key was provided: confirmation of key registration. |
valid_until | optional Timestamp | 4 | Verification is valid until this time. Provider must re-verify periodically. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Messages — Agent Account Registration
Section titled “Messages — Agent Account Registration”RegisterRequest
Section titled “RegisterRequest”Agent asks the Exchange to create its account. The caller’s identity is proven by the verified request signature, never asserted in the body; the business-registration payload rides in registration_data. Whether the Exchange inspects that payload follows its manifest: one publishing AccountRegistration.data_schema validates against that schema and refuses a non-conforming payload, one publishing none passes it through to its system of record uninspected. The payload is bounded either way, by four checks that run in a fixed order — top-level member count (at most 64), then nesting depth (at most 32 containers), then whether the payload has a canonical JSON form at all, then the size of that form (at most 16384 bytes of RFC 8785 canonical JSON) — because the cost of validating is the schema’s cost multiplied by the elements in the payload, and the unit has to be named for a field that arrives decoded rather than as bytes. The order is part of the contract: the two counts bound the document the third then has to walk, and the byte cap is defined as the length of the canonical encoding, so until that encoding exists there is no number to compare against. All four run before the schema does. Submitting a registration also states which terms the operator accepted — terms_digest echoes the value the manifest publishes, the request signature covers that echo, and the Exchange records the accepted digest with the account, so a later terms revision does not erase what was agreed.
Every gate above applies to account creation only. A repeat registration, from an agent that already holds an account, is answered from the stored record: the Exchange returns the existing billing_ref and changes nothing. It runs none of the registration_data checks, no terms gate and no schema check, because it discards registration_data rather than validating it — and because applying operator-controlled gates to a returning caller would break every one of them on the day the operator revises its terms. What a repeat still runs is everything outside those gates: the request signature is verified, exchange is checked against the recipient, and the field-level constraints still apply.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
registration_data | Struct | 2 | Business-registration data about the operator behind this agent — the details an Exchange needs to open a commercial account (legal entity, address, jurisdiction, tax identifiers and the like). The specific members are operator-defined and not fixed in the wire contract; whether the Exchange inspects them follows its manifest — see AccountRegistration.data_schema. This is NOT an identity claim: the caller's identity is taken from the verified request signature, never from this payload, so nothing here is trusted as authentication. Bounded, because a published data_schema is applied to THIS. The schema's own caps bound the schema; the cost of checking a payload against it is roughly that cost multiplied by the elements in the payload, and the multiplier was unbounded — a subschema under items is counted once by the schema's evaluation cap and evaluated once per element at runtime. At most 64 members at the top level. Top level rather than every level: nested bulk is already bounded by the byte cap below, and a recursive count would refuse a small document that merely nests, which a business entity legitimately does — an address is an object. At most 32 nested JSON containers, counting this member itself as the first — the same number and the same counting rule as the schema's own depth cap, because it is the same question asked of the other document. It is bounded for a reason the other two caps do not cover: a deeply nested payload is small and has few top-level members, and canonicalising it walks it RECURSIVELY, so without a stated bound the verdict was a property of the reader's runtime rather than of the payload — one implementation refused past roughly five hundred containers on one release of its language and accepted nine hundred on the next, while two others accepted every depth tried. Checked before the payload is canonicalised, so the bound precedes the walk it exists to bound. At most 16384 bytes, measured as this member's RFC 8785 (JCS) CANONICAL JSON encoding. The unit is named on purpose and is the load-bearing half of the rule: every other cap in this contract is over bytes a party actually served, and this member is never served as bytes — it is a Struct, decoded before any consumer sees it. "16KB" therefore means nothing until an encoding is chosen, and two implementations choosing privately is the same both-ends disagreement the schema rules exist to prevent. JCS also pins number formatting, which is not a detail: a payload carrying 1e300 is seven bytes under one renderer and three hundred under another. A payload with NO JSON REPRESENTATION at all is refused in the same class as the three bounds: it has no canonical encoding, so the byte cap above has nothing to measure. This member is a Struct, and a Struct can carry two such values — both of which the protobuf binary decoder accepts and proto-JSON refuses to render: a NON-FINITE NUMBER. JSON can represent neither NaN nor an infinity, while Struct's number_value is an IEEE-754 double that carries one perfectly well. a VALUE WITH NO KIND SET. google.protobuf.Value holds its payload in a oneof, and a oneof with no member set is well-formed on the wire. There is no JSON value it denotes, so there is nothing to write. That check MUST read the decoded protobuf value, never a native map converted from it. This is stated because two conformant implementations already answered the same signed request differently, and because NEITHER value survives the conversion. Some runtimes render a non-finite double as the STRING "NaN", "Infinity" or "-Infinity", and once that has happened the payload cannot be told apart from one that legitimately carries that text — an operator legally named NaN is a valid string value that has to be accepted. A value with no kind set converts to the same empty result as a JSON null, which is a value the payload may legitimately carry. In both cases the conversion destroys the information, so the check has to precede it. ORDER. The four checks on this member run in this sequence, and the sequence is not free choice: 1. top-level member count 2. nesting depth 3. canonicalizability, which is where both no-JSON-form checks live 4. canonical byte size The first two are counts, and they bound the document the third then has to walk. The third precedes the fourth because the byte cap is DEFINED as the length of the canonical encoding: until that encoding exists there is no number to compare against, and answering "too large" for a payload that has no encoding at all would state something untrue about it. All four are checked BEFORE the schema runs, for the reason the schema's own size cap is checked before the document is parsed — a check that exists to stop work has to precede the work. A payload breaking any of them is a malformed request, NOT REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA: that reason names non-conformance to a published schema and applies only where one is published, while these four hold either way. The terms_digest gate sits between these four and the schema — see RegisterRequest.terms_digest for why that order is also fixed. |
exchange | string | 3 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. It matters most here: the caller reaches this endpoint by resolving a fetched, cached manifest, so the RFC 9421 signature covers only the URL that was dialled — this field is what lets the genuine Exchange refuse a registration that was meant for a different one. |
terms_digest | optional string | 4 | Echo of WellKnownManifest.terms_digest, stating WHICH terms document the operator accepted. The request signature covers this statement, so it is the durable record a later dispute asks for; the Exchange stores the accepted value with the account. Four cases, all defined: the Exchange publishes a digest and this matches — registration proceeds; it publishes one and this differs — refused with REGISTRATION_FAILURE_REASON_TERMS_DIGEST_STALE; it publishes one and this is absent — refused with the SAME reason, because the caller's remedy is identical (read the manifest, echo, retry) and a second reason would split one fix in two; it publishes none and this is present — the Exchange MUST ignore the value and MUST NOT record it as an acceptance, since it publishes no digest and therefore cannot verify what document the value refers to, and storing it would put an unverifiable claim exactly where this field exists to hold a verified one. A registering client MUST read the digest from a FRESHLY fetched manifest rather than a cached copy — a cached endpoint is fine, a cached digest is not, because a client cannot detect staleness locally and a warm cache would otherwise make it retry a refused value until the cache expired. Registration happens once per Exchange, so the extra fetch is cheap. GATE ORDER. This gate runs AFTER the four registration_data checks (see RegisterRequest.registration_data) and BEFORE the published data_schema. Terms before schema is not arbitrary: the schema may itself have changed in the revision the caller has not read yet. Validating a stale-terms caller against the CURRENT schema hands back field errors describing a document it has never seen, so it fixes those members, re-fetches, and finds the requirements have moved. Terms first means a caller is always told to go read the current manifest before it is told anything about that manifest's contents. It also keeps one refusal to one remedy: TERMS_DIGEST_STALE says re-fetch and echo, INVALID_REGISTRATION_DATA says fix the payload, and a request that would earn both is given the one that has to be done first. The whole order applies only when an account is being CREATED: a repeat registration is answered from the stored record and runs no gate at all, so the four cases above are the four cases of a FIRST registration. See "Repeat registration" in the Agent Account Registration section header. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
RegisterResponse
Section titled “RegisterResponse”Exchange returns the minted billing_ref — the opaque, long-lived, per-Exchange account handle — and the account’s current active state. A repeat Register for the same agent returns the same billing_ref; a refused registration travels as a non-OK transport error carrying ErrorDetail.registration_failure.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
billing_ref | string | 2 | Opaque, long-lived, per-Exchange account handle minted by the Exchange. Means nothing on its own and is never accepted as caller input. A repeat Register for the same agent returns the same value. |
active | bool | 3 | Whether the account is currently active. Accounts may start inactive and be activated out-of-band by the Exchange operator. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
GetAccountStatusRequest
Section titled “GetAccountStatusRequest”Read-only check of whether the calling agent’s account is active. Deliberately carries no identifying field — the Exchange resolves the account from the verified request signature.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
exchange | string | 2 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. Accounts are per-Exchange, so "which Exchange am I asking about" is not derivable from anything else in this message. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
GetAccountStatusResponse
Section titled “GetAccountStatusResponse”The account’s current state. billing_ref is empty when the calling agent has no account yet.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
billing_ref | string | 2 | The account handle minted at registration (see RegisterResponse.billing_ref). Empty when the calling agent has no account yet. |
active | bool | 3 | Whether the account is currently active. |
terms_digest | optional string | 4 | The terms document this account ACCEPTED: the "method:hexdigest" value the agent echoed in RegisterRequest.terms_digest, which the Exchange recorded with the account. This is the read side of that record. Without it the protocol required an Exchange to store the acceptance and named the question the record exists to answer — "which terms did this operator accept" — while giving no way to ask it, so the only party who could check what it had agreed to was the party holding the database. An Exchange that holds a recorded digest for this account MUST return it here. Absence has exactly ONE meaning: no acceptance is recorded. Two situations produce it — the Exchange publishes no terms_digest, so nothing was ever accepted (and per RegisterRequest.terms_digest it MUST NOT record a presented value in that case), or the account was created before the operator began publishing one. It does NOT mean "this account has no terms", and an Exchange MUST NOT withhold a digest it holds: absence is already spoken for, so withholding would make the field state something untrue. The value is what was ACCEPTED, not what is published now. The two differ as soon as the operator revises its terms, and that difference is the point: comparing this against a freshly fetched WellKnownManifest.terms_digest is how an agent discovers that the terms moved under an account it already holds. A repeat Register will not tell it — a repeat is answered from the stored record and runs no gate at all. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Messages — Discovery
Section titled “Messages — Discovery”WellKnownManifest
Section titled “WellKnownManifest”Served at /.well-known/fora.json by every FORA participant (agent, exchange, broker, publisher). Single canonical document, role-tagged via role. Signing keys are not carried here — each participant publishes them in its WBA directory (the JWK Set at /.well-known/http-message-signatures-directory, modeled by WBAFile). Per-role fields are populated only when that role applies; consumers MUST ignore non-applicable fields based on role.
Two advertised addresses carry the same host binding: endpoint (ExchangeService) and catalog_endpoint (CatalogService) MUST each be on the host and port that serve the manifest, or a subdomain of that host on that port, and MUST NOT carry userinfo — because a signed call goes to that address and a manifest naming an unrelated host would redirect it to a party the signature never covered. An absent catalog_endpoint means the Exchange does not expose CatalogService; a consumer does not fall back to endpoint. See Endpoint host binding.
Who checks each is not the same today. The rule on endpoint is enforced below the caller: it runs inside the shared endpoint resolver in all three SDKs, before the value is returned and before it is cached, and it is corpus-locked. No SDK reads catalog_endpoint at all — a publisher configures the catalog address the way the agent client’s home Exchange is configured — so a deployment that does read it from a manifest MUST apply the binding itself before dialling. The MUST is on the consumer, not on a library it can assume.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | Version of THIS MANIFEST DOCUMENT's layout — "1.0", stamped by the party that serves the document from the SDK's WellKnownManifestVersion, never from ProtocolVersion. A namespace separate from the RPC envelope ver: a change to this document's layout bumps both numbers, a protocol change that leaves the document untouched bumps only the envelope's, so a reader that parses only manifests upgrades when the manifest changes and at no other time. Consumers read ver before any other member. They ACCEPT a recognised MAJOR version whatever the MINOR — a minor revision of the manifest is additive, and a reader ignores members it does not know. They REJECT an unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT ver: the document sits at a fixed, unversioned path and is read before any signature is checked, so a layout the reader cannot classify must not supply anything a signed call then carries — the endpoint that call is sent to, or the terms_digest it echoes and its signature covers. It binds every consumer of this document rather than one use of it. In the SDK that is both manifest-reading faces, in all three languages — the endpoint resolver and the registration-requirements reader — each applying it at its own call site, pinned to one corpus. The key face is exempt because it reads a plain JWK Set, which carries no version of this document and never will. |
role | Role | 2 | Role this manifest describes. |
domain | string | 3 | Canonical domain serving this manifest. |
contact | optional string | 4 | Contact email (licensing, integration, security). |
exchanges | repeated AuthorizedExchange | 7 | Publisher-only. Authorized exchanges for this publisher's resources. Like ads.txt — declares who may sell. MUST be empty for non-publisher roles. |
catalog_contributors | repeated CatalogContributor | 8 | Publisher-only. Authorized third-party catalog contributors. MUST be empty for non-publisher roles. |
name | optional string | 9 | Exchange-only. Human-readable Exchange name. |
operator | optional string | 10 | Exchange-only. Organization operating this Exchange. |
operator_domain | optional string | 11 | Exchange-only. Operator's corporate domain (may differ from domain). |
endpoint | optional string | 12 | Exchange-only. ExchangeService endpoint URL. MUST be on the same host AND PORT that serve this manifest, or on a subdomain of that host on that port, and MUST NOT carry userinfo. A consumer refuses an endpoint anywhere else: this document is only as trustworthy as the host that served it, so an endpoint naming an unrelated host would let whoever answers for the manifest redirect a signed call to a party the signature never covered, and another port is another service the publisher of the manifest need not control. The host match is on a full dot-delimited label boundary, so evil-a.com is not a subdomain of a.com. A port equal to the scheme's default and an omitted port are the SAME port, so https://x, https://x:443 and x all match. An Exchange reachable on a non-default port names that port on both sides. |
health_endpoint | optional string | 13 | Exchange-only. Health check endpoint URL. |
catalog_endpoint | optional string | 14 | Exchange-only. CatalogService endpoint URL (if exposed). It carries the same binding as endpoint: it MUST be on the same host AND PORT that serve this manifest, or on a subdomain of that host on that port, and MUST NOT carry userinfo. A consumer refuses a catalog endpoint anywhere else — a publisher's push is a signed call, and a manifest naming an unrelated host would redirect it to a party the signature never covered. The host match is on a full dot-delimited label boundary, and a port equal to the scheme's default and an omitted port are the SAME port. Absent means this Exchange does not expose CatalogService; a consumer does not fall back to endpoint. |
protocol_versions_supported | repeated string | 16 | Exchange-only. Supported FORA protocol versions (e.g. ["1.0"]). |
pricing_models_supported | repeated PricingModel | 17 | Exchange-only. Supported pricing models. |
delivery_methods_supported | repeated DeliveryMethod | 18 | Exchange-only. Supported delivery methods. |
hash_methods_supported | repeated string | 19 | Exchange-only. Accepted resource hash methods for attestation verification. |
accepted_verifiers | repeated string | 20 | Exchange-only. Trusted attestation verification vendors (domains). |
terms_uri | optional string | 21 | Exchange-only. Terms of service URL. |
privacy_uri | optional string | 22 | Exchange-only. Privacy policy URL. |
supported_profiles | repeated string | 23 | Exchange-only. Domain extension profiles this Exchange conforms to. See standards-layering docs. |
supported_auth_methods | repeated AuthMethod | 24 | Exchange-only. Authorization methods this Exchange supports (ordered by preference). |
oidc_issuer | optional string | 25 | Exchange-only. OIDC Discovery URL when OAuth methods are supported. |
gnap_grant_endpoint | optional string | 26 | Exchange-only. GNAP grant endpoint when GNAP is supported. |
base_currency | optional string | 27 | Exchange-only. Base currency for pricing (ISO 4217). All unit_cost values from this Exchange are denominated in this currency. |
max_intermediary_hops | optional int32 | 28 | Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message Signatures. A request carrying more SHOULD be rejected. Lets Exchanges publish their chain-depth tolerance so Brokers prune before forwarding. Absent = no published limit (Exchange applies its own default policy). |
account_registration | optional AccountRegistration | 30 | Exchange-only. How to open an account here — see AccountRegistration, which owns the contract. Absent: registration_data is accepted uninspected, exactly as before this field existed. |
terms_digest | optional string | 31 | Exchange-only. Digest of the document served at terms_uri, in "method:hexdigest" form (e.g. "sha256:9f86d081..."), pinning WHICH terms document this manifest is currently offering. terms_uri alone cannot answer that: it is a URL, and its content changes, so after the first revision every earlier registration points at a document that no longer says what was agreed. RegisterRequest.terms_digest echoes this value, the request signature covers that echo, and the Exchange records the accepted digest with the account — which is what makes "which terms did this operator accept" answerable later, through GetAccountStatusResponse.terms_digest, which is where the accepted value is read back. Because a digest identifies a document only while a copy of it still exists, keeping the historical terms documents retrievable is the Exchange's obligation. It sits at the top level rather than inside account_registration on purpose: an Exchange with pass-through registration publishes no block yet still needs to pin its terms version, and coupling "I enforce a schema" to "I version my terms" would tie together two independent decisions. Operator note: publishing this field for the first time refuses every client that does not yet echo it, so it is a coordinated change rather than a safe addition. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown values reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown. |
AccountRegistration
Section titled “AccountRegistration”How to open an account at an Exchange, published as the optional account_registration block on the manifest. Omitting the whole block keeps the pass-through behaviour: registration_data is accepted uninspected. data_schema is the API mode — publishing it commits the Exchange to enforcing it on account creation, and an Exchange that publishes it MUST accept registration through the API. The gate runs on creation only: a repeat registration is answered from the stored record and runs no schema check at all. A future web mode (a URL to a registration page where a human completes steps an API call cannot carry) is an additional option an agent MAY offer its user instead, never a replacement for the API path. Terms versioning deliberately lives outside this block, on WellKnownManifest.terms_digest, so an Exchange with pass-through registration can still pin which terms document it serves.
data_schema carries a normative safety rule set, because a client reads it out of a third party’s manifest and validates against it before any signature has been checked: same-document references only and no reference cycles, one pinned dialect, a JSON object at the top level, a 16KB size cap, a 32-container depth cap, a 10,000-evaluation work cap, a restricted pattern alphabet with no nested quantifiers, and format left as an annotation. The bounds are numbers in the contract rather than each implementation’s choice, because a schema is validated at both ends of one registration and a limit invented by one side refuses payloads the other accepts. The work cap is the one that is easy to omit: the size and depth caps bound the document and say nothing about how expensive checking a payload against it is, and branches multiply along a reference chain. See Registration schema rules for the list and the reasoning.
| Field | Type | Number | Description |
|---|---|---|---|
data_schema | Struct | 1 | JSON Schema (draft 2020-12) describing the RegisterRequest.registration_data object this Exchange expects. This field is the single home of the enforce/pass-through contract, and publishing it IS the enforcement switch. Present: this Exchange validates registration_data against the schema and refuses a non-conforming payload with REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the offending members in RegistrationFailure.field_errors. Absent: registration_data is passed through to the system of record uninspected, so an Exchange that publishes no schema needs no change to stay conformant. The gate above runs on ACCOUNT CREATION ONLY. A repeat registration, for an agent that already holds an account, is answered from the stored record and runs no schema check at all — it discards registration_data rather than validating it. That exception is stated here rather than left to the section above, because this field calls itself the single home of the contract and a reader who comes here for the whole rule would otherwise leave with the wrong one. See "Repeat registration" in the Agent Account Registration section for why, and for the other gates it applies to. Absent means the field carries no bytes, or only JSON whitespace — space, tab, carriage return and line feed, RFC 8259's four and no others. Nothing else counts, and the distinction is load-bearing rather than pedantic: this is the enforcement switch, so a byte sequence read as absent is one that turns validation OFF. A consumer that asked its own language what "blank" means got three different answers to the same document — U+00A0 and U+3000 are whitespace to some runtimes and not others, and a decoder that strips a byte order mark makes a mark followed by a space look like nothing at all. A document that is not empty and not JSON is malformed, which is a refusal; it is never silence. Safety rules, because a consumer reads this schema out of a THIRD PARTY's manifest and validates against it before any signature has been checked. A publisher MUST satisfy every rule below and a consumer MUST refuse a schema that does not. The bounds are stated here as numbers rather than left to each implementation on purpose: a schema is validated at both ends of the same registration, and a limit chosen privately by one side refuses payloads the other accepts. Self-contained. Every $ref, $dynamicRef and $recursiveRef MUST be a same-document reference — it begins with "#". A consumer MUST NOT resolve a reference that leaves the document: doing so turns every reader into an SSRF vector aimed at a URL the schema's author chose. One dialect. $schema, wherever it appears in the document, MUST name https://json-schema.org/draft/2020-12/schema (an empty fragment on the end is the same value). A document declaring none is read as that dialect. An older draft is refused rather than validated under semantics its author did not intend. Data is not schema. const, enum, default and examples hold arbitrary JSON VALUES, and their contents are never read as keywords: a const whose value happens to carry a "$ref" or "$schema" member states a value a payload may equal, not a reference to resolve or a dialect to honour. Both rules above therefore stop at those four keywords, and so does the pattern alphabet. Their nesting still counts against the depth cap. Likewise the child keys of properties, patternProperties, $defs, definitions and dependentSchemas are NAMES rather than keywords, so a property called "$ref" is a property. Bounded size: 16KB, measured as the UTF-8 bytes of this member AS SERVED in fora.json. One encoding. The bytes MUST be well-formed UTF-8 (RFC 8259 requires it for interchange) and MUST NOT begin with a byte order mark. RFC 8259 forbids ADDING a mark and lets a parser ignore one, so both policies conform and the choice is made here rather than left to each implementation: parsers differ, and one that strips a mark validates a different document — and counts three bytes against the size cap that the schema does not contain. A consumer MUST NOT repair ill-formed bytes either; substituting U+FFFD silently enforces a schema nobody published. A mark is in any case only valid at the start of a JSON text, and this is a member inside one. Bounded depth: 32 nested JSON containers, counting the schema itself as the first. Bounded WORK: 10000 evaluations, counted statically over the SCHEMA — each anyOf/oneOf/allOf branch and prefixItems entry costs its own subschema, and a $ref costs its target. It is the cost of applying the schema at ONE location in a payload, not of a whole payload: a subschema under items is counted once here and evaluated once per element at runtime. The size and depth caps do not bound it and are not a substitute for it: branches multiply along a reference chain, so a 1.6KB schema five containers deep can cost tens of millions of evaluations and tens of seconds against a two-member payload. A definition nobody references costs nothing, so a document may carry a library of them. Bounded reference chains: 100 hops, counted as the longest path of $ref hops rather than as the number of references the document contains. This is a THIRD axis, and a flat chain of definitions shows why it has to be: each one referring to the next is three JSON containers deep however long it is, so the depth cap never sees it, and it costs one evaluation per link, so the work cap does not either. What it does reach is the recursion a validator performs while resolving the chain — a chain of a few hundred exhausted one implementation's stack outright, on a document every other rule had passed. A schema describing a business entity chains one or two references. No reference cycles. A $ref chain MUST NOT return to a schema already on it. The construct is legal JSON Schema and is how a recursive structure is written, but its evaluation cost has no static bound and it is what makes a validator recurse until it aborts. Registration data describes a business entity, which is not a recursive shape. A portable pattern alphabet, stated as what a pattern MAY contain rather than as what it may not. A group MUST open with "(" or "(?:" and nothing else; only the escapes $, (, ), *, +, ., /, ?, \D, \W, [, \, ], ^, \d, \f, \n, \r, \t, \v, \w, {, \ |
WBAFile
Section titled “WBAFile”The pure WBA directory, served at /.well-known/http-message-signatures-directory (Content-Type: application/jwk-set+json). Holds each participant’s inline signing keys and an optional emergency-revocation pointer. Keys are identified by their RFC 7638 thumbprint (the RFC 9421 keyid).
| Field | Type | Number | Description |
|---|---|---|---|
keys | repeated JsonWebKey | 1 | Signature-verification keys; ≥1 valid at serve time |
revocation_url | optional string | 2 | Emergency key-revocation list URL (KeyRevocationList) |
JsonWebKey
Section titled “JsonWebKey”Inline RFC 7517 JWK. FORA v1.0 supports Ed25519 only. Time bounds are RFC3339 strings; the validity window is half-open [not_before, not_after). Keys carry no kid label — a key is identified by its RFC 7638 thumbprint (the RFC 9421 keyid).
| Field | Type | Number | Description |
|---|---|---|---|
kty | string | 2 | Key type. FORA v1.0: MUST be "OKP". |
crv | string | 3 | Curve. FORA v1.0: MUST be "Ed25519". |
use | string | 4 | Intended key use. FORA v1.0: MUST be "sig". |
alg | string | 5 | Signing algorithm. FORA v1.0: MUST be "EdDSA". |
x | string | 6 | base64url-encoded 32-byte Ed25519 public key. |
not_before | string | 7 | RFC3339 timestamp. Key is invalid before this instant. |
not_after | string | 8 | RFC3339 timestamp. Key is invalid at and after this instant (strict upper bound). |
KeyRevocationList
Section titled “KeyRevocationList”Body served at WBAFile.revocation_url. Snapshot semantics: revoked is the complete set of revoked key thumbprints at as_of; consumers replace their local revocation set on each successful poll.
| Field | Type | Number | Description |
|---|---|---|---|
as_of | Timestamp | 1 | Server's response time (RFC3339, UTC). Consumers use this to detect clock skew. |
revoked | repeated string | 2 | Complete list of revoked key thumbprints (RFC 7638, base64url-no-pad) at as_of. |
CatalogContributor
Section titled “CatalogContributor”Authorizes a third party to push catalog metadata on the provider’s behalf.
| Field | Type | Number | Description |
|---|---|---|---|
domain | string | 1 | Canonical domain of the authorized contributor (e.g., "doubleverify.com"). |
relationship | string | 2 | Relationship of this contributor to the provider. Examples: "verifier" (resource intelligence vendor that attests to resource properties), "exchange" (an Exchange that enriches catalog entries). |
AuthorizedExchange
Section titled “AuthorizedExchange”A Exchange authorized to sell this provider’s content.
| Field | Type | Number | Description |
|---|---|---|---|
domain | string | 1 | Canonical domain of the Exchange, in the shape "Request recipient" defines in the file header. |
endpoint | string | 2 | FORA ExchangeService endpoint URL. |
relationship | ProviderRelationship | 3 | Relationship type (mirrors ads.txt DIRECT/RESELLER). |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Messages — Broker Protocol
Section titled “Messages — Broker Protocol”Messages for the Agent-to-Broker path (Steps 1 and 6). When an agent talks directly to an Exchange, it uses ResourceQuery/TransactionRequest instead.
DiscoveryRequest
Section titled “DiscoveryRequest”Agent sends to Broker (Step 1), carried by BrokerService.Resolve. Pure discovery: it returns offers, executes no transaction, and so carries no idempotency_key (retrying is naturally safe). Correlation rides on the X-Request-ID header, not the body.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
requester | Requester | 3 | Requester identity — who is making this request, what scopes they have. The Broker forwards this to Exchanges in ResourceQuery.requester. |
uris | repeated string | 8 | Resource URIs the agent wants. The Broker forwards these to Exchanges in ResourceQuery.uris. Optional when query / search_filters drive Broker-side discovery instead. |
acceptable_restrictions | repeated AcceptableRestriction | 9 | The limits the agent will operate within, per restriction axis — see AcceptableRestriction. The Broker forwards these to Exchanges in ResourceQuery.acceptable_restrictions. Advisory selection inputs, not enforcement. |
constraints | optional RequestConstraints | 4 | Constraints for exchange filtering and offer selection. |
supported_profiles | repeated string | 5 | Domain extension profiles the agent understands. The Broker uses this to: 1. Route queries to Exchanges that support these profiles 2. Forward the profiles in ResourceQuery.supported_profiles 3. Include profile-specific ext fields when returning results Examples: ["fora-academic-v1"] — agent working on literature review |
query | optional string | 6 | Search query for Broker-side resource discovery. Used when the agent doesn't know specific URIs but wants the Broker to find matching resources across Exchanges. When present, the Broker interprets the query and discovers resources across Exchanges on the agent's behalf. Results returned as Offers in DiscoveryResponse, same as for specific URI requests. Can be used alongside uris (specific URIs + search in one request). |
search_filters | optional Struct | 7 | Structured search filters (optional, alongside or instead of query). Keys are profile-specific: "academic.topic", "news.category", "legal.jurisdiction", etc. The Broker maps these to Exchange-specific query parameters. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
RequestConstraints
Section titled “RequestConstraints”Budget and preference constraints for exchange filtering and offer selection.
| Field | Type | Number | Description |
|---|---|---|---|
exchanges | repeated string | 1 | Authorized Exchange domains, in the shape "Request recipient" defines in the file header. Broker queries only these. This is a FILTER over third parties, not an address — the recipient of the request carrying it is a separate question. |
max_price | optional Cost | 2 | Maximum price the agent is willing to pay. |
max_unit_cost | optional string | 3 | Maximum effective cost per unit, as an exact decimal string (not a float). |
delivery_preference | repeated DeliveryMethod | 4 | Preferred delivery methods, in order of preference. |
reporting_capable | optional bool | 5 | Whether the agent supports post-usage reporting. |
preferred_exchanges | repeated string | 6 | Exchanges the agent has existing relationships with (subscriptions, contracts). The Broker SHOULD prefer these when resource is available — subscription resource has zero marginal cost. |
budget_scope | optional string | 7 | Budget scope identifier for per-period tracking. E.g. "user:u-12345" for per-user budgets, "team:eng" for per-team. The Broker tracks cumulative spend per scope across sessions. |
period_budget | optional Cost | 8 | Per-period budget limit. The Broker tracks spend against this for the budget_scope. Transactions that would exceed are denied. |
budget_period | optional Duration | 9 | Budget period (e.g. "2592000s" = 30 days; proto-JSON encodes Duration as seconds). Resets at period boundary. |
max_data_age | optional Duration | 10 | Maximum acceptable age of resource data. The Broker SHOULD exclude offers where (now - Offer.data_as_of) exceeds this duration. Only relevant for DYNAMIC resources. Ignored for STATIC (content is immutable) and LIVE (content doesn't exist yet). Examples: 7 days — "credit report updated within the last week" 1 hour — "stock snapshot from the last hour" 30 days — "drug interaction database updated this month" |
max_hops | optional int32 | 11 | Maximum forwarding hops the agent will allow (Agent → Broker → … → Exchange), counted as the number of RFC 9421 HTTP Message Signatures on the request. Caps chain depth so a request is not relayed through more brokers than the agent is willing to trust or pay. A Broker MUST NOT forward a request whose signature count would exceed this. Absent = agent imposes no cap (the Exchange's max_intermediary_hops still applies). |
DiscoveryResponse
Section titled “DiscoveryResponse”Broker returns to Agent (Step 6). Discovery-only: offer_groups (field 4, one OfferGroup per requested URI, each carrying the full signed Offer with Offer.exchange as the execute-routing target) or absence_reason (field 16) with empty offer_groups. There are no inline delivery fields — the per-transaction result rides on TransactionResponse via the separate execute path.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
offer_groups | repeated OfferGroup | 4 | Offers grouped by requested URI — the sole offer representation in this response. One OfferGroup per URI the agent asked for (echoed in OfferGroup.uri); a group with no offers carries OfferGroup.absence_reason explaining why. Each contained Offer is the full signed Offer the Exchange issued (including Offer.exchange, the execute-routing target), forwarded by the Broker unchanged so the agent can verify the signature end to end. |
absence_reason | optional OfferAbsenceReason | 16 | Why the resolve produced no offers at all. Set (and offer_groups empty) on a successful "no result" answer; unset when offer_groups is non-empty. Same vocabulary DiscoverResources uses for OfferGroup.absence_reason. RESTRICTION_FILTERED may appear here, but Resolve does not surface the per-axis detail: DiscoveryResponse has no restriction_filters companion (unlike OfferGroup). A consumer needing the filtered axes calls DiscoverResources. Existence-oracle note: an authorization-flavored reason (SCOPE_INSUFFICIENT, NOT_AUTHORIZED, NOT_IN_CATALOG, CONTENT_BLOCKED) confirms a resource exists and why access was refused. Resolve surfaces the same oracle at the broker that OfferGroup.absence_reason does at the Exchange, so the same mitigation applies: where existence itself must stay hidden, the Broker MAY omit the reason (leave this unset) rather than reveal it. See the threat model. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
Messages — CatalogService
Section titled “Messages — CatalogService”Messages for the optional CatalogService RPC — the publisher role’s surface: push, remove and refresh the catalog entries a publisher, or a contributor it authorised, supplies to an Exchange.
Every pushed entry is checked in two tiers, and both are stated so a publisher can run them before sending. The wire tier is protovalidate: the ResourceEntry envelope rules below and the LicenseTerm rules, applied to the request exactly as received. The ingest tier runs over the canonicalised terms: restriction tokens are trimmed of RFC 8259 whitespace, ASCII-case-folded (lower for RESTRICTION_KIND_FUNCTION and RESTRICTION_KIND_USER_TYPE, upper for RESTRICTION_KIND_GEOGRAPHY; a non-ASCII byte is never folded) and alias-resolved to their registered token; then a bare (non-namespaced) Pricing.unit or Quota.metric that is not a registered token is rejected, as is a restriction whose permitted and prohibited lists name one token once folded (restriction.canonical_disjoint), while an unregistered restriction token and an OBLIGATION_KIND_OTHER obligation without detail are accepted and reported in PushResourcesResponse.warnings. Disjointness is the one property both tiers assert, over different values, so a term the boundary clears can still be refused at ingest. The SDK ships both tiers (ValidateResourceEntry and its Python/TypeScript twins) and a catalog client (NewCatalogClient / createCatalogClient / CatalogClient) in all three languages; the Exchange’s own run of the checks is the deciding one.
PushResourcesRequest
Section titled “PushResourcesRequest”Push or update content entries in the Exchange catalog. entries carries at least one entry and at most 256 — an empty push is refused rather than answered with zero counts, and a larger feed is pushed in several submissions. The cap bounds one submission, not the work of checking it: that is bounded by the maximum request size the recipient will read. exchange is the bare domain of the Exchange the push is meant for (the recipient-addressing rule), and caller_id names the contributor whose key signed the request.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
tenant_id | string | 2 | Tenant identifier |
entries | repeated ResourceEntry | 3 | Content entries to push. At least one: an empty push asks for nothing and is refused rather than answered with zero counts. At most 256, the bound a caller-chosen batch carries elsewhere in this contract (see ResourceQuery.uris) — it bounds one submission, so a larger feed is pushed in several. The cap is over entries because a submission is stored or refused whole, and a refusal names each entry that failed; it does not bound the work of checking a submission, which the recipient bounds at the transport. |
caller_id | string | 4 | Identity of the caller (who is pushing this data). The Exchange verifies this matches a registered CatalogService client. |
exchange | string | 5 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. Distinct from tenant_id above, which names a publisher tenant WITHIN an Exchange, not the Exchange itself. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
ResourceEntry
Section titled “ResourceEntry”A single resource catalog entry. The envelope carries its own wire rules, so an entry that cannot become a catalog URI is refused at the boundary rather than after ingestion. The list caps below bound how many — how many terms one entry may carry, how many entries a submission can store — never how many bytes, and not the work of checking one: a validator walks every element it is handed before any cardinality rule is reported, so that cost is bounded at the transport instead. Several members inside an entry carry no length rule, so a conformant push has no size ceiling and the transport cap can refuse one.
domainis a bare host in the recipient-addressing shape (a port is allowed; a scheme, path, query or userinfo is not; at most 260 characters). Withpathit forms the catalog URI by concatenation, so anything but a host would choose the URI rather than name the host.pathis an absolute URL path: it starts with/, carries no?or#, no whitespace and no control character, and is 1–2048 characters.title(512),content_idandcontent_hash(255),hash_method(64) andprovenance_source(260) are length-bounded, in characters.content_hashis deliberately not format-checked — a bare hex digest and amethod:hexdigestform both travel — becausehash_methodnames the algorithm.word_countandestimated_quantityare non-negative.- Every length above is characters — Unicode code points, not bytes, which is what
protovalidate’smax_lencounts.path’s pattern admits non-ASCII and the unpatterned fields admit anything, so a conformant value can exceed its character count in bytes several times over. attestationscarries at most 64 entries andtermsat most 32, stated on the wire so every implementation refuses the same size. Because the terms cap is a wire rule, an over-cap entry is refused at the boundary before any per-entry classification runs — which is whyCATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDEDcan no longer be produced for a push.resource_mutability, when present, must not beRESOURCE_MUTABILITY_UNSPECIFIED; omitted, the Exchange appliesRESOURCE_MUTABILITY_STATICat Offer build.
| Field | Type | Number | Description |
|---|---|---|---|
domain | string | 1 | Provider domain — the bare host the resource lives on, in the shape "Request recipient" defines in the file header: a port is allowed, a scheme, path, query or userinfo is not. With path it forms the catalog URI by concatenation, so a value carrying anything but a host would choose the URI rather than merely name the host. |
path | string | 2 | Content path — an absolute URL path such as "/premium/article-42.html": starts with "/", carries no query or fragment delimiter, no whitespace and no control character, and is at most 2048 characters. Characters, not bytes: protovalidate's max_len counts Unicode code points, and the pattern admits non-ASCII, so a conformant path can exceed 2048 bytes. |
content_id | optional string | 3 | Content identifier |
title | optional string | 4 | Content title |
word_count | optional int32 | 5 | Word count |
estimated_quantity | optional int32 | 6 | Estimated quantity in the metering unit |
content_hash | optional string | 7 | Content hash, carried as the publisher computed it — a bare hex digest or a "method:hexdigest" form; bounded in length, never format-checked, because hash_method names the algorithm. |
hash_method | optional string | 8 | Hash algorithm |
source | optional IngestionSource | 9 | How the entry was discovered |
provenance_source | optional string | 10 | Who provided this resource metadata. Creates audit trail for "where did this catalog entry come from?" |
provenance_timestamp | optional Timestamp | 11 | When this metadata was collected/generated. |
attestations | repeated ResourceAttestation | 12 | Signed attestations about this resource entry. Same semantics as Offer.attestations — see ResourceAttestation message for verification levels and claim vocabulary. Attestations pushed via CatalogService are verified at push time: the Exchange checks that the attestation verifier is authorized to push for this provider (via catalog_contributors in the provider's WellKnownManifest) and validates the attestation signature against the verifier's public key from its WBA directory (the JWK Set at /.well-known/http-message-signatures-directory; the keyid is the key's RFC 7638 thumbprint). The verifier's fora.json carries only its role, determined by the verifier's operator. |
terms | repeated LicenseTerm | 13 | Publisher-declared licensing terms for this resource. See LicenseTerm for the full model. For ENUMERATED terms, Pricing MUST be present. For REFERENCE_ONLY terms, License.uri is authoritative. The Exchange validates ENUMERATED terms at push time and surfaces them in Offer.terms on discovery. At most 32 terms per entry, stated on the wire so every implementation refuses the same size. An over-cap entry refuses the whole submission, as every catalog rejection does; what being a wire rule changes is WHEN — the refusal now happens at the boundary, before any per-entry classification runs, which is why the rejection reason that named this cap can no longer be produced for a push. |
resource_mutability | optional ResourceMutability | 14 | Optional mutability hint. When omitted, the Exchange applies the STATIC default at Offer build; an explicit UNSPECIFIED is rejected. A value in ext is not read — the typed field is authoritative, so an ext-only value is treated as omitted. Mirrors the required Offer-side ResourceIdentity.resource_mutability. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
PushResourcesResponse
Section titled “PushResourcesResponse”accepted tallies the entries a successful push stored — and because a push is all-or-nothing, a success stores every entry it carried. A push that could not be applied travels as a non-OK transport error carrying ErrorDetail.catalog_rejection.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
accepted | int32 | 2 | Number of entries accepted. A push is all-or-nothing, so a successful push stored every entry it carried and this is the submission's own size. A push that could not be applied is not a response at all: it travels as a non-OK transport error carrying ErrorDetail.catalog_rejection. |
rejected | int32 | 3 | Number of entries rejected. Structurally always 0 on this path, and kept for the same reason CATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDED is kept: a rejection returns an error rather than a response, so there is no successful answer in which this can be non-zero. It remains meaningful only for a deployment that applies catalog rules somewhere the all-or-nothing rule above does not front. Do NOT read a zero here as "nothing failed" — read accepted. |
warnings | repeated string | 4 | Non-fatal issues encountered during ingestion — the ingest tier's lint, which accepts the term and flags it. Examples: an unregistered bare restriction token on any axis, and an OBLIGATION_KIND_OTHER obligation with no detail. Warnings do not cause rejection; they are surfaced so publishers can fix their feeds without a hard failure. A condition that rejects is not a warning — a REFERENCE_ONLY term with no License.uri, for instance, is refused by license_term.reference_only.requires_uri and never reaches this list. |
ext | Struct | 15 | Extension point |
ext_critical | repeated string | 90 | Critical extension keys (COSE crit pattern, RFC 9052). Lists keys within ext that the consumer MUST understand. Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → all ext keys are safe to ignore. |
RemoveResourcesRequest
Section titled “RemoveResourcesRequest”paths carries at least one path and at most 256 — the same batch bound PushResourcesRequest.entries carries — each in the same absolute-path shape ResourceEntry.path carries.
| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
tenant_id | string | 2 | Tenant identifier |
paths | repeated string | 3 | Paths to remove — the absolute-path shape ResourceEntry.path carries, at least one and at most 256, the same batch bound PushResourcesRequest.entries carries and for the same reason. |
exchange | string | 4 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. Distinct from tenant_id above, which names a publisher tenant WITHIN an Exchange, not the Exchange itself. |
RemoveResourcesResponse
Section titled “RemoveResourcesResponse”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
removed | int32 | 2 | Number of entries removed |
RefreshCatalogRequest
Section titled “RefreshCatalogRequest”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
tenant_id | string | 2 | Tenant identifier |
exchange | string | 3 | REQUIRED. Bare host of the recipient this request is addressed to (e.g. "exchange.example" or "exchange.example:8081"). See "Request recipient" in the file header. Distinct from tenant_id above, which names a publisher tenant WITHIN an Exchange, not the Exchange itself. |
RefreshCatalogResponse
Section titled “RefreshCatalogResponse”| Field | Type | Number | Description |
|---|---|---|---|
ver | string | 1 | FORA protocol version — "1.0". Stamped by the sender from a single constant; advisory on receive. See "Protocol version" in the file header. |
started | bool | 2 | Whether the refresh was started |
Universal Licensing Core
Section titled “Universal Licensing Core”A resource carries zero or more LicenseTerm entries — each term is a complete commercial arrangement. Multiple terms are the normal case: a news article may be free for academic use and paid for commercial use; a stock photo may be perpetually licensed with an impressions cap.
See /protocol/licensing-terms for a full conceptual walkthrough with examples.
LicenseTerm
Section titled “LicenseTerm”One complete access arrangement for a resource. Lives at both ingestion (ResourceEntry.terms) and emission (Offer.terms).
Validation rules (wire-enforced via protovalidate unless noted). A parenthetical
code is the cross-field CEL rule id: that enforces the rule (e.g. license_term.reference_only.requires_uri); single-field rules use protovalidate’s standard constraints (enum.not_in, string.pattern, …).
pricingMUST be present on every term, any semantics — absent Pricing is a validation error.model = FREEmust be stated explicitly — absent Pricing is not free.semanticsMUST be set —TERM_SEMANTICS_UNSPECIFIEDis rejected (the field’senum.not_in:[0]rule).REFERENCE_ONLYrequireslicense.urito be non-empty (license_term.reference_only.requires_uri); aLicensewith aurirequires auri_digest(license.digest_required_with_uri).- At most one
Restrictionperkind(license_term.one_restriction_per_kind); a token cannot be both permitted and prohibited (restriction.permitted_prohibited_disjoint). That rule compares the tokens as written, because it runs over the request as received — so two accepted spellings of one token (an alias beside its registered form, or either in another ASCII case) clear it and collide once folded. The ingest tier asserts the same property over the canonicalised tokens asrestriction.canonical_disjoint; both refuse the term, and a term that fails both is reported by both. Both are scoped to oneRestriction: what makes one restriction the whole of an axis islicense_term.one_restriction_per_kind, so the per-axis reading is held by the two rules together. quotasandobligationseach carry at most 64 items, the bound every per-message list in the contract carries when no rule walks it more than once. They bound what one term can carry, not the cost of checking it.restrictionscarries at most 8, and like the other two this bounds the document, not the cost of checking it. Only one restriction per axis is valid andRestriction.kindis defined-only, so four is the longest conformant list and eight leaves room for an axis this version does not have. The tighter bound is deliberate for a second reason: this is the one list a message rule walks against itself, so the cap is also the threshold of the size test the one-per-kind rule carries, and a conformance guard holds the two equal. The disjointness rule on each element is quadratic only in that element’s two token lists, both capped at 64, so its cost is bounded per restriction and linear across the list.- Unknown tokens in
restrictions[].permitted/prohibitedproducePushResourcesResponse.warnings[]but do NOT cause hard rejection (ingest-time, not CEL). Tokens are canonicalised first — RFC 8259 whitespace trimmed, ASCII case folded, and the aliases authored beside the tokens resolved (train-ai→ai-train,tdm→text-and-data-mining,personal→individual, …) — soGenerative-AIis the registeredai-input, not an unknown token. - A bare (non-namespaced)
pricing.unitorquotas[].metricthat is not a registered token IS a hard rejection at ingest (registry membership, not CEL: a rule that re-listed the vocabulary would drift from it). Avendor:tokenvalue bypasses membership on every axis. - The same checks ship in the SDK as a publisher pre-check (
ValidateLicenseTerm/ValidateResourceEntryand their Python and TypeScript twins); the Exchange’s own run is the deciding one.
| Field | Type | Number | Description |
|---|---|---|---|
license | optional License | 1 | Governing license document. Authoritative for REFERENCE_ONLY terms, which MUST carry a License with a non-empty uri — a REFERENCE_ONLY term that references nothing is rejected at ingest. |
semantics | TermSemantics | 2 | How to interpret the machine fields. |
restrictions | repeated Restriction | 3 | Usage restrictions (function, geography, user-type). Multiple restrictions are AND-combined — the agent must satisfy all of them. At most 8, and this list is the one of the three that does NOT carry the contract's usual 64: only one restriction per axis is valid, the axis enum is defined-only, so four is the longest conformant list and eight leaves room for an axis this version does not have. Like the caps on quotas and obligations, this one bounds the DOCUMENT — how many restrictions one term may carry — and not the work of checking it: a validator walks every element it is handed before any cardinality rule is reported, so an over-cap list is traversed in full on its way to being refused. What makes this list different is that one rule walks it against ITSELF. The one-per-kind rule below is quadratic, so it carries its own size test and stays silent above this cap; a conformance guard holds the two numbers equal, because a cap raised without the test would leave the lists in between unchecked for duplicate axes and accepted. The neighbouring disjointness rule on each element is quadratic only in that element's two token lists, both capped at 64, so its cost is bounded per restriction and linear across the list — it needs no such test. |
quotas | repeated Quota | 4 | Usage caps. The agent must not exceed any individual Quota. At most 64, the bound every per-message list in this contract carries when no rule walks it more than once. It bounds what one term may carry, not the work of checking one — a validator walks every element it is handed before the cap is reported, so the cost of checking is bounded at the transport. |
obligations | repeated Obligation | 5 | Post-use behavioral requirements. At most 64, for the reason quotas carries. |
pricing | optional Pricing | 6 | Pricing for this term. REQUIRED for every term regardless of semantics — an agent cannot act on a priceless term, so absent Pricing is a validation error at ingest. model = FREE must be stated explicitly (absent Pricing is not free). A REFERENCE_ONLY term states its price here too; its License governs the human-readable terms but does not replace the machine-readable price. |
scopes | repeated string | 7 | Delegation scope-gating: the Exchange returns this term to an agent iff the agent's delegation grant covers ALL of these scopes (AND-semantics). Empty = public. A subscription term is Pricing{model:FREE} + scopes:["subscription:..."]. Coverage uses the SAME matching rule as Requester/delegation scopes: segment-wise (":" separated), each granted segment must equal the corresponding required segment or be "", a terminal "" matches all remaining segments, and there is NO implicit prefix match (a grant narrower than the requirement does not cover it). "dist:*" covers "dist:US" and "dist:US:CA"; "dist" covers only "dist". There is exactly one scope-matching algorithm across the protocol. |
part_label | optional string | 8 | Informational human-readable name for this sub-part (sub-part terms). |
License
Section titled “License”Identifies the governing license document for a LicenseTerm.
| Field | Type | Number | Description |
|---|---|---|---|
uri | optional string | 1 | Canonical identity of the license document (RFC 3986). MUST NOT be URL-validated — data-labels TDL identifiers use non-URL schemes. For REFERENCE_ONLY terms this is the authoritative specification. Examples: "https://creativecommons.org/licenses/by/4.0/" "https://techcrunch.com/licensing/ai-terms-2026" "MUST NOT URL-validate" means do not REJECT non-URL schemes — it does NOT mean fetch blindly. A consumer that dereferences this URI MUST apply the SSRF countermeasures in the security threat model (T-LIC-1): scheme allowlist, block loopback/private/metadata addresses (resolve-then-check), fetch via an egress proxy, and treat the response as untrusted content. Verify the fetched bytes against uri_digest before use. |
id | optional string | 2 | Stable short identifier: SPDX short-id ("GPL-3.0-only"), TollBit cuid, or catalog doc-id. Used by agents and the vocab linter for known-license lookup; SHARE_ALIKE derivatives default their scope_license to this. |
name | optional string | 3 | Human-readable name (licenseType, schema.org node name). |
immutable | optional bool | 4 | Data-labels TDL: the document at uri is versioned and will not change. |
uri_digest | optional string | 5 | Cryptographic digest of the document at uri, in "method:hexdigest" form (e.g. "sha256:9f86d081..."). Pins the referenced document so a consumer can verify the bytes it fetches match what was offered; covered by the offer signature, so it is tamper-evident end to end. REQUIRED whenever uri is non-empty — any semantics, mutable or not: without a pinned digest a MitM (or the publisher) can swap the document the agent reads. The Exchange pins it at ingestion (computing it over the safely-fetched document, or accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a non-URL TDL scheme). The method MUST be a collision-resistant hash — sha256, sha384, or sha512. Legacy md5/sha1 are rejected on the wire: a forgeable digest would defeat the swap-protection this field exists for. The CEL is STRUCTURE ONLY (allowlisted prefix + matching hex length); presence (digest-when-uri) is enforced at ingest. |
Restriction
Section titled “Restriction”A single constraint on one licensing dimension: function (what), geography (where), or user-type (who).
Reading a restriction: a value is in-scope when it matches at least one permitted[] token AND none of the prohibited[] tokens. Empty permitted[] = any value permitted on this axis. Restrictions ride on the offer; the agent self-selects the term it can honour (the Exchange does not pre-filter terms against requester attributes).
Vocabulary sources: proto-native — (fora.v1.vocab_enum) on the RESTRICTION_KIND_FUNCTION / RESTRICTION_KIND_GEOGRAPHY / RESTRICTION_KIND_USER_TYPE enum values (no side-car JSON registry).
| Field | Type | Number | Description |
|---|---|---|---|
kind | RestrictionKind | 1 | Which dimension this restriction applies to. Defined-only: the axis set is CLOSED, and a number outside it is refused rather than ignored. A custom axis is RESTRICTION_KIND_OTHER, whose meaning rides in permitted/prohibited, so a new number was never the extension mechanism — accepting one would admit a restriction no consumer can evaluate onto a term whose default is BINDING (see advisory below), which fails open on the axis a publisher most needs enforced. Closing the axis does NOT bound the cost of the one-per-kind rule below, and must not be read as doing so: a number this rule refuses is still distinct from every other, so that rule's all() finds no duplicate to stop on and walks the list in full anyway. Its cost is bounded by the size test the rule itself carries. |
permitted | repeated string | 2 | Tokens allowed on this axis. Empty = all permitted. For FUNCTION: "ai-input", "ai-train", "search", "editorial", "commercial", … For GEOGRAPHY: "US", "DE", "EU", "EEA", "*", … For USER_TYPE: "individual", "academic", "commercial_entity", … |
prohibited | repeated string | 3 | Tokens blocked on this axis. Takes precedence over permitted[]. |
advisory | bool | 4 | Fail-closed by default. When false (the default), this restriction is BINDING: an agent that cannot evaluate every token in it — including an unknown vendor token — MUST decline the term. Set advisory = true to downgrade an unverifiable restriction to non-blocking. This deliberately inverts the COSE-crit opt-in default: a license restriction a consumer does not understand should stop it, not be silently ignored. |
A usage cap that gates whether a LicenseTerm remains valid. Quotas limit consumption before a term expires or must be renegotiated — they are NOT billing quantities.
Metric vocabulary: proto-native — (fora.v1.vocab) on Quota.metric. The registered metrics, rendered from the proto at build time:
display-words impressions tokens input-tokens units-manufactured accesses copies seats
| Field | Type | Number | Description |
|---|---|---|---|
metric | string | 1 | The unit being capped — an open vocabulary axis. The (fora.v1.vocab) entries below are the SOLE authored source of the registered bare metric tokens. A buf plugin reads them structurally and emits the quotametrics constants + IsRegistered; ingest enforces membership from those. The CEL is STRUCTURE ONLY (non-empty bare token or vendor:namespaced) — it never lists the tokens, so it cannot drift. Token meanings: display-words Words of content text rendered to an end user. impressions Times the content is displayed to an end user. tokens LLM output tokens generated using this content. input-tokens LLM input tokens consumed from this content. units-manufactured Physical units manufactured from this design/pattern. accesses Distinct content access / retrieval events. copies Digital or physical copies produced. seats Distinct named users licensed to access the content. |
limit | int64 | 2 | Maximum allowed value in the given window. A quota of 0 grants nothing — express "no access" by omitting the term, not a zero quota. |
window | QuotaWindow | 3 | Time window over which the limit accumulates. |
Obligation
Section titled “Obligation”A post-use behavioral requirement attached to a LicenseTerm. Attribution and contribution are behavioral requirements here, not pricing models.
| Field | Type | Number | Description |
|---|---|---|---|
kind | ObligationKind | 1 | What the agent must do. |
trigger | ObligationTrigger | 2 | When the obligation activates. |
scope_license | optional License | 3 | The license that derivatives must be released under. REQUIRED for SHARE_ALIKE (rejected if absent), where it MUST identify a license — set id (SPDX short-id, the common copyleft case, often the term's own License.id) and/or uri. Because it is a License, a referenced uri inherits the uri_digest swap-protection rule: a uri without a digest is rejected, exactly as for any other license reference. |
detail | optional string | 4 | Free-form detail: attribution string, notice file URI, etc. OBLIGATION_KIND_OTHER without it → lint warning. |
Unified Error Model
Section titled “Unified Error Model”One way to communicate failure across every RPC. The transport carries a coarse canonical code (gRPC / Connect Code) as the error class; an ErrorDetail message — attached to the non-OK transport error’s details (the same mechanism protovalidate uses to attach its Violations) — carries the precise, machine-readable reason and structured context. Clients branch on the typed reason, never on a human string.
Error vs. body. A query that ran successfully returns its answer in the response body, and “no results” / per-item absence (OfferGroup.absence_reason) is a success, not an error. Only a method that could not perform the requested action returns a non-OK code plus an ErrorDetail. The per-domain reason enums below are the single source of truth that replaces the former in-body failure fields (denial_reason, rejection_reason, accepted, verified, failure_reason).
ErrorDetail
Section titled “ErrorDetail”The structured detail attached to every non-OK transport error.
| Field | Type | Number | Description |
|---|---|---|---|
message | string | 1 | Developer-facing, NON-authoritative human message. Clients MUST branch on the typed reason below, never on this text. Servers SHOULD NOT place secrets, PII, or existence/authorization detail here that the closed typed reason deliberately withholds: unlike the enum, this free text is unbounded and easily becomes an existence oracle or leak channel (see metadata). |
domain | string | 2 | Stable grouping for the failing surface, e.g. "fora.v1.ExchangeService". Mirrors google.rpc.ErrorInfo.domain so generic tooling can group errors. |
metadata | map<string, string> | 3 | Dynamic key/value context that also appears in message (ids, limits, axes). Mirrors google.rpc.ErrorInfo.metadata. Strongly-typed context rides in the per-domain reason block below instead. Same leakage rule as message: servers SHOULD NOT put secrets, PII, or withheld existence/authorization detail here — it is the same potential side channel as the absence oracle. |
transaction_denial | TransactionDenial | 10 | reason oneof — ExecuteTransaction denial |
catalog_rejection | CatalogRejection | 11 | reason oneof — CatalogService rejection |
registration_failure | RegistrationFailure | 12 | reason oneof — agent/provider registration refused |
dispute_failure | DisputeFailure | 13 | reason oneof — DisputeTransaction filing refused |
domain_verification_failure | DomainVerificationFailure | 14 | reason oneof — domain verification failed |
retrieval_auth_failure | RetrievalAuthFailure | 15 | reason oneof — signed-URL / proof-of-possession check failed |
usage_report_rejection | UsageReportRejection | 16 | reason oneof — ReportUsage filing rejected |
Exactly one typed reason block is set, selected by the failing method. The reason oneof is absent for generic transport-class failures (e.g. INVALID_ARGUMENT, INTERNAL) that carry no domain-specific reason.
TransactionDenial
Section titled “TransactionDenial”ExecuteTransaction could not complete. Reuses the DenialReason vocabulary.
| Field | Type | Number | Description |
|---|---|---|---|
reason | DenialReason | 1 | The denial reason (defined-only, non-zero) |
restriction_mismatches | repeated RestrictionKind | 2 | When reason = RESTRICTION_NOT_SATISFIED, the failed axes (same RestrictionKind vocabulary the terms use). |
offer_id | optional string | 3 | Batch mode: the offer this denial pertains to. |
exchange | optional string | 4 | Bare host of the Exchange that PRODUCED this denial, in the form "Request recipient" defines in the file header. Not an echo of what the caller sent: on a relayed or fanned-out execute the request went to a Broker, so the Exchange that refused may not be one the agent named. Carrying it here is what lets ACCOUNT_NOT_REGISTERED be actionable — the agent learns where to call Register without fetching a manifest to work it out. NOTHING SIGNS THIS VALUE: it rides in a response, and on a relayed path the response passed through an intermediary, so this field is exactly the unsigned addressing the request-side exchange field exists to refuse. Treat it as a HINT, not an instruction. Before acting on it — and registering is a consequential act, handing an operator's business data and a signed acceptance of that Exchange's terms to whoever answers — a caller MUST check the value against a domain it already trusts for this transaction: the signed offer.exchange of the denied item, or its own RequestConstraints.exchanges set. A value matching neither is reported to the caller and never dialled, because a hostile intermediary that could choose it would be choosing where an unattended agent registers. |
CatalogRejection
Section titled “CatalogRejection”A CatalogService call could not be applied.
| Field | Type | Number | Description |
|---|---|---|---|
reason | CatalogRejectionReason | 1 | The rejection reason (defined-only, non-zero) |
rejected_paths | repeated string | 2 | The entry paths the refusal is about. A catalog push is all-or-nothing, so these name which entries failed inside a submission that persisted nothing — they are not a list of what was dropped from an otherwise applied batch. |
RegistrationFailure
Section titled “RegistrationFailure”A registration request could not be completed.
| Field | Type | Number | Description |
|---|---|---|---|
reason | RegistrationFailureReason | 1 | The failure reason (defined-only, non-zero) |
field_errors | repeated RegistrationFieldError | 2 | When reason = INVALID_REGISTRATION_DATA: the registration_data members that are missing or do not conform. Empty for every other reason — enforced by the message rule above, not left to prose. |
RegistrationFieldError
Section titled “RegistrationFieldError”One registration_data member that failed the Exchange’s published AccountRegistration.data_schema, carried on RegistrationFailure.field_errors.
| Field | Type | Number | Description |
|---|---|---|---|
path | string | 1 | RFC 6901 JSON Pointer to the offending member, relative to registration_data (e.g. "/vat_id", "/address/postal_code"). The empty string addresses registration_data itself, for whole-object failures (oneOf, minProperties) that belong to no single member. |
error | string | 2 | Developer-facing, NON-authoritative description of what failed (e.g. "required", "must match ^[A-Z]{2}[0-9]+$"). Wording is validator-defined and not stable across Exchanges; clients branch on reason, never on this text. States the constraint, NEVER the submitted value — the ErrorDetail leakage rule applies here too. |
DisputeFailure
Section titled “DisputeFailure”A dispute could not be filed (distinct from DisputeReason, why the agent disputes, and DisputeStatus, an accepted dispute’s lifecycle).
| Field | Type | Number | Description |
|---|---|---|---|
reason | DisputeFailureReason | 1 | The failure reason (defined-only, non-zero) |
DomainVerificationFailure
Section titled “DomainVerificationFailure”RequestDomainVerification / ConfirmDomainVerification failed.
| Field | Type | Number | Description |
|---|---|---|---|
reason | DomainVerificationFailureReason | 1 | The failure reason (defined-only, non-zero) |
RetrievalAuthFailure
Section titled “RetrievalAuthFailure”A signed-URL retrieval or its proof-of-possession check failed at the delivery edge.
| Field | Type | Number | Description |
|---|---|---|---|
reason | RetrievalAuthFailureReason | 1 | The failure reason (defined-only, non-zero) |
UsageReportRejection
Section titled “UsageReportRejection”A usage report could not be accepted.
| Field | Type | Number | Description |
|---|---|---|---|
reason | UsageReportRejectionReason | 1 | The rejection reason (defined-only, non-zero) |
CatalogRejectionReason
Section titled “CatalogRejectionReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | NOT_CATALOG_CONTRIBUTOR | caller is not an authorized contributor for the domain |
| 2 | TENANT_MISMATCH | tenant_id does not match the authenticated caller |
| 3 | DOMAIN_NOT_VERIFIED | contributing domain is not verified |
| 4 | SIGNATURE_INVALID | request signature missing or invalid |
| 5 | MALFORMED_ENTRY | a resource entry failed schema/validation |
| 6 | UNKNOWN_VOCAB_TOKEN | an unregistered vocab token in a restriction/term |
| 7 | QUOTA_EXCEEDED | contributor push quota exceeded (per-caller) |
| 8 | TERMS_LIMIT_EXCEEDED | A single entry carries more license terms than ResourceEntry.terms allows. Retired on the PushResources path: the cap is a wire rule now, so a push carrying an over-cap entry is refused whole, before any per-entry classification runs, and no rejection naming this reason can be produced for it. Kept for a deployment that applies the cap somewhere the wire rules do not reach — an entry that arrived by some other route than PushResources. |
| 9 | URI_UNAVAILABLE | The URI cannot be claimed by this caller's entries. Named from the caller's own perspective ON PURPOSE: it MUST NOT disclose that another resource/ contributor already owns the URI. Within one publisher, mutually-untrusting contributors share a catalog, so an "owned by another" reason would be a confirmed-existence oracle a contributor could use to map a competitor's catalog. The conflict is resolvable only by the publisher (who is authorized to see full ownership); the human-readable message routes the caller there without confirming who, if anyone, holds the URI. |
RegistrationFailureReason
Section titled “RegistrationFailureReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | DOMAIN_NOT_VERIFIED | caller domain is not verified |
| 2 | INVALID_KEY | signing key malformed or unsupported |
| 3 | SIGNATURE_INVALID | request signature invalid |
| 4 | ALREADY_REGISTERED | LEGACY, never emitted. Register MUST NOT emit this reason. Registering again for an agent that already holds an account SUCCEEDS: it is answered from the stored record and returns the existing billing_ref, so there is no refusal to report. See "Repeat registration" in the Agent Account Registration section. The number is retained and MUST NOT be reused, and this value MUST NOT be given a new meaning. It reads like a natural home for a future cross-account identity collision — one agent key claiming a business identity another account already holds — and that is exactly why it is closed off here: this contract defines no way for an Exchange to correlate business identity across accounts, since registration_data is operator-defined and passed through uninspected unless a schema is published. Repurposing 4 later would silently change what it means for every client already built against this text. If that case is ever specified, it needs its own identity model and its own named reason. |
| 5 | QUOTA_EXCEEDED | registration quota exceeded |
| 6 | INVALID_REGISTRATION_DATA | registration_data does not conform to the Exchange's published AccountRegistration.data_schema |
| 7 | TERMS_DIGEST_STALE | terms_digest does not match the currently published WellKnownManifest.terms_digest, or was omitted while the Exchange publishes one |
DisputeFailureReason
Section titled “DisputeFailureReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | TRANSACTION_NOT_FOUND | transaction_id is unknown |
| 2 | REPORT_NOT_FILED | no UsageReport precedes the dispute (report_id missing/unknown) |
| 3 | WINDOW_EXPIRED | filed outside the allowed dispute window |
| 4 | DUPLICATE | a dispute already exists for this transaction |
| 5 | INELIGIBLE | the transaction/state is not disputable |
DomainVerificationFailureReason
Section titled “DomainVerificationFailureReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | CHALLENGE_NOT_FOUND | token not served at the well-known path |
| 2 | CHALLENGE_MISMATCH | served token does not match the issued token |
| 3 | CHALLENGE_EXPIRED | confirmation arrived after the challenge expired |
| 4 | FETCH_FAILED | Exchange could not fetch the verification URL |
| 5 | EXCHANGE_NOT_AUTHORIZED | fora.json does not list this Exchange |
| 6 | KEY_REGISTRATION_FAILED | signing-key registration failed during confirmation |
RetrievalAuthFailureReason
Section titled “RetrievalAuthFailureReason”Single-sources the delivery-edge token unions (signed-URL checks in verify.ts, RFC 9421 proof-of-possession in pop.ts).
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | URL_EXPIRED | Signed-URL checks (verify.ts). |
| 2 | URL_SIGNATURE_MISSING | 'missing_sig' |
| 3 | URL_EXPIRY_MISSING | 'missing_exp' |
| 4 | URL_SIGNATURE_MISMATCH | 'signature_mismatch' |
| 5 | AGENT_KEY_MISSING | Proof-of-possession checks (pop.ts). |
| 6 | PROOF_SIGNATURE_MISSING | 'missing_sig' |
| 7 | KEYID_MISMATCH | 'keyid_mismatch' |
| 8 | THUMBPRINT_MISMATCH | 'thumbprint_mismatch' |
| 9 | PROOF_CREATED_MISSING | 'pop_missing_created' |
| 10 | PROOF_EXPIRY_MISSING | 'pop_missing_exp' |
| 11 | PROOF_EXPIRED | 'pop_expired' |
| 12 | PROOF_SIGNATURE_INVALID | 'pop_sig_invalid' |
UsageReportRejectionReason
Section titled “UsageReportRejectionReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | TRANSACTION_NOT_FOUND | transaction_id is unknown |
| 2 | DUPLICATE | a report was already filed for this transaction |
| 3 | WINDOW_EXPIRED | filed outside the reporting window |
| 4 | MISSING_REQUIRED_FIELDS | ReportingObligation.required_fields not satisfied |
| 5 | MALFORMED | report payload failed validation |
PricingModel
Section titled “PricingModel”The charging structure only (a closed set). The open-ended metering basis (“per what”) is NOT enumerated here — it lives in Pricing.unit as a registry-governed vocabulary. UNSPECIFIED is rejected on Pricing.model (omission cannot default to FREE).
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — zero allowed on WellKnownManifest.pricing_models_supported (capability list); rejected (not_in:[0]) as the Pricing.model discriminator (omission cannot default to FREE) |
| 1 | FREE | no charge; rate must be 0 (state FREE explicitly — absent Pricing is not free) |
| 2 | PER_UNIT | rate per Pricing.unit; unit REQUIRED (registered token or vendor:custom) |
| 3 | FLAT | one-time flat fee; rate is the total, no unit |
The metering basis (“per what”) is the Pricing.unit vocabulary, not a model; a subscription is FREE + scopes; attribution and contribution are Obligation.kinds; revenue-share settlement is off-protocol.
Registered Pricing.unit tokens (the metering basis — rendered from the proto, any vendor:namespaced token also accepted):
fetches accesses tokens calls pages seconds minutes records streams images seats units-manufactured characters bytes items sq-km
PricingMetering
Section titled “PricingMetering”How usage is tracked for billing reconciliation. Used in Pricing.metering (field 9). Absent = ONLINE.
| Value | Name | Description |
|---|---|---|
| 0 | ONLINE | Default. Exchange tracks usage events in real time. ReportUsage is required. |
| 1 | NONE | One-time perpetual sale. No ongoing metering; billing_id is issued at ExecuteTransaction and the ledger entry is closed. No ReportUsage required. |
| 2 | OFFLINE_SELF_REPORTED | Agent self-reports physical-world consumption (e.g. units manufactured from a licensed design). Exchange audits. |
TermSemantics
Section titled “TermSemantics”How the Exchange interprets a LicenseTerm’s machine fields.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | ENUMERATED | Machine restrictions/quotas/obligations are the complete, authoritative expression of the term (internally consistent, no self-contradiction) and are enforced. Pricing MUST be present. |
| 2 | REFERENCE_ONLY | The document at License.uri (MUST be non-empty) is the authoritative, complete source; the agent reads it before using. Machine restrictions/quotas/obligations are optional here (the publisher MAY send Pricing alone) but any that are sent must be accurate (MUST NOT contradict the referenced document) and are enforced just like ENUMERATED. Pricing is still required. |
RestrictionKind
Section titled “RestrictionKind”Which dimension a Restriction constrains.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — zero allowed on AcceptableRestriction.axis / OfferGroup.restriction_filters / *.restriction_mismatches; rejected (not_in:[0]) as the Restriction.kind discriminator |
| 1 | FUNCTION | What the agent may do with the content. Seeded from RSL 1.0 AI-use vocabulary and extended with established IP/copyright terms. |
| 2 | GEOGRAPHY | Where the agent may use the content. ISO 3166-1 alpha-2 codes (US, DE, GB) are valid structurally; only the non-ISO specials are registered here. |
| 3 | USER_TYPE | Who may access and use the content. |
| 4 | OTHER | Custom axis; values carried in permitted/prohibited |
Registered tokens
Section titled “Registered tokens”The complete token list for each axis, rendered directly from the proto’s
(fora.v1.vocab_enum) options at build time — not hand-maintained here, so it
cannot drift from the contract. Any vendor:namespaced token is also accepted
on every axis; these are the registered bare tokens.
FUNCTION — what the agent may do:
all ai-all ai-train ai-input ai-index search crawl text-and-data-mining tts commercial advertising editorial research reproduce distribute modify display sync broadcast stream print manufacture sell
Accepted aliases, canonicalised at ingest: train-ai → ai-train, generative-ai → ai-input, scrape → crawl, tdm → text-and-data-mining, copy → reproduce, adapt → modify, derivative → modify
GEOGRAPHY — registered specials (plus structural ISO 3166-1 alpha-2 codes):
* EU EEA
USER_TYPE — what kind of entity the agent represents:
individual academic non_profit news_publisher broadcaster commercial_entity
Accepted aliases, canonicalised at ingest: personal → individual, business → commercial_entity, enterprise → commercial_entity
QuotaWindow
Section titled “QuotaWindow”Time window over which a Quota.limit accumulates.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | HOURLY | Resets each hour |
| 2 | DAILY | Resets each day |
| 3 | MONTHLY | Resets each month |
| 4 | TOTAL | Lifetime cap — never resets |
ObligationKind
Section titled “ObligationKind”What the agent must do after use. ATTRIBUTION and CONTRIBUTION are behavioral requirements, not pricing models.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | ATTRIBUTION | Credit the author or publisher whenever the resource is used |
| 2 | CONTRIBUTION | Good-faith payment — amount suggested, not contractually fixed |
| 3 | SHARE_ALIKE | Derivatives must be released under the same / compatible license (CC-BY-SA / GPL style); scope_license required |
| 4 | NETWORK_COPYLEFT | Network service triggers copyleft (AGPL style) |
| 5 | NOTICE | Include the specified copyright notice |
| 6 | OTHER | Custom requirement, described in Obligation.detail |
ObligationTrigger
Section titled “ObligationTrigger”When an Obligation activates.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | ON_USE | Triggered on any use |
| 2 | ON_DISTRIBUTION | Triggered when copies are distributed |
| 3 | ON_NETWORK_SERVICE | Triggered when served over a network (AGPL) |
| 4 | ON_DERIVATIVE | Triggered when a derivative work is produced |
DenialReason
Section titled “DenialReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | output enum; zero = not-applicable on TransactionResultItem.denial_reason, rejected (not_in:[0]) where set on TransactionDenial.reason |
| 1 | ACCOUNT_INACTIVE | the requester's account (the billing_ref minted at Register) exists but is not active — typically awaiting the Exchange operator's out-of-band activation; the remedy is to wait or contact the operator, NOT to register again |
| 2 | INSUFFICIENT_BALANCE | Requester's balance too low |
| 3 | RATE_LIMITED | Too many requests |
| 4 | CONTENT_UNAVAILABLE | Resource no longer available |
| 5 | RESTRICTION_NOT_SATISFIED | Accepted term's restriction not satisfied by the request; the axes are in TransactionDenial.restriction_mismatches (single) / TransactionResultItem.restriction_mismatches (batch), same RestrictionKind vocabulary as the terms |
| 6 | REPORTING_OVERDUE | Requester has >20% overdue reports (MAY threshold) |
| 7 | OFFER_EXPIRED | Offer TTL exceeded |
| 8 | SIGNATURE_INVALID | Offer signature verification failed |
| 9 | QUOTA_EXCEEDED | Subscription access count exhausted for this period |
| 10 | DELEGATION_INVALID | Delegation missing, unverifiable, expired, holder binding failed, or scopes/caps do not cover the request |
| 11 | SCOPE_INSUFFICIENT | Requester scopes don't cover this resource |
| 12 | ENTITLEMENT_MISSING | Entitlement family — subscription/entitlement access failures on a subscription-gated offer. Finer-grained than DELEGATION_INVALID so callers and operator tooling can triage each mode. These single-source the Exchange's KindEntitlement* refusal taxonomy, which today is distinguishable only by a server-side tag (all collapse to UNAUTHENTICATED on the wire). Format-neutral: they classify a JWT/opaque entitlement-token failure, not a format-specific one. |
| 13 | ENTITLEMENT_MALFORMED | entitlement token failed to decode (malformed) |
| 14 | ENTITLEMENT_EXPIRED | entitlement token's validity window has passed |
| 15 | ENTITLEMENT_WRONG_BUYER | token's subscriber_org does not match the asserted requester |
| 16 | SUBSCRIPTION_LAPSED | the covering subscription contract has lapsed |
| 17 | ENTITLEMENT_NOT_GRANTED | subscription exists but no buyer-side grant ties this caller to it |
| 18 | ACCOUNT_NOT_REGISTERED | Split out of the former single billing-reference reason. An agent hits the wall at execute, not at register, and "no account here" and "account awaiting activation" are two different states of the caller with two different remedies — call Register, which an agent can do unattended, versus wait for a human. Neither value discloses the Exchange's internal billing state; both describe the caller's own account, which it may already query with GetAccountStatus. TransactionDenial.exchange names WHERE to register, so the agent converges without fetching a manifest first. |
DisputeReason
Section titled “DisputeReason”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | CONTENT_MISMATCH | Content hash does not match what was promised in the Offer. |
| 2 | DELIVERY_FAILED | Resource was not delivered (signed URL returned 404/403/5xx). |
| 3 | WRONG_CONTENT | Resource was delivered but is entirely different from what was described. |
| 4 | EXPIRED_BEFORE_FETCH | Signed URL expired before the agent could fetch the resource. |
| 5 | INCOMPLETE_CONTENT | Resource was truncated or incomplete. |
DisputeStatus
Section titled “DisputeStatus”Full dispute lifecycle from filing to final resolution.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — output/optional; zero is a valid not-applicable/unset state |
| 1 | FILED | Agent submitted DisputeRequest. Initial state. |
| 2 | AUTO_RESOLVED | Exchange auto-resolved via Tier 1 rules (CDN logs, hash comparison). |
| 3 | EVIDENCE_NEEDED | Exchange requests additional evidence from the agent or provider. |
| 4 | UNDER_REVIEW | Exchange is reviewing with Tier 2 resolution rules. |
| 5 | ESCALATED | Escalated to Tier 3 pattern-based investigation. |
| 6 | RESOLVED | Decision made (credit, redelivery, rejected). See resolution field. |
| 7 | APPEALED | Losing party appealed with new evidence. Re-enters review. |
| 8 | SETTLED | Financial settlement applied. |
| 9 | FINAL | No further appeals. Dispute closed. |
ResolutionType
Section titled “ResolutionType”Outcome of a resolved dispute.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — output/optional; zero is a valid not-applicable/unset state |
| 1 | CREDIT | Account credit applied to the agent's next billing cycle. |
| 2 | REDELIVERY | New signed URL issued for the same resource (e.g., when content hash now matches after provider correction). |
| 3 | REJECTED | Dispute reviewed and rejected; no remedy applied. |
| 4 | INVESTIGATION | Escalated to Tier 3 pattern analysis for further investigation. |
CitationFormat
Section titled “CitationFormat”How the citation is presented to the user.
| Value | Name | Description |
|---|---|---|
| 0 | LINK | Hyperlink citation |
| 1 | FOOTNOTE | Footnote citation |
| 2 | INLINE | Inline text citation |
OfferAbsenceReason
Section titled “OfferAbsenceReason”Why no offers are available for a requested URI.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — output/optional; zero is a valid not-applicable/unset state |
| 1 | NOT_IN_CATALOG | Resource URI is not in this Exchange's catalog. |
| 2 | CONTENT_BLOCKED | Resource exists but the provider has opted out of AI access for it (the provider's consent/opt-out signal blocks licensing). |
| 3 | RESTRICTION_FILTERED | Resource exists but its offers were pre-filtered out for one or more restriction axes the requester stated (a convenience filter matched to the query, not an enforcement verdict — see Restriction). The filtered axes are listed in OfferGroup.restriction_filters, in the same RestrictionKind vocabulary the terms use. The agent MAY still be eligible. |
| 4 | TEMPORARILY_UNAVAILABLE | Resource is temporarily unavailable (e.g., provider feed refresh in progress). |
| 5 | NOT_AUTHORIZED | Exchange is not authorized by the provider to sell this resource. |
| 6 | SCOPE_INSUFFICIENT | Requester's scopes/subscription do not cover this resource. Applies wherever access is gated by subscription or scope entitlements (not only enterprise deployments): the resource exists but the requester's delegation token or subscription does not grant it. The Exchange returns this so the requester learns the resource is reachable under the right subscription/scope. (Where existence itself must stay hidden, the Exchange MAY omit it silently instead.) |
| 7 | UNKNOWN_CRITICAL_EXTENSION | Consumer encountered ext_critical keys it does not recognize. The unrecognized keys SHOULD be listed in the OfferGroup's ext field under "unrecognized_critical_extensions" for diagnostic purposes. |
| 8 | BUDGET_EXCEEDED | Offers exist, but none fit within the requester's budget (e.g. every offer's price exceeds RequestConstraints.period_budget). Returned by Resolve as a successful "no result" answer when a budget/price ceiling filtered out every otherwise-licensable offer. |
RequesterType
Section titled “RequesterType”What kind of entity is making the request.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | AGENT | Autonomous AI agent (LLM, RAG system, research bot). |
| 2 | HUMAN_TOOL | Human using an AI-powered tool (copilot, assistant). |
| 3 | SERVICE | Enterprise service account (automated pipeline, cron job). |
| 4 | DELEGATED | Agent acting on behalf of a user (delegated identity). |
| 5 | RESEARCH | Research pipeline (batch data collection, model training). |
Identifies which FORA participant a WellKnownManifest describes.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | AGENT | |
| 2 | EXCHANGE | |
| 3 | BROKER | |
| 4 | PUBLISHER |
DiscoveryMethod
Section titled “DiscoveryMethod”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — output/optional; zero is a valid not-applicable/unset state |
| 1 | EXCHANGE | URI was requested by the agent directly or found via Exchange query. |
| 2 | SEARCH | URI was discovered via a search engine (e.g., Exa, Tavily, Brave Search). The Broker searched on the agent's behalf, then routed through Exchange. |
| 3 | RECOMMENDATION | URI was recommended by a resource recommendation service. |
| 4 | SYNDICATION | URI was found via resource syndication tracking (e.g., same article on another domain). |
DeliveryMethod
Section titled “DeliveryMethod”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — output/optional and capability-list; zero is a valid not-applicable/unset state |
| 1 | DIRECT | Exchange returns resource inline or via its own endpoint. |
| 2 | INSTRUCTIONS | Exchange returns access info (signed URL, token) for retrieval from a Resource Owner / Resource Delivery Endpoint. |
| 3 | STREAMING | Resource delivered via real-time streaming connection (WebSocket, SSE, gRPC stream). The signed URL points to a streaming endpoint. Agent connects and receives continuous data for the duration of the session. |
ResourceMutability
Section titled “ResourceMutability”Signals whether resource content changes over time. Drives hash verification behavior.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — the Exchange defaults to STATIC at Offer build; an explicit UNSPECIFIED is rejected on ResourceEntry.resource_mutability and the Offer's ResourceIdentity.resource_mutability (both {not_in:[0]}) |
| 1 | STATIC | Content is immutable. Hash computed at offer time will match at delivery time. Agent SHOULD verify content_hash on delivery. Mismatch is disputable. |
| 2 | DYNAMIC | Content changes between offer generation and agent fetch. Hash reflects state at offer time — mismatch is expected, not disputable. Offer.data_as_of indicates when the snapshot was taken. |
| 3 | LIVE | Content does not exist at offer time (real-time streaming). No content_hash is applicable. The "resource" is the stream endpoint/channel. Metering is time-based (per-minute, per-hour). |
IngestionSource
Section titled “IngestionSource”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — output/optional; zero is a valid not-applicable/unset state |
| 1 | FORA_SITEMAP | FORA XML namespace in sitemap |
| 2 | RSL | RSL rsl.txt |
| 3 | SITEMAP | Standard sitemap.xml |
| 4 | HTML_CRAWL | HTML crawl + readability extraction |
| 5 | CMS_API | CMS REST API (WordPress, etc.) |
| 6 | MANUAL | Manual configuration |
| 7 | CATALOG_API | Third-party CatalogService push |
ProviderRelationship
Section titled “ProviderRelationship”| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — rejected at ingest |
| 1 | DIRECT | Provider has a direct contract with this Exchange. |
| 2 | RESELLER | Exchange resells resources via another authorized party. |
AuthMethod
Section titled “AuthMethod”Authentication methods a participant advertises in its WellKnownManifest.
| Value | Name | Description |
|---|---|---|
| 0 | UNSPECIFIED | unset — zero allowed on WellKnownManifest.supported_auth_methods (capability list); no discriminator carrier |
| 1 | GNAP | GNAP (RFC 9635) — key-first identity, key-bound tokens. Recommended. |
| 2 | OAUTH_DPOP | OAuth 2.0 + DPoP (RFC 9449) — sender-constrained tokens. Enterprise recommended. |
| 3 | OAUTH_BEARER | OAuth 2.0 Bearer JWT — acceptable, widely deployed. |
| 4 | OAUTH_MTLS | OAuth 2.0 + mTLS — high-security environments. |
C2PAStatus
Section titled “C2PAStatus”C2PA / content-provenance status carried on ResourceIdentity.
| Value | Name | Description |
|---|---|---|
| 0 | C2PA_STATUS_UNSPECIFIED | unset — output/optional; zero is a valid not-applicable/unset state |
| 1 | C2PA_STATUS_TRUSTED | Manifest is valid AND signer certificate chains to a C2PA Trust List root. Highest assurance: provenance is cryptographically verified by a trusted CA. |
| 2 | C2PA_STATUS_VALID | Manifest is structurally correct and signature verifies, but signer certificate does NOT chain to a C2PA Trust List root. The content has provenance, but the signer is not institutionally vouched for. |
| 3 | C2PA_STATUS_INVALID | Manifest is present but validation failed (signature mismatch, malformed JUMBF, certificate expired, hard binding broken). |
| 4 | C2PA_STATUS_ABSENT | Content was checked and has no C2PA manifest. |