Skip to content

Changelog

This page contains curated public release notes. For the exhaustive maintainer and protocol history, see proto/CHANGELOG.md.

The publisher docs describe the console’s onboarding and the file on the publisher’s origin (docs and comments only; no wire change). The public onboarding page and the publisher guide walk the console’s four steps: Your business, which is required, then Your domain, Your terms and Connect your site, which can be finished later and which every further domain takes again. Prices read “per fetch”, and those two pages call the artifact the discovery file. The edge pages say a provider’s fora.json and rsl.txt are files on its own origin: the edge forwards exactly those two paths for every client, ahead of all its checks, and keeps no copy, so the serving modes, the caching strategy and the settings that fed an edge-built document are gone. A domain is proven by the fora.json it serves. RequestDomainVerification and ConfirmDomainVerification stay in the contract, and their comments no longer name a fora-cli tool or an edge KV store. The Exchange pages lose the /provider/{domain}/fora.json and /provider/{domain}/rsl.txt endpoints, whose only reader was such an edge. The documentation conformance gate refuses the retired phrasings, and a website test holds both pages to the console’s step names.

Protocol

  • Delegation profile. The holder-bound JWT delegation chain is now a fixed profile on the authentication page, and the Exchange verifies it as written. The chain is one compact JWS: each child carries its complete signed parent in the fora_parent claim and its own Ed25519 public key in the protected jwk header, at most four tokens deep. Every token requires the protected header typ fora-delegation+jwt and alg EdDSA or Ed25519 with an Ed25519 key, and the claims iss, aud, cnf.jkt and a finite exp; nbf is honoured, jti is recommended, and a claim the profile does not list is refused, with vendor metadata inside the signed fora_advisory object only. aud follows the Request recipient rule. The root’s iss is the identity configured with the trusted key, on the subscription record that owns the gated term; a child’s iss is the thumbprint of its own signing key. No key is discovered through OIDC, JWKS or the token. The clock leeway is 60 seconds and never extends the effective expiry, the chain’s earliest exp, which bounds a gated offer’s expiry. Delegation.issuer and Delegation.principal_domain mirror the root’s iss and establish nothing. For a term gated behind a verified delegation the Exchange reads scopes from the chain only and ignores Requester.scopes. Revocation lookup through Delegation.revocation_uri is not part of the profile: withdrawing a configured key is how a chain is invalidated early. The tokens the documentation shows are real and verified by a conformance test.

Wire

  • Delegation token size. Delegation.token is at most 32768 bytes, declared as bytes.max_len on the field. The limit holds for the complete chain, every embedded parent included, and is checked at wire validation before the token is parsed, so a longer token is an invalid request rather than a per-item denial, whether or not the bytes are a valid JWT. The generated Pydantic and Zod models, the published JSON Schemas and the precompiled strict validators carry the same limit as an exact base64 pattern, because a plain maxLength on the base64 string would admit a token one byte over the limit. The validation corpus carries a 32768-byte token that is accepted and a 32769-byte token that is refused.

SDK users

  • ScopesSubset is grant containment. helpers.ScopesSubset, scopes_subset and scopesSubset report whether every scope in the first list is covered by some scope in the second under the protocol’s segment-wise rule, not whether it is an exact member. A child publisher:news:read under a parent publisher:*:read now passes, as the proto always said it should, and a child publisher:news:* under that parent still fails. A caller that relied on exact membership sees wildcard grants accepted. The subset section of scopes-vectors.json carries the new cases.
  • Go client errors. connect.CallError and resolvers.FetchError gain a Header field, an http.Header, so neither struct compares with == any more. An RPC CallError now carries the Status of the peer’s error answer, which v1.1.0 left at 0, so its message names that status, as the content leg’s always did.

Scope matching and delegation verification in the SDKs.

  • Scope matching helpers. helpers.ScopeCovers and helpers.ScopesCover (Go), scope_covers and scopes_cover (Python), scopeCovers and scopesCover (TypeScript) implement the protocol’s segment-wise scope rule: each granted segment equals the required one or is *, a terminal * covers the remaining segments and needs at least one, no implicit prefix match, a grant with more segments than the requirement never covers it, and a * in the requirement is a literal segment. All three are pinned to sdk/go/helpers/testdata/scope-match-vectors.json, whose verdicts are written from the rule, not emitted by an implementation.
  • Delegation chain verifier (Go). helpers.VerifyDelegation verifies a chain against the profile: the size and depth limits, the protected header, the chain walk through fora_parent, the root under caller-configured TrustedIssuer keys, each child under its jwk and the intermediate issuer rule, the claims table, the clock leeway, attenuation of scopes, audiences and expiry, the audience and the holder binding. It takes a DelegationInput and returns a VerifiedDelegation (root issuer, holder thumbprint, effective scopes, effective expiry, depth) or a DelegationError naming one DelegationFailure, which unwraps to ErrDelegationInvalid. It verifies the Ed25519 signature with the standard library under either alg name, because the pinned go-jose accepts only EdDSA. It meters nothing, so a chain carrying fora_max_spend_cents, fora_max_accesses or fora_quota_period is refused. The constants MaxDelegationTokenBytes, MaxDelegationChainDepth, DelegationClockLeeway, DelegationTokenType and MaxDelegationNumericDate are the profile’s values; the last bounds exp and nbf to 9999-12-31T23:59:59Z so the returned expiry always fits a Timestamp. The verifier accepts only unpadded base64url segments and exactly one JSON object as payload, and refuses a b64 header parameter. The TypeScript and Python verifiers are deferred and recorded as a decision in the parity matrix.
  • Shared delegation chain cases. sdk/go/helpers/testdata/delegation-chains.json carries signed chains from fixed test keys and a fixed clock with outcomes declared from the profile, one case per rule, plus the chains the documentation embeds. The Go tests re-verify every case; an Exchange’s tests and the deferred ports replay the same file.

Three more SDK capabilities for conformance and e2e harnesses (no wire change). A harness can now read the header fields a refusal arrived with, sign an acceptance that names an empty requester to prove that a receiver refuses it, and, in Python, have the client refuse a request the contract does not describe before it is signed.

  • Refusal headers. A refusal carries the header fields of the answer that refused: CallError.headers (Python, a mapping looked up case-insensitively), ForaCallError.headers (TypeScript, a Fetch Headers) and CallError.Header with resolvers.FetchError.Header (Go, an http.Header), on the RPC legs and the delivery fetch alike. A Web Bot Auth verifier’s Accept-Signature, a gateway’s Retry-After and an edge’s discovery headers are read through the SDK; parse_discovery_hint, parseDiscoveryHint and helpers.ParseDiscoveryHint read the last with the error’s status. Only an error answer, status 400 or above, carries fields: a local failure, a transport failure, a 2xx the client could not read and a refused redirect carry none, the redirect because its Location names a URL the peer chose. A strictly refused error envelope keeps them, as it keeps its code. The fields are a copy and are never rendered into the message. A field sent on several lines reads as one value joined with ”, ” in Python and TypeScript; Go keeps the lines, and strings.Join(Header.Values(name), ", ") reads the same value. Go’s RPC CallError.Status is now set beside them. TypeScript’s UnaryResponse gains an optional headers, which the default send fills; an injected send that leaves it out yields refusals carrying none. The new refusal-header-vectors.json pins which failures carry fields and what they read as, in all three SDKs.
  • Test-only acceptance signers. A receiver must refuse an acceptance that names an empty requester even when its signature verifies, and the SDK’s own signers refuse to produce one, so the test proving the refusal needs a signer that does. A test-support package in each language holds twins of the four acceptance functions that skip only that refusal: helperstest.CanonicalAcceptanceBytesAnyRequester, SignOfferAcceptanceAnyRequester, CanonicalRequestAcceptanceBytesAnyRequester and SignRequestAcceptanceAnyRequester (Go, package sdk/go/helperstest); jcs_acceptance_payload_any_requester, sign_offer_acceptance_any_requester, jcs_request_acceptance_payload_any_requester and sign_request_acceptance_any_requester (Python, fora_sdk.testing, not re-exported from fora_sdk); acceptancePayloadAnyRequester, signOfferAcceptanceAnyRequester, requestAcceptancePayloadAnyRequester and signRequestAcceptanceAnyRequester (TypeScript, export path ./testing). Every other refusal is kept, in the same text and order, and the protocol’s signers and verifiers are unchanged and still refuse. Each language renders both through one canonical renderer that takes the requester check as a parameter: sdk/go/internal/canonicalsign in Go, private functions in Python’s fora_sdk.core, and src/acceptance-render.ts in TypeScript, which neither package manifest exports. The twins reproduce the refused rows of acceptance-vectors.json and request-acceptance-vectors.json byte for byte in all three SDKs, and a test in each fails if production code imports the test package.
  • Strict requests (Python). ClientConfig(strict_requests=True) refuses a request the contract does not describe, before it is signed. The generated models ignore a member they do not declare, so a misspelled key in a plain dict passes the default check, is signed and sent, and is discarded by the server’s JSON codec. Under the switch a verb takes only its own generated request model or a RawBody: a plain dict, or a model of another message, is refused as malformed before anything is resolved, read or sent, naming the model to pass. The message as stamped, with ver, the configured requester and a purchase’s reflected offers in it, must then pass the published strict JSON Schema of the request message and the cross-field rules, so an unknown member at any depth is refused and named by its path. A RawBody is still sent exactly as given. The switch applies whatever validation says and is off by default. A keyword argument a generated model does not declare is dropped by the model before the SDK sees it; a type checker reports it. Go verbs take the generated messages and TypeScript verbs are typed with the request input types, so neither has the switch.

Go: a call an interceptor retries reports its last attempt (Go SDK fix; no wire change). An interceptor passed to connect.WithInterceptors may call next again to retry a call. In v1.1.0 the client then kept what an earlier attempt had received: a call whose last attempt got no answer reported its Connect code as the peer’s (CallError.Code), and under WithStrictDecoding an earlier attempt’s error envelope could be checked, and refused, in place of the last attempt’s failure. Each request the call sends is now recorded on its own, so Code, and the new Status and Header, describe the last request sent.

This release does not interoperate with v1.0.8 inside one deployment: the request signature changed in both directions, so every party of a deployment upgrades together. Each bullet names what breaks, in which direction, and what to do; the entries below give the detail.

Every party

  • Request signatures and key directories (a hard cut). A v1.0.8 verifier does not read the dictionary Signature-Agent this release sends. This release refuses the bare value v1.0.8 sends, a key directory served as application/jwk-set+json or unsigned, and a delivery proof at the publisher edge that is not a full Web Bot Auth signature. Upgrade every signer, verifier, publisher edge and key directory together, and serve each directory with its own media type, signed by every key it lists. See “Request signatures and key directories follow the Web Bot Auth profile”.
  • Reason values added since v1.0.8. CATALOG_REJECTION_REASON_EXCHANGE_NOT_LISTED, DENIAL_REASON_RELAY_NOT_ACCEPTED, and the UNKNOWN_CRITICAL_EXTENSION value of the denial, catalog-rejection, usage-report, dispute, registration and domain-verification reason families are new. The v1.0.8 Python and TypeScript SDKs check an answer against generated models that do not list the new values, so they refuse a whole answer carrying one, such as a purchase answer with one item denied as DENIAL_REASON_RELAY_NOT_ACCEPTED. ErrorDetail.request_auth_failure is new as well; those models drop it, so a v1.0.8 client sees a signature refusal’s code with no typed reason. Upgrade those clients.

Exchange operators

  • An offer carries its price once. An offer whose term carries pricing is refused by a validating receiver (offer.terms.pricing_unset) and by this release’s offer signers and verifiers; v1.0.8 accepted it. State the price in Offer.pricing only and leave the sold term’s pricing unset. A PER_UNIT offer whose estimated_quantity is zero or negative is refused too (offer.metered.estimate_positive): omit the estimate or state a positive one.
  • A per-item denial is always in the body. A denied one-item purchase is answered OK with the item’s denial_reason; v1.0.8 answered it with a non-OK error carrying transaction_denial, which is now only for a refusal of the whole request. Answer in the body; a client reads the body’s denial_reason whatever the item count.
  • A relayed request needs an admitted Broker. A ResourceQuery or TransactionRequest whose requester.domain is not the signer’s directory is acted on only when the Exchange admits the signer as a Broker, and is otherwise refused unauthenticated with request_auth_failure SIGNATURE_INVALID. A v1.0.8 Exchange read a Broker-forwarded request’s agent signature and took the Broker’s key from a configured key set; a Broker’s request now carries the Broker’s signature alone, with its key in the directory it names. Decide which Brokers you admit, for example the ones registered with you. See “An Exchange tells a relayed request from a direct one”.
  • The hop cap counts every signature. max_intermediary_hops is the number of signatures accepted, every one counted, and a request over it is refused with resource_exhausted before any signature is verified. The Go guidance to configure one more than you publish is gone: publish the count itself.
  • Refusals that changed shape. A catalog call whose signature fails is refused unauthenticated with request_auth_failure, where v1.0.8 sent CATALOG_REJECTION_REASON_SIGNATURE_INVALID. GetAccountStatus answers an agent with no account NOT_FOUND, where a v1.0.8 Exchange could answer OK with an empty billing_ref. The same idempotency key with a different request is refused already_exists, where v1.0.8 replayed the stored result for any reuse of a key.
  • Rules that stop applying. fora.admin.v1.ReportingPolicy.quantity_tolerance is ignored: never refuse a usage report for its quantity. The scope-shortfall and restriction reasons deprecated in this release are never sent.

Agents and other clients

  • A query and a purchase name their requester. requester is required on ResourceQuery, DiscoveryRequest and TransactionRequest, and Requester.id is required, 1 to 255 characters; v1.0.8 had neither rule. Name the requester, with an id and with the host of your own key directory as its domain.
  • Mint a new idempotency key for each distinct request, since a reused key with a different request is refused, and read a one-item denial from the body, as above.

Publishers and catalog contributors

  • RemoveResources names each resource in resources. paths is ignored, so a v1.0.8 request carrying only paths is refused. Send ResourceRef items, each a domain and a path.
  • A catalog signature failure arrives as request_auth_failure, as above.

Brokers

  • A Broker never forwards an agent’s request unchanged. It originates its own discovery queries and signs them alone, where v1.0.8 relayed an agent-signed ResourceQuery and chained its own signature onto it. It never copies the agent’s request-level ext or ext_critical onto a request it authors.

SDK users

  • Signing. A signing transport requires the signer’s directory (WithSignatureAgent, signature_agent, signatureAgent) and refuses a signed call locally without one; v1.0.8 signed with an empty Signature-Agent. core.MonotonicWindow and its peers sign at the clock’s time and are deprecated. Python’s sign_agent_binding returns an AgentBinding instead of a tuple.
  • Key resolution. The key resolver’s scheme option is removed (Go WBAKeyResolverOptions.Scheme, Python WBAKeyResolver(scheme=...), TypeScript WBAKeyResolverOptions.scheme). A custom request-key resolver receives the signature’s directory: Python’s KeyResolver.resolve and TypeScript’s RequestKeyResolver.resolve take it as a second argument. WBAKeyResolver.Revoked / revoked takes the directory and answers from that directory’s own list, and the offer-key cache’s predicate takes (thumbprint, exchange).
  • Removed TypeScript helpers. COVERED_COMPONENTS, buildRequestSignatureBase, maxSigLabelN, signatureBytesByLabel, parseMultisigSignatureInput and the regex-based proof parsers are gone.
  • Stricter inputs and reads. The offer signers refuse an offer whose term carries pricing, and the acceptance signers and verifiers refuse an empty requester id or domain. The generated Pydantic models, Zod schemas and TypeScript input types require requester on the three requests. A document body over 1 MiB is refused rather than truncated, and the TypeScript reader also ends a read at 30 seconds and follows at most five redirects itself, never from https to http.

Discovery has no side effects; usage-report and relayed-acceptance refusals are stated (comments only). Five rules the contract implied but did not state:

  • DiscoverResources creates no transaction, no billing authorization and no reporting obligation; an agent commits to an offer only on the execute path.
  • Discovery is therefore safe to retry. A transport failure, such as an unreachable Exchange or a timeout, is the client’s concern, and the protocol defines no transient-failure code for it.
  • A usage report whose billing_id is not the transaction’s is malformed: invalid_argument with USAGE_REPORT_REJECTION_REASON_MALFORMED.
  • A usage report for a transaction recorded under a different agent is answered exactly as for an unknown transaction, not_found with USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND, so the answer does not reveal that the transaction exists.
  • On a purchase a Broker relayed, an item whose AgentAcceptance does not verify under the keys the directory requester.domain names publishes is denied in the body with DENIAL_REASON_SIGNATURE_INVALID, as for the AgentRequestAcceptance; the whole request is not refused.

The DiscoverResources RPC, the UsageReport message and DENIAL_REASON_SIGNATURE_INVALID state them, and the transaction-flow and Exchange request-flow pages follow.

GetAccountStatus answers an agent with no account NOT_FOUND (a behaviour change; no wire change). The RPC comment now states the answer for an agent the Exchange knows, its request signature verified, but that holds no account at that Exchange: the Connect code NOT_FOUND. GetAccountStatusResponse.billing_ref is therefore set on every OK answer, and a receiver reads an empty handle, which an Exchange built before the rule may send, as the same answer. The reference and AI-agent pages and the SDK doc comments of GetAccountStatus / get_account_status / getAccountStatus say the same; the SDKs return the refusal as their call error with the code not_found.

The hop cap counts every signature and is refused with resource_exhausted (a changed meaning for a released field; no wire change). WellKnownManifest.max_intermediary_hops is the maximum number of RFC 9421 signatures an Exchange accepts on a request, every signature counted whether or not it covers another. An Exchange refuses a request carrying more before it verifies any signature, with the Connect code resource_exhausted (HTTP 429) and no typed reason; the field comment states it once. Go’s connectserver.WithMaxSignatures and helpers.VerifyOptions.MaxSignatures no longer say to set the cap to max_intermediary_hops + 1: they take max_intermediary_hops itself, as the Python and TypeScript verifiers already did. RequestConstraints.max_hops rides only on DiscoveryRequest, which ends at the Broker, and a Broker originates its own queries, so no party receives or enforces it in this version; it is kept for multi-hop Broker discovery, and the proto comments and pages that said an Exchange enforces it no longer do. The transaction-flow, authentication, Exchange request-flow, reference, role-composition, what-is-FORA, Exchange overview and operator pages state the count-first rule and drop the forwarding-chain framing, the exchange-manifest page documents the field, and the academic, due-diligence and live-demo walkthroughs buy as the protocol now defines it: directly, or through BrokerService.ExecuteTransaction with the agent’s acceptances.

An MCP tool error carries {"refusal": UpstreamRefusal}; UpstreamRefusal.exchange is renamed party (field rename; no released message changes). UpstreamRefusal is the one shape for “a peer refused a call made on the caller’s behalf”. Its field 1 was named exchange, which fit only a Broker purchase. It is now party, the bare host of the party that refused, with the same number and host rule. On a Broker purchase item the party is the Exchange, equal to the refused items’ signed offer.exchange. UpstreamRefusal was added in this release, so no released message changes and buf breaking passes against v1.0.8 and v1.0.0; the proto-JSON name is now party.

The Identity Service and AI-agent pages specify an MCP tool error: an error result whose structuredContent is {"refusal": UpstreamRefusal}, the party being the Broker or Exchange that refused the call, or the Identity Service itself when it refused the tool call; detail is the FORA ErrorDetail that party attached, unchanged. Each tool’s outputSchema declares two branches, its success shape or {refusal}.

A Broker purchase item refused with no answer has an unknown outcome (comments only). TransactionResultItem.refusal said an item carrying an Exchange’s refusal was not purchased, while UpstreamRefusal.code said a timeout may have completed a purchase. They now agree. With the code unavailable or deadline_exceeded the item’s outcome is unknown, whether the Exchange gave no answer or answered that code, and retrying the agent’s request with the same idempotency_key settles it, on any route; with any other code the Exchange decided no item, and the item was not purchased. BrokerTransactionResponse.totals does not count an item with an unknown outcome, so it is then a lower bound of what the purchase charged. The Broker purchase verbs’ doc comments in all three SDKs, the Python README, and the transaction-flow, Broker, Identity Service and fetch-flow pages say the same.

Revocation is scoped to the signer, and revocation-list and manifest fetches may follow up to five redirects (comments, docs and an SDK API change). Each signature is verified against its own signer’s key directory and the revocation list that directory names, and against no other party’s. A Broker’s list covers only the Broker’s keys, and an agent’s list covers only that agent’s keys, so no party’s list can revoke another party’s key. A request that carries several request signatures is refused if any one of them fails, and a request signed only by the agent never depends on the Broker’s directory being reachable. A party fetching a revocation list or a /.well-known/fora.json manifest MAY follow up to five redirects, re-pinning the address and re-vetting the scheme at each hop. A key directory is never fetched through a redirect: it answers 200 itself, as the Web Bot Auth profile below requires. The file header, WBAFile.revocation_url, KeyRevocationList and the Well-Known Discovery block state both rules, and the authentication page gains “Whose list revokes whose key” and “Fetching directories, revocation lists and manifests”.

SDKs, in all three languages: the key resolver’s revocation accessor answers for one directory’s own list, and the cross-directory form is removed (a breaking change for a caller). Go WBAKeyResolver.Revoked(keyID, directory), Python WBAKeyResolver.revoked(key_id, directory) and TypeScript revoked(keyId, directory) report whether the thumbprint is on the revocation list of the directory directory names, and on no other. The reference is normalized as the resolver normalizes a Signature-Agent member, and a fetched directory spelled differently still matches under the request-recipient identity rule. The accessor used to report a thumbprint present on any fetched directory’s list, so a directory a request named, even one listing no key, could revoke another party’s key. The offer-key caches’ revocation predicate gains the exchange the key came from: Go CachedOfferKeyResolverConfig.Revoked becomes func(thumbprint, exchange string) bool, Python’s revoked a Callable[[str, str], bool] and TypeScript’s revoked a (thumbprint, exchange) => boolean, which is the resolver accessor’s shape. A directory that lists no key is still polled, since its list may revoke copies of its own keys held elsewhere. revocation-membership-vectors.json serves two directories and pins that one directory’s list never answers for the other’s key.

PushResourcesRequest.caller_id is deprecated (comments only). It was never needed: each entry’s domain names whose resource it is, and the caller is the party that signs the request; the Exchange checks that the verified signer may push for each entry’s domain, and a receiver does not rely on it. The field is retained for the v1 wire contract; it does not yet carry the deprecated field option, which follows once implementations no longer read it. The SDK docs, test fixtures and the catalog pages (reference, multi-tenant, request flows, content sources, verification vendors, publisher onboarding, deployment models, threat model) no longer set or rely on it.

Docs: CoMP keys, self-push and custodied signing (docs only). The licensing-terms page no longer cites a CoMP revshare key: canonical CoMP V1 has none, and fora-comp-v1 keys are flat dotted keys in ext like every other profile’s, as the CoMP page says. Its revenue-share example gates the term on the scope the agent states in its signed request. The for-providers page says a publisher may always push for its own domain, and only listed contributors besides. The identity overview says the service signs an acceptance with the agent’s own custodied key.

Docs: request samples sign as their requester and read fields that exist, and the operator checklist follows the protocol (docs only). The proof-of-concept curl sends the whole Web Bot Auth header set: Content-Digest, an empty Authorization, a Signature-Agent dictionary member and a Signature-Input that covers it. It also sends the body’s exact bytes, which the digest covers. The client samples on the walkthrough-v1, catalog-sources and Broker selection-engine pages name the signer’s key directory (WithSignatureAgent, signatureAgent, signing_transport_for), so the Broker’s connection pool signs alone, and the budget-reporting page lists that option among the elided ones. The fetch-flow page reads UpstreamRefusal.party, the for-AI-agents quota sample uses the fields SubscriptionQuotaInfo has, and the scenario-walkthrough and production-architecture pages list the full covered set. The for-Exchange-operators page replaces its signature and forwarding sections with one ordered checklist that links to the authentication page: count the signatures, verify each under the profile, refuse a replay, check revocation against the signer’s own directory, tell a direct request from a relayed one, verify the agent’s acceptances, and read a Broker-led query’s requester as the Broker’s statement. It no longer says the Requester carries a signature or that a key directory carries a role, and its denial list gains DENIAL_REASON_RELAY_NOT_ACCEPTED. The for-AI-agents page drops the same role slip.

Term scopes are matched against the scopes in the requester’s signed request (comments only). LicenseTerm.scopes said the Exchange returns a term when the agent’s delegation grant covers its scopes, and the Restriction header said term visibility is gated by delegation scope coverage. The Exchange returns an offer for a term when the scopes in the requester’s signed request (Requester.scopes) cover the term’s scopes. The threat model’s scope-pollution and scope-gating entries say the same.

A requester’s scopes are its own statement, and an Exchange may require a verified delegation before it presents a scoped term (comments and docs; no wire change). Requester.scopes was called “the enforcement mechanism” for enterprise roles and subscriptions, and the threat model listed scope pollution as preventable at protocol level, yet nothing verifies the scopes a requester states: they are part of its signed request, attributable to the signer, and unverified. Requester.scopes and LicenseTerm.scopes now say so. Scope coverage decides which terms an Exchange presents (LicenseTerm.scopes says “only when” where it said “iff”); it is not access control. A false statement is traceable to the signing key, its directory and its account, and the agent’s acceptance signs the scoped offer it buys; it is settled through reconciliation and the account relationship. An Exchange MAY also require a verified delegation that grants a term’s scopes before it presents the term, and answers a requester that presents none as if the term did not exist. An offer the Exchange presented is still honoured until it expires, and the purchase-time entitlement denials are unchanged. The threat model’s scope-pollution entry says what reconciliation compares and moves from “Preventable at protocol level” to “Detectable via reconciliation” and “Legal/contractual only”. The authentication, licensing-terms, ingestion and getting-started pages say the same, and the design history records that verifying scopes is deferred.

Restrictions are declared terms, never enforced by the Exchange; DENIAL_REASON_RESTRICTION_NOT_SATISFIED and TransactionResultItem.restriction_mismatches are deprecated (comments only). The licensing-core header, the ENUMERATED and REFERENCE_ONLY comments and the Quota comment said restrictions and quotas are enforced or gate a term’s validity. They are declared terms the parties agree to when the agent accepts the offer, and the Exchange never enforces a restriction, so a purchase is never denied for one. DENIAL_REASON_RESTRICTION_NOT_SATISFIED is never sent and TransactionResultItem.restriction_mismatches is never set; their numbers are retained and not reused. The licensing-terms, transaction-flow, reference, C2PA and medical-imaging pages follow: the medical-imaging walkthrough records the agent’s DUA instead of denying the purchase with a restriction reason.

RemoveResources names each resource by domain and path (additive field and message; RemoveResourcesRequest.paths deprecated). A bare path names no domain, so the Exchange could not tell whose resource it was or whether the signer may remove it. The new RemoveResourcesRequest.resources (field 5, 1 to 256 items) carries ResourceRef items, each a domain and a path under ResourceEntry’s rules. For each item the Exchange resolves /.well-known/fora.json from its domain and checks that the request’s signer is authorized to push for that domain, exactly as for a push; the caller is the party that signs. paths is deprecated and ignored, and loses its min_items rule, so a request carrying only paths is refused by the min_items rule on resources. The Go SDK’s client comment and tests, the TypeScript raw-body test and the conformance cases use resources; the reference and multi-tenant pages say the same.

A mis-addressed request is refused before processing, for every RPC (comments only). “Request recipient” says the recipient applies the exchange check to every RPC that carries it, before processing the request, and never answers it with an RPC’s own reason family. PushResourcesResponse limits its “a push that could not be applied carries catalog_rejection” to pushes the Exchange processed: a mis-addressed or malformed push is invalid_argument with no typed reason, and one whose signature fails is unauthenticated with request_auth_failure. The reference page says the same.

Catalog push refusals: wire bounds, the contributor reason and CATALOG_REJECTION_REASON_EXCHANGE_NOT_LISTED (one additive enum value). A push with no entries or more than 256 is a malformed request: the existing min_items and max_items rules refuse it at wire validation, invalid_argument with no catalog_rejection. An entry naming a resource owner the signer may not push for is CATALOG_REJECTION_REASON_NOT_CATALOG_CONTRIBUTOR, whose comment now says who may push. The new CATALOG_REJECTION_REASON_EXCHANGE_NOT_LISTED (11) refuses a push for a domain whose manifest does not list the recipient Exchange. The SDK error-detail decoders in Python and TypeScript know the new value; the reference and Exchange request-flow pages state the same.

A Broker forwards the agent’s supported_profiles and does not route by them (comments only). DiscoveryRequest.supported_profiles said the Broker routes queries to Exchanges that support the agent’s profiles. The Broker MUST forward the list unchanged in ResourceQuery.supported_profiles on every query, and an absent list as absent; it does not choose Exchanges by profile, and each Exchange answers for its own profiles. The Broker overview, the extension-profile pages and the getting-started pages say the same.

A request’s critical extensions are for its receiver, and an entry’s are for the agent: UNKNOWN_CRITICAL_EXTENSION is added to six reason enums (additive enum values). The file header gains “Critical extensions”, the one statement of the rule. A request’s ext_critical is for the party that receives that request; a receiver that does not understand a listed key answers with its RPC’s reason: OK with no offers and OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION on discovery and Resolve, OK with every item denied as the new DENIAL_REASON_UNKNOWN_CRITICAL_EXTENSION (20) on a purchase, the new CATALOG_REJECTION_REASON_UNKNOWN_CRITICAL_EXTENSION (10) on a catalog push, and the new USAGE_REPORT_REJECTION_REASON_UNKNOWN_CRITICAL_EXTENSION (6, invalid_argument) on a usage report. A dispute, a registration (a repeat included) and both domain-verification calls are refused with invalid_argument and the new DISPUTE_FAILURE_REASON_UNKNOWN_CRITICAL_EXTENSION (6), REGISTRATION_FAILURE_REASON_UNKNOWN_CRITICAL_EXTENSION (8; 4 stays retired) and DOMAIN_VERIFICATION_FAILURE_REASON_UNKNOWN_CRITICAL_EXTENSION (7). GetAccountStatus, whose refusals have no reason family, answers invalid_argument with no typed reason. A Broker that does not understand a key the agent’s purchase lists answers OK with every item denied as DENIAL_REASON_UNKNOWN_CRITICAL_EXTENSION and contacts no Exchange — the one denial a Broker makes itself — after its signature, requester and malformed-request checks and before routing. A Broker never copies the agent’s request-level ext or ext_critical onto a request it authors; a message it carries unchanged keeps its own. RemoveResourcesRequest and RefreshCatalogRequest carry no ext. ResourceEntry.ext_critical is for the agent: the Exchange carries it onto Offer.ext_critical and does not consume it, so it never refuses a push. The SDK error-detail decoders in Python and TypeScript know the new values, and error-detail-wire-vectors.json and the validation corpus are regenerated. The extension-profiles, academic walkthrough, what-is-FORA, transaction-flow and medical-imaging pages state the rule.

A catalog call whose signature fails is refused with request_auth_failure; CATALOG_REJECTION_REASON_SIGNATURE_INVALID is deprecated (a behaviour change; no wire change). A signed CatalogService call is refused like every other signed request when its signature is missing, does not verify or is stale: unauthenticated with ErrorDetail.request_auth_failure (SIGNATURE_MISSING, SIGNATURE_INVALID or SIGNATURE_STALE). CATALOG_REJECTION_REASON_SIGNATURE_INVALID is never sent; its number is retained and is not reused. The Exchange request-flow page no longer lists it.

An offer carries its price once: Offer.pricing (two validation rules added; LicenseTerm.pricing loses its field-level rule; no field removed). An offer carried its price twice, in Offer.pricing and in the pricing of the one term it sells, and the comment called the term’s copy authoritative. The two could disagree inside one signed offer, nothing said which one execute charges, and the metered rule read either copy.

  • Offer.pricing is the offer’s only price: the price execute charges, a Broker ranks and a metered purchase is charged at. The term inside Offer.terms MUST carry no pricing (rule offer.terms.pricing_unset, on Offer). Each offer derives from exactly one catalog term, so the price is stated once and cannot disagree with itself.
  • offer.metered.estimate_positive reads Offer.pricing only: an offer is metered when its pricing is PER_UNIT.
  • Catalog terms still carry pricing. LicenseTerm.pricing loses its field-level required rule, because the requirement depends on the message that holds the term, and ResourceEntry gains resource_entry.terms.pricing_required: every catalog term carries its price, whatever its semantics. A standalone LicenseTerm without pricing is no longer refused on its own.
  • buf breaking against v1.0.0 passes. A validating peer now refuses an offer whose term carries pricing, including inside a TransactionRequest.
  • The field-level corpus drops LicenseTerm/pricing/missing. The cross-field corpus gains two priced-term offers and a ResourceEntry term without pricing, replacing the metered term-only case, and the license-term entry vectors report a catalog term without pricing as that cross-field rule.

SDKs, in all three languages: IsMeteredOffer / is_metered_offer / isMeteredOffer read the offer’s pricing only. New CheckOfferTermsUnpriced / check_offer_terms_unpriced / checkOfferTermsUnpriced apply offer.terms.pricing_unset without wire validation (Go returns helpers.ErrOfferTermPriced, new; Python raises ValueError; TypeScript throws). The offer signers (helpers.SignOffer, sign_offer_jcs, signOffer) refuse an offer whose term carries pricing, and the offer verifiers reject one after the signature and expiry checks. The Python and TypeScript cross-field layers gain both rules, with a composed ResourceEntry model, and validate_resource_entry / validateResourceEntry check the entry itself. The offer-verify vectors gain a term repeating the offer’s price, which is rejected, and an unpriced term under a FLAT price, which verifies.

A per-item purchase denial is always answered in the body (comments and two deprecations; no wire change). A denied one-item purchase was described as a non-OK error carrying ErrorDetail.transaction_denial, while the same denial in a batch rode in the body, so a caller handled one decision in two shapes.

A usage report is refused for time, never for quantity (comments; one admin field deprecated). UsageReportRejectionReason states that the reported quantity is unrestricted: no reason refuses a report because its consumed_quantity differs from the estimate, above or below. Monitoring how far reports fall from estimates is the Exchange operator’s own business, not part of the protocol. The existing USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED is the time cause, refused with failed_precondition; USAGE_REPORT_REJECTION_REASON_MALFORMED is a malformed report, refused with invalid_argument. fora.admin.v1.ReportingPolicy.quantity_tolerance is deprecated and ignored: an Exchange must not refuse a report on it.

Idempotency keys are scoped per caller, and a reused key with a different request is refused (a behaviour change; no wire change). The file header gains “Idempotency”, the one statement of the rule for TransactionRequest, UsageReport and DisputeRequest. A key is scoped to the authenticated agent, whatever the route: a purchase relayed through BrokerService.ExecuteTransaction shares the agent’s scope with a direct one. A key another agent or tenant used is never a collision. The same key with the same request is a replay answered from the stored result. The same key with a different request (for a purchase, different items or another order) is refused with the Connect code already_exists and an ErrorDetail with no typed reason.

BrokerService.Resolve reports a budget as BUDGET_EXCEEDED (comments only). Resolve may apply the agent’s budget (RequestConstraints) as a filter over the offers it returns. How the budget is computed is the Broker’s own behaviour, and the protocol does not specify it: which price an offer is compared at, and how spend is counted, are left to the party that applies the budget. What the protocol fixes is the report: a URI left with no offer is answered with OFFER_ABSENCE_REASON_BUDGET_EXCEEDED, never NOT_AUTHORIZED and never an error such as RESOURCE_EXHAUSTED, and Resolve never charges. The RequestConstraints budget fields keep plain meanings, and period_budget no longer says transactions are denied.

TransactionResponse.total_cost is unset when the items span currencies (comments only). It is the exact sum of the purchased items’ costs in their one shared currency, and unset when they span more than one. Currency conversion is out of scope for this version, which BrokerTransactionResponse.totals states too.

DisputeTransaction states its refusal reasons (comments only). Every Exchange implements the RPC. Each DisputeFailureReason now names its check: no transaction for the caller, no accepted report for this transaction, the dispute window closed, a dispute already filed (a retry under the same key is a replay), and a transaction whose state admits no dispute. A filing whose ext_critical names a key the Exchange does not understand is refused with DISPUTE_FAILURE_REASON_UNKNOWN_CRITICAL_EXTENSION (see the critical-extensions entry above).

Docs: the transaction-flow page gains “Denials are in the body” and “Idempotency”, states the report refusals and the dispute refusal reasons, and drops “no refunds in the protocol”. The Exchange request flow answers every per-item denial in the body, scopes the idempotency key per caller, leaves total_cost unset across currencies and stops checking a minimum billable quantity. The Broker pages describe the budget as a filter at Resolve and drop the currency converter and the generated idempotency key. The money-flow page divides unit_cost by the resource’s size rather than by estimated_quantity, and every walkthrough and example that paired a per-access unit with a token-count estimate now states the estimate in the price’s own unit. The medical-imaging walkthrough answers its DUA failure in the body. Offer examples drop their terms’ pricing, and the licensing-terms, reference and threat-model pages state the same rules.

A metered purchase charges estimate × rate, or one unit’s rate without an estimate, and the charge is final (comments and one validation rule; no wire change). A PER_UNIT price is charged per unit, but the protocol did not say what a metered purchase charges.

  • The publisher states the rate and the unit. An offer may state a rate with or without pricing.estimated_quantity; whether to state one is the publisher’s decision, and nothing requires it, on the pushed term or on the offer. An estimate an offer states is positive (rule offer.metered.estimate_positive, on Offer). An estimate the publisher states, on the term’s own pricing or once on ResourceEntry.estimated_quantity, is carried onto Offer.pricing, the term’s own taking precedence.
  • Stated once, on Pricing: a metered purchase charges E × R, or one unit, 1 × R, when the offer states no estimate. That is what TransactionResultItem.cost carries, and the charge is final. A disagreement about the quantity consumed is a dispute, which this version does not define further. A price whose metering is NONE is no different.
  • A usage report is a record of the quantity consumed. UsageReport, Usage.consumed_quantity, ReportingObligation and UsageReportRejectionReason state that a report whose quantity differs from the estimate is never refused for it.
  • buf breaking against v1.0.0 passes. The rule is new validation: a validating peer refuses a PER_UNIT offer that states a zero or negative estimate, including inside a TransactionRequest. A PER_UNIT offer with no estimate is valid. The cross-field corpus gains the zero and negative cases, and the conformance cases accept a metered offer with no estimate, alone and inside a TransactionRequest.

SDKs, in all three languages: the offer verifiers reject a metered offer whose stated estimate is not positive, after the signature and expiry checks (Go: core.Verifier, reason helpers.ErrMeteredEstimateNotPositive), and verify a metered offer that states none. New checks IsMeteredOffer / is_metered_offer / isMeteredOffer and CheckMeteredEstimate / check_metered_estimate / checkMeteredEstimate apply the rule without wire validation. The offer-verify vectors gain the metered cases, among them a metered offer with no estimate that verifies.

Docs: the transaction-flow page gains “Charging a metered purchase”, and its list of report checks drops the ±20% quantity tolerance that refused honest reports, as do the Exchange storage model and the scenario walkthrough. The licensing-terms page gains “Metered pricing: rate, unit and estimate”, and the money-flow page, the reference page and the walkthroughs describe the charge. The content-ingestion pages, the billing adapter, the Exchange configuration and storage pages, the operator guide, the JSONL ingestion page and the threat model now describe the pipeline’s token count as the size unit_cost compares by, never an offer’s estimate: an offer carries only the estimate the publisher states, in the price’s own unit, and a usage report never changes the charge. The JSONL worked example no longer pairs a per-access price with a token-count estimate.

A query and a purchase name their requester, and an acceptance names it too: requester, Requester.id and Requester.domain are required (validation rules added; no wire change). An agent’s offer acceptance signs canonical bytes built from the offer, the request’s Requester and the idempotency key. The proto let Requester.id be empty, and the SDKs signed an empty requester_domain as well, so those bytes could name no requester. On a purchase relayed through BrokerService.ExecuteTransaction, that acceptance is the only agent signature the Exchange sees: the request signature there is the Broker’s, which says only that the call comes from the Broker and never signs the purchase.

  • Requester.id is REQUIRED, 1 to 255 characters. It is a free label the agent chooses for attribution, for example to tell apart sub-agents or end customers behind one key directory. It is never identity, never used to find keys, and never trusted.
  • Requester.domain is REQUIRED, a non-empty bare host under the same pattern every other domain field uses. The pattern already refused an empty value; the comment now says so. The field is the host of the agent’s key directory, and never a free label.
  • requester itself is REQUIRED on ResourceQuery, TransactionRequest and DiscoveryRequest. A rule on a field of an absent message never runs, so a request that named no requester at all passed the two rules above. RegisterRequest, GetAccountStatusRequest, UsageReport and DisputeRequest carry no requester.
  • The Requester.domain comment said verification never uses the field, which contradicted the relayed purchase. It now states the rule per path. On a direct request (the agent’s own signature arrives), a verifier resolves the agent’s keys from the covered Signature-Agent and MUST require Requester.domain to name that same directory, compared by the request-recipient identity rule; a mismatch is refused as unauthenticated with request_auth_failure SIGNATURE_INVALID. An Exchange applies this to every request the agent signed, as a Broker already did on ExecuteTransaction. On a purchase relayed through a Broker, the Exchange MUST verify the agent’s acceptances against the key directory Requester.domain names. On a Broker’s discovery fan-out, which carries no agent signature, the field is the Broker’s statement of whom it queries for.
  • AgentAcceptance, AgentAcceptancePayload, AgentRequestAcceptance and AgentRequestAcceptancePayload state that an acceptance names a non-empty requester. A signer refuses to sign bytes that name an empty requester, and a verifier refuses an acceptance whose canonical bytes name one, even when the signature over them verifies.
  • buf breaking against v1.0.0 passes: adding validation rules is not a wire break. A peer that sent an empty Requester.id, or a query or purchase with no requester, is now refused by a validating receiver.
  • The validation corpus gains Requester/id/too_short, Requester/id/too_long, ResourceQuery/requester/missing, TransactionRequest/requester/missing and DiscoveryRequest/requester/missing. Its Requester baseline now carries an id, and is seeded so the baselines of the three requests carry it.

SDKs, in all three languages: the acceptance canonicalizers and signers refuse an empty requester id or domain, for the offer acceptance and the request acceptance alike, and the verifiers refuse such an acceptance. Go returns helpers.ErrAcceptanceRequesterEmpty (new, match it with errors.Is) from CanonicalAcceptanceBytes, SignOfferAcceptance, SignOfferAcceptanceWith, VerifyOfferAcceptance, RequestAcceptancePayload, CanonicalRequestAcceptanceBytes, the request-acceptance signers and VerifyRequestAcceptance. Python raises ValueError from the *_jcs canonicalizers and signers and from verify_offer_acceptance_jcs, and verify_request_acceptance_jcs returns False. TypeScript throws from acceptancePayload, requestAcceptancePayload and the signers, and the verifiers return false. The purchase verbs of every client (direct and through a Broker) refuse a configured requester with an empty id or domain locally, as a malformed call, before signing or sending anything. The shared acceptance vectors move their empty-requester cases into a new refused list, which records the bytes and raw signature a signer without the check would produce, so each language proves it refuses them even though that signature verifies. The generated Pydantic models, Zod schemas and TypeScript input types now require requester on the three requests, a type-level change for code that builds one without it. The Python and TypeScript clients validate an outgoing request by default, so a discover left with no requester (none configured and none in the message) is refused locally as malformed; resolve and the purchase verbs already refused it with a named remedy. Go validates only when asked, so its discover is refused by the receiver.

Docs: the authentication page’s Requester table and a new “Requester domain and the key directory” section, the projected-execute section, the transaction-flow page, the reference page and the Exchange request-flow example state the same rules.

An Exchange tells a relayed request from a direct one (a new receiver rule; no wire change). Agents and Brokers call the same ExchangeService RPCs, and Requester.domain had one rule on a direct request and another on a request a Broker authored, with nothing saying how an Exchange tells them apart; the file header said the Exchange does not distinguish. The file header gains “Direct and relayed requests”, the one statement of the rule. On ResourceQuery and TransactionRequest, an Exchange compares Requester.domain with the host of the directory the verified signature’s covered Signature-Agent member names, by the request-recipient identity rule. Equal is a direct request, under the direct rule. Different is a relayed request, which the Exchange acts on only when it admits the signer as a Broker, by its own policy (for example, the Brokers registered with it), and otherwise refuses as unauthenticated with request_auth_failure SIGNATURE_INVALID. No field marks a relay, and neither WellKnownManifest.role nor Requester.type admits a Broker: both are the sender’s own statements. A Broker is never relayed to. It refuses a mismatched requester at BrokerService.ExecuteTransaction; at BrokerService.Resolve it refuses nothing for requester.domain, and every query it originates names the agent’s verified directory. An Exchange that accepts relayed requests needs an admitted-Broker policy. The Role values gain comments: a Broker’s manifest declares ROLE_BROKER, which describes the document and admits nothing.

Docs: the authentication page gains “Direct and relayed requests”, and its key table lists a Broker under ROLE_BROKER. The transaction-flow, production-architecture, proto-fora, Broker overview, exchange-manifest, request-flows and threat-model pages state the rule or link to it, and no page gives a Broker ROLE_AGENT any more.

A purchase a Broker relays is the agent’s (comments and docs; no wire change). Nothing said whose purchase a Broker relays: the account was resolved “from the verified signature”, which on a relayed purchase is the Broker’s, and an idempotency key was scoped to the pair (Broker, requester), so one signed consent could execute again through a second Broker. The file header gains “Accounts”, the one statement of the rule. An account at an Exchange belongs to the agent’s key directory: every currently valid key it publishes acts for the account, a key rotation keeps the account and its billing_ref, and Requester.id never selects one. The Exchange learns the directory only from what authenticates it: the request signature on a direct request, the agent’s acceptances on a relayed purchase. Billing, the account denials (DENIAL_REASON_ACCOUNT_NOT_REGISTERED, DENIAL_REASON_ACCOUNT_INACTIVE, DENIAL_REASON_REPORTING_OVERDUE), ownership of the transaction, and the right to report on it and dispute it refer to the agent’s account, and the Exchange keeps the Broker with the transaction record for attribution. The agent files its usage reports and disputes with the issuing Exchange itself; a Broker files none.

An idempotency key is scoped to the agent whatever the route: a purchase repeated directly or through any Broker the Exchange admits is the same purchase, so one signed consent executes at most once at an Exchange, however it arrives. The Exchange verifies the agent’s authorization before it creates idempotency state or answers from it, and a replay returns the stored result with the delivery binding it recorded. An unknown outcome is settled by retrying with the same key on any route, and a new attempt after a stored denial takes a new key and new acceptances.

Docs: the authentication page gains “Accounts”, and the transaction-flow, request-flows, threat-model and reference pages state the rule. The Broker retry comments in all three SDKs and the Python README say a retry may take any route. The Broker overview and deployment pages no longer describe a report or dispute relay or a ReportingRelay interface, and the agent SDK, due-diligence, scenario, money-flow and getting-started pages say the agent holds the account and files its own reports. The Exchange scaling and storage pages key the idempotency store by the agent and its key.

A scope shortfall is never disclosed: OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT and DENIAL_REASON_SCOPE_INSUFFICIENT are deprecated and never sent (no wire change). Their comments described an Exchange sending them, which contradicted Requester.scopes: a requester never learns about a resource outside its scopes. Requester.scopes now states the existence-hiding rule once, and the related comments point at it.

  • At discovery, when the requester’s scopes leave no presentable offer for a resource, the Exchange answers with no offers and no absence reason, exactly as for any resource with nothing to offer. A Broker relaying that answer adds no reason of its own. OfferGroup.absence_reason is no longer described as present whenever offers is empty.
  • At purchase, an offer the Exchange presented is honoured until it expires, so a scope refusal cannot arise.
  • Both values are marked [deprecated = true]. Their numbers are retained, because removing them would break the v1 wire contract, and must not be reused. A receiver treats either value as unknown. The SDK readers still decode them, and the generated models still accept them.
  • The guides no longer tell an Exchange to send either value, or an agent how to react to one: the authentication page’s disclosure-policy section became “Existence hiding”, and the Exchange-operator, enterprise, AI-agent, discovery-paths and threat-model pages and the reference page follow it. The doc-conformance check now fails if either value is named in the guides.

Edge discovery headers: X-Content-Rules and X-FORA-Exchange (HTTP convention specified; no message change). When a publisher’s edge refuses an unlicensed AI agent with 403, it answers with two discovery headers, and they are now part of the protocol. Neither was defined before. The documentation presented X-Content-Rules as a FORA extension pointing at the Exchange, which was wrong: it points at the publisher’s manifest. The normative text is the new “Edge discovery headers” section of the fora.proto file header.

  • X-Content-Rules is the absolute URL of the publisher’s manifest, exactly https://{domain}/.well-known/fora.json with the publisher’s bare domain (a port allowed) and no userinfo, query, fragment, trailing slash or other path. An edge SHOULD send it on every 403 to a request for licensed content that carries no valid signed URL.
  • X-FORA-Exchange is the bare domain of one Exchange that sells the content directly, in the same form as Offer.exchange. An edge MAY send it, only together with X-Content-Rules, and only for an Exchange its manifest lists. It is an optimisation over X-Content-Rules, not a replacement.
  • An agent reads the headers only from a 403, and ignores a value of the wrong shape as if it were absent. With X-FORA-Exchange it MAY start discovery at the named Exchange without fetching the publisher’s manifest first. It resolves that Exchange’s endpoint from the Exchange’s own fora.json, never from the header, under the request-recipient rule.
  • An agent MUST NOT treat X-FORA-Exchange as authorization. Offers are verified by their signatures, and the publisher’s manifest stays the authority on which Exchanges sell the content. When the header names an Exchange the manifest does not list, the manifest wins: the agent discards the header, does not transact on an offer from that Exchange, and discovers at the Exchanges the manifest lists.

SDKs, in all three languages: parse_discovery_hint(status, headers) (Python, from fora_sdk), parseDiscoveryHint(status, headers) (TypeScript, export path ./discovery-hint) and helpers.ParseDiscoveryHint(status, header) (Go) read a response’s discovery headers into a DiscoveryHint: each value with a state of absent, valid or malformed. Any status other than 403 reads as absent, and a header sent twice is malformed. reconcile_discovery_hint, reconcileDiscoveryHint and helpers.ReconcileDiscoveryHint check the hinted Exchange against the domains the publisher manifest’s exchanges lists, with the recipient identity match, and answer listed, unlisted or no_exchange. The header names are exported as ContentRulesHeader and ExchangeHeader in all three languages and join wire-constants-vectors.json. New corpus: discovery-hint-vectors.json, replayed by all three SDKs.

Discovery Paths summarizes the rules.

Published JSON Schemas, with a strict variant (no wire change). The SDK build already generated a JSON Schema per message from fora.proto and discarded it after generating the Pydantic and Zod models. Those schemas are now release artifacts under gen/jsonschema/: one self-contained draft 2020-12 file per message of fora.v1 and fora.admin.v1, named by the fully-qualified message name, in two variants. fora.v1.ResourceResponse.schema.json accepts unknown fields; fora.v1.ResourceResponse.schema.strict.json sets additionalProperties: false on every message object at every depth, so a conformance check fails on an unknown or misspelled field. JSON Schemas describes how to validate a message against them.

  • Shipped in all three packages: the Go module as the gen/jsonschema/ directory, with jsonschema.Load(name, strict) and jsonschema.FS in package github.com/FORA-Protocol/protocol/gen/jsonschema; fora-protocol on PyPI as package data, read with wire.schemas.load(name, strict=False) and listed by wire.schemas.names(); @fora-protocol/sdk on npm under the export path ./jsonschema/*.
  • The schemas describe canonical proto-JSON. Field names are the snake_case proto names, and the lowerCamel aliases the generator emits are dropped, so a camelCase key is an unknown field. google.protobuf.Struct (every ext) stays open in the strict variant. A 64-bit integer accepts a decimal string or a JSON integer, with its bound applied to both forms; other numbers are JSON numbers only, so a string cannot carry a value past a numeric bound.
  • They carry the per-field buf.validate constraints, and mark required every field whose zero value its own rule rejects. Cross-field rules and oneof exclusivity are not expressible per field and are not in the schemas.
  • The drift gate regenerates and compares them like the generated code. A Go conformance test checks them against the descriptors, and both variants must match Go protovalidate on every case of the conformance corpus, in Python and in TypeScript.

The Broker buys: BrokerService.ExecuteTransaction (additive wire change). A purchase of offers from several Exchanges is now one call to the Broker. Before this change BrokerService had only Resolve, and the contract described the agent buying at each Exchange directly, with a Broker on the path forwarding the request byte-for-byte under a stack of hop signatures. Implementations relayed purchases through the Broker anyway, by re-packaging them outside the contract. The contract now defines that relay.

  • rpc ExecuteTransaction(TransactionRequest) returns (BrokerTransactionResponse) on BrokerService. The agent sends the same TransactionRequest it would send an Exchange: every item with its AgentAcceptance, and one AgentRequestAcceptance over all items. The Broker verifies the agent’s request, groups the items by each signed offer’s exchange, and sends one ExchangeService.ExecuteTransaction per Exchange, signed with its own key. The acceptances travel in each sub-request body, so every Exchange still verifies the agent’s consent. The Broker forwards the agent’s idempotency_key unchanged to every Exchange.
  • New messages: BrokerTransactionResponse (items in request order, exchanges, per-currency totals), ExchangeOutcome (one per Exchange contacted: exchange, offer_ids, agent_identity_hash, subscription_quota) and UpstreamRefusal (party, the refusing party’s bare host; the Connect code; the party’s ErrorDetail). It is the one shape for “a peer refused”: the Identity Service’s MCP tool errors carry it too.
  • TransactionResultItem.refusal = 14. An Exchange’s refusal of a whole sub-request is never turned into a Broker error: it rides on each affected item, and the other Exchanges’ results come back unchanged.
  • DenialReason.DENIAL_REASON_RELAY_NOT_ACCEPTED = 19: the provider at this Exchange does not accept purchases relayed by a Broker. Buy the offer directly.
  • The Broker’s own refusals are non-OK errors: unauthenticated with request_auth_failure for an invalid agent signature, and SIGNATURE_INVALID when requester.domain is not the agent’s verified signing directory; invalid_argument for a malformed request; failed_precondition for an Exchange it cannot route to or does not approve.
  • DENIAL_REASON_CONTENT_UNAVAILABLE is documented as not a catch-all for upstream failures.
  • Re-packaging is safe because each item is atomic and its integrity is per resource: each offer carries its Exchange’s signature, each acceptance binds the agent to that one offer, and each result comes from the Exchange that owns the resource. In a result item the one signed value is retrieval_endpoint; the combined response is otherwise the Broker’s unsigned report.
  • Updated rules: a purchase through a Broker is always re-packaged, and the Broker signs each sub-request alone (see the Web Bot Auth profile entry below). On a re-packaged purchase the delegation holder binding (cnf.jkt) and agent_identity_hash use the key the item’s AgentAcceptance verifies under, not the request signer, which is the Broker. An Exchange scopes a relayed request’s idempotency key to the agent the acceptances authenticate, exactly as a direct request’s.

SDKs, in all three languages:

  • BrokerClient.Execute (Go), BrokerClient.execute (Python, async and sync) and BrokerClient.execute (TypeScript) buy verified offers through a Broker in one call and return the BrokerTransactionResponse. The client builds each item’s acceptance and the request acceptance from its signer, stamps ver and the requester, and refuses locally, as malformed, a requester.domain that is not the host of the directory it signs as (Signature-Agent).
  • The exchange client buys several offers from one Exchange in one request: Client.ExecuteBatch in Go, and execute given a sequence (Python) or an array (TypeScript). Offers from more than one Exchange are refused locally.
  • The client-request corpus gains the brokerExecute verb.

SDK capabilities for conformance and e2e harnesses, in all three languages (no wire change). A harness can now drive FORA services through the SDK, build a malformed request only where it means to, and check every answer through the SDK’s own decoder. Testing a FORA Service Through the SDK summarizes them per language.

  • Pre-signing hook. A function that receives each RPC request just before it is signed; the request it returns is signed and sent, and the answer decoded as usual. ClientConfig.before_sign (Python, an httpx.Request), beforeSign in ClientOptions (TypeScript, a Fetch API Request), connect.WithBeforeSign (Go, an *http.Request). A hook that changes the method or URL, sets a header the signer writes, or fails is refused locally as malformed, and nothing is sent.
  • Raw mode. A per-call body sent exactly as given: no ver, idempotency_key or requester is filled in and nothing about the message is refused locally, while the body is still signed and the answer still decoded. RawBody(body) in place of a verb’s request in Python and TypeScript, connect.WithRawBody(body) in Go (binary protobuf; every Go verb now takes call options). A verb that routes by the message still reads its destination from the body’s exchange.
  • Strict response decoding. ClientConfig(strict=True) (Python), strict: true (TypeScript) and connect.WithStrictDecoding() (Go) refuse an answer carrying an unknown field at any depth, or breaking a field-level or cross-field rule. Python and TypeScript check the published strict JSON Schema of the response message and the SDK’s cross-field rules, reading a null member as absent; Go checks the descriptor’s unknown fields and runs protovalidate. An error answer is checked too. The Connect error envelope may carry only code, message and details, must name one of the sixteen Connect codes, and its details must be well formed: each entry carries only type, value and debug, a non-empty type, a base64 value and at least one of the two. Every ErrorDetail in it is checked against the strict ErrorDetail schema and its rules, both the decoded binary value (an unknown field in the binary encoding included) and the debug projection. A refused envelope is malformed, keeps the Connect code on code/Code and carries no detail; an empty or non-JSON error body is a gateway’s answer and is still classified by its status. Without strict decoding an error answer is read as before. connect-error-vectors.json gains a strict_malformed column on every row and three rows for it: an envelope with an unknown top-level member, a debug projection with an unknown field, and a registration_failure whose field_errors break the RegistrationFailure message rule. All three SDKs replay every row strict and non-strict.
  • Error decoding. The JSON SDKs read a Connect error’s ErrorDetail from the binary details[].value, with a table-driven decoder and no protobuf dependency, and fall back to the debug projection only when value is absent. A value that does not decode is not replaced by its debug. The Connect code of a peer’s answer has its own field: CallError.code (Python), ForaCallError.code (TypeScript), CallError.Code (Go). connect-error-vectors.json now pins the code and the whole detail, including rows carrying only value, and the new error-detail-wire-vectors.json pins the binary decoding of every field of the ErrorDetail subtree against the descriptor.
  • Delivery URLs pass through unchanged. Execute, the Broker purchase and fetch hand back each retrieval URL as the Exchange issued it, and fetch presents the agent’s proof of possession. The delivery edge verifies the URL signature and, where it can, the agent binding against that proof; an edge that cannot check the binding (CloudFront with its pre-arranged RSA key pair) checks its own signature and treats the URL as a bearer token. New tests in all three SDKs pin the pass-through.
  • Admin client. AdminClient covers fora.admin.v1.AdminService (SetTenantFeeRate, SetReportingPolicy) and the two domain-verification RPCs, RequestDomainVerification and ConfirmDomainVerification: AdminClient in Python (async and sync), createAdminClient in TypeScript, connect.NewAdminClient in Go. Python has no separate factory: as for every other client, the Go factory folds into the AdminClient constructor. The SDK parity matrix records it with that reason, as its 17th documented divergence (16 at v1.0.8).
  • Identity helpers. Mint a fresh agent: a key and its thumbprint, the Web Bot Auth directory document for a key set, and a signer that signs as it. generate_key, directory_document, signing_transport_for (Python, in fora_sdk.identity); generateKey, directoryDocument, signingTransportFor (TypeScript, export path ./identity); helpers.GenerateKey, helpers.DirectoryDocument, core.SigningTransportFor (Go).
  • Document readers. Read and check the documents a party publishes over HTTPS: read_manifest, read_wba_directory, read_revocation_list and read_license_document (Python, fora_sdk.resolvers); readManifest, readWBADirectory, readRevocationList and readLicenseDocument (TypeScript, ./resolvers); resolvers.ReadManifest, ReadWBADirectory, ReadRevocationList and ReadLicenseDocument (Go). Each fetches through the SDK’s guarded client, with the SSRF and https-only scheme guards, and returns the parsed generated message with the URL, the bytes and the media type (Document), or fails with a typed error; none returns nothing. The manifest must be served as application/json and the WBA directory as application/http-message-signatures-directory+json (MediaTypeRefusedError, MediaTypeRefused, ErrMediaTypeRefused), fetched with no redirect and signed by every key it lists (see the Web Bot Auth profile entry below). The manifest’s ver is read first. Every document must pass the strict check below. The license reader checks the License, fetches its uri and verifies the bytes against uri_digest (LicenseDocument, or DigestMismatchError, DigestMismatch, ErrDigestMismatch). A failed fetch, a non-200 answer, a body that is not JSON and a body over 1 MiB are an unavailable document; a body over the cap is now refused rather than truncated, in the resolvers as well, and the cap is the read’s own in all three SDKs, whatever transport is injected. Two of those failures also say which: a 404 on any read is a missing document (DocumentNotFoundError, DocumentNotFound, ErrDocumentNotFound), and any 3xx answering a key directory is a refused redirect (RedirectRefusedError, RedirectRefused, ErrRedirectRefused), whose error names the read and never the Location. Each is still an unavailable document, as a subclass or a wrapped sentinel, so a check for one still matches; the resolvers report them too, so a 404 or a refused redirect from the WBA key resolver or the endpoint and requirements readers now carries the new class’s name. Go’s ErrDirectoryUnavailable reads resolvers: document unavailable, and a manifest fetch failure in the Go endpoint resolver and requirements reader now wraps it. The endpoint resolver, the registration-requirements reader, the WBA key resolver and the offer-directory fetch read the same documents through the same fetch and decode, without the strict check; the WBA key resolver and the offer-directory fetch still check a directory’s media type and response signatures. The Python and TypeScript readers return the generated model, which names an enum by its value name, so they refuse a document that writes an enum as its number; the Go reader accepts it. New corpora: document-check-vectors.json, the verdict for a document’s bytes and Content-Type, document-fetch-vectors.json, the verdict for the status a read is answered with, and license-digest-vectors.json, all replayed by all three SDKs.
  • Public strict check. check_strict(message_name, payload) (Python, from fora_sdk and fora_sdk.client, raising StrictViolationError), checkStrict(message, payload, schema?) (TypeScript, ./client, throwing StrictViolation) and helpers.CheckStrict(name, payload) (Go, wrapping helpers.ErrStrictViolation). The client’s strict decoding of a success answer and of an error envelope, and the document readers, call this one check in each language. Python and TypeScript check the published strict JSON Schema and the cross-field rules; TypeScript takes the strict schema of a message it does not bundle as the third argument. Go decodes the proto-JSON, refuses unknown fields, a lowerCamelCase member and a 32-bit number or bool written as a string, and runs protovalidate; helpers.CheckStrictMessage is the same check on a decoded message, which the Go client applies to binary answers. The Python client’s proof_headers stays internal: a harness signs a delivery fetch with the public sign_agent_binding, as Go does with helpers.SignAgentBinding and TypeScript with signInbound.
  • Typed request inputs. Python verbs accept the generated request models as well as dicts, and to_wire(model) renders a model as the JSON object the SDK sends. TypeScript exports the request input types its verbs are typed with. Go verbs already take the generated messages.

A request-signature refusal carries a typed reason (additive wire change). An RPC request whose RFC 9421 HTTP message signature failed verification was refused as Connect unauthenticated with only a message, such as helpers: missing Signature-Input header. The contract tells clients to branch on a typed reason and never on the message, and ErrorDetail had no reason for this failure. It now has one: a new reason oneof member, RequestAuthFailure request_auth_failure = 17, whose RequestAuthFailureReason is one of three values:

  • SIGNATURE_MISSING: no signature, or none that parses. Sign the request.
  • SIGNATURE_INVALID: the signature does not verify, for example a bad signature, a key that cannot be resolved, a content-digest mismatch or a required covered component that is missing.
  • SIGNATURE_STALE: the signature is outside its created/expires window, or was already used. Sign the request again, now.

The values are deliberately coarse: each names what the caller does next, never which validation step failed, so a refusal tells a forger nothing about how far its request got.

The Go server binding attaches the detail. connectserver.WriteReject, which the verify seam of NewExchangeServiceHandler, NewBrokerServiceHandler and NewCatalogServiceHandler answers with, now adds one fora.v1.ErrorDetail to the details of every unauthenticated refusal. The reason comes from the verification error: a missing or malformed Signature-Input or Signature is SIGNATURE_MISSING; an expired signature, one created in the future, or a replay (connectserver.ErrReplayed) is SIGNATURE_STALE; every other error, including one the mapping does not know, is SIGNATURE_INVALID. The detail’s domain is empty, because the writer is not given the request and cannot name the service. The envelope’s code and message are unchanged, and a resource_exhausted refusal still carries no detail.

The detail builders gain helpers.RequestAuthFailureDetail (Go), request_auth_failure_detail (Python) and requestAuthFailureDetail (TypeScript), and the readers (helpers.Reason, reason) return the new enum. The Python and TypeScript SDKs have no RPC refusal writer, so they read this detail but do not emit it. Request signatures and key directories follow the Web Bot Auth profile (SDK and wire change; a hard cut). The Authentication page said FORA was “automatically compatible” with Web Bot Auth and that “no changes to FORA’s authentication are required”. That was not true: a Web Bot Auth verifier refused a FORA signature, and a FORA verifier refused a signature from a Web Bot Auth library. The pages also disagreed with each other: Transaction Flow and the Exchange request flows said each hop covers @method, @authority, @path and Content-Digest, while Authentication required @method, @target-uri, content-digest, authorization and signature-agent.

The target is draft-ietf-webbotauth-httpsig-protocol-00 (1 September 2026), the only document the IETF webbotauth working group has adopted. Authentication specifies the profile in one place, the other pages and the proto comments refer to it, and the three SDKs implement it:

  • Every FORA request signature is a conformant Web Bot Auth signature. Signature-Agent is a structured-field dictionary with one member per signature, <label>="https://<origin>", covered as "signature-agent";key="<label>". The signature covers @target-uri or @authority, and carries created, expires, keyid (the RFC 7638 thumbprint), alg="ed25519", tag="web-bot-auth" and a fresh 64-byte nonce. Its lifetime is at most five minutes.
  • A FORA RPC signature also covers @method, @target-uri, content-digest and authorization. This is FORA policy on top of Web Bot Auth, advertised through Accept-Signature. A bodiless request at the publisher edge needs only the Web Bot Auth base.
  • Signers use a Signature-Agent member key equal to the signature label. Verifiers also accept a member key that differs from the label, a signature without a nonce, a signature without alg (an alg that is present, an empty one included, must be ed25519), and a member with type=directory. The legacy sf-string Signature-Agent is accepted on a request carrying one signature only. Verifiers refuse a signature with no tag, a Signature-Agent member that is not an https origin, and the bare unquoted value the v1.0.8 SDKs sent, and answer a missing component, a refused form or a member that is not an https origin with Accept-Signature listing what they require. Signature-Agent is never empty; an empty Authorization stays valid.
  • Several signatures: each is verified on its own, against the key its keyid names in the directory its own covered member names; a signature that covers several members (because it covers an earlier signature) follows the one keyed to its label. Covering an earlier signature is optional (WG-00 §5.2.2), and a signature that covers "signature";key=X must also cover "signature-input";key=X and every component X lists, and X must appear before it. Labels carry no meaning; the sig1 to sigN requirement is gone. The hop caps count every signature. FORA’s Broker never forwards an agent’s request unchanged, so it never covers an agent’s signature: discovery through the Broker is Broker-led, the Broker choosing the Exchanges and originating its own queries, and execute is re-packaged per Exchange; in both cases the Broker signs alone, covering its own request and only its own member. So a delegation on a query a Broker originated cannot pass holder binding: the Exchange does not honour it and answers as if none were present. How an agent proves a holder-of-key entitlement at discovery time through a Broker is an open protocol question, and an Exchange that requires a verified delegation before it presents a scoped term presents no such term on that query. On a relayed purchase the agent’s identity and consent come from AgentAcceptance, verified against the agent’s registered key resolved from Requester.domain. That key gives the delivery-URL binding, and a delegation’s cnf.jkt is checked against it; a Broker is never delegated to. The Exchange resolves each signer’s key in the directory that signer names, not in a configured key set.
  • The retrieval proof of possession at the publisher edge is a full Web Bot Auth signature plus @method and @target-uri. That covered set is a minimum: a proof that also covers @authority, or a header, verifies. The edge keeps verifying offline with the key in X-FORA-Agent-Key, which it accepts only when its thumbprint equals both the signature keyid and the delivery URL’s agent_id. A generic WBA verifier accepts the same signature by resolving the agent’s directory.
  • Key directories are served over https at /.well-known/http-message-signatures-directory, answer 200 without a redirect, use the media type application/http-message-signatures-directory+json, and carry a response signature per listed key with tag="http-message-signatures-directory", covering "@authority";req and content-digest. A revocation list and the fora.json manifest may still follow up to five redirects. Each key’s JWK alg stays EdDSA, the JOSE name RFC 7517 §4.4 defines. This is a known, deliberate deviation from WG-00 §5.5.1, which restricts that member to HTTP Message Signatures names (ed25519); FORA follows RFC 7517 and Cloudflare’s reference library. It is one of three departures from WG-00, all in the key directory. The response must be signed, where WG-00 only recommends it, and a verifier uses only the listed keys whose response signature verifies, over exactly "@authority";req and content-digest; a response signature may carry no alg. Each key must carry not_before and not_after, and a key without both is never valid. Within what WG-00 permits, a verifier also accepts only Ed25519 keys and ignores a member whose type is not directory.

Authentication includes a complete signed request, a query a Broker originated and signed alone, and a signed directory response, all with real values the SDKs reproduce byte for byte.

SDKs, in all three languages:

  • Signing: helpers.SignRequest / sign_request / signRequest and helpers.AppendSignature / append_signature / appendSignature take the signer’s directory origin and emit the dictionary member, the tag and the profile’s parameter order (created, expires, keyid, alg, nonce, tag). They refuse a missing or non-origin directory (helpers.CheckHTTPSOrigin / check_https_origin / checkHttpsOrigin), a window over five minutes (MaxSignatureLifetime / MAX_SIGNATURE_LIFETIME), an unusable label, and appending to a legacy Signature-Agent. An appended signature covers only its own request and member unless asked to cover the previous one (SignOptions.CoverPrevious, cover_previous, coverPrevious). Go’s SignOptions gains SignatureAgent, Label and CoverPrevious.
  • Verifying: the verifiers apply the rules above, resolve each signature’s key with the directory its member names, and carry that directory on the result. A refusal for a missing component, a refused form or a member that is not an https origin carries the Accept-Signature value (helpers.AcceptSignature / accept_signature / acceptSignature): Go’s connectserver writes it on the 401, and the Python and TypeScript server verdicts carry it. The key resolver receives the directory of the signature it resolves: Go threads it through helpers.SignatureAgentFromContext, while Python’s KeyResolver.resolve and TypeScript’s RequestKeyResolver.resolve take it as a second argument (a breaking change for a custom resolver). helpers.ErrBrokenSignatureChain now means an incomplete or dangling coverage of an earlier signature. helpers.BrokerKeyIDPrefix is deprecated. The TypeScript helpers that served the old chain (COVERED_COMPONENTS, buildRequestSignatureBase, maxSigLabelN, signatureBytesByLabel, parseMultisigSignatureInput and the regex-based proof parsers) are removed.
  • Signing transports: 64-byte nonces; a directory is required (WithSignatureAgent, signature_agent, signatureAgent), and a client without one refuses a signed call locally as malformed. A per-request signer source (core.WithSignerSource / SignerSource) signs each request as the identity a callback picks. Append mode (core.WithAppendSigner, appendOnly) is joined by Python’s append_only, which Python lacked. core.MonotonicWindow / monotonic_window / monotonicWindow used to move created forward by one second per request, stamping signatures in the future above one request per second; it now signs at the clock’s current time, exactly as the clock window, and is deprecated.
  • Delivery proof: helpers.SignAgentBinding / sign_agent_binding / signInbound take the agent’s directory, a nonce and the HTTP method, and emit the Signature-Agent header. Python’s sign_agent_binding now returns an AgentBinding with the four header values instead of a tuple. Go gains the verify face, helpers.VerifyAgentBinding, which refuses with a PoPError (PoPFailure token and PoPAcceptSignature). The three verifiers accept a proof covering at least the profile’s components, parse it with a structured-field parser, and refuse with the same tokens; a refusal the fetcher can fix carries PoPAcceptSignature / POP_ACCEPT_SIGNATURE, and the Hono middleware answers it with 401 and that header.
  • Directories: helpers.SignDirectoryResponse / sign_directory_response / signDirectoryResponse sign a directory response and helpers.VerifyDirectoryResponse / verify_directory_response / verifyDirectoryResponse check one. The WBA key resolvers and the offer-directory fetch refuse redirects, check the media type and hand out only keys that signed the response; ReadWBADirectory / read_wba_directory / readWBADirectory require every listed key to have signed, and refuse application/jwk-set+json. The key resolver’s scheme option, which let a configuration fetch an https origin’s directory in plaintext, is removed from all three SDKs (Go WBAKeyResolverOptions.Scheme, Python WBAKeyResolver(scheme=...), TypeScript WBAKeyResolverOptions.scheme): a directory is always fetched over https. A Content-Digest that does not match the body is an unsigned response in Go too, as in Python and TypeScript, with helpers.ErrDigestMismatch kept in the chain. The directory reader and the offer-directory fetch judge response signatures at an injectable clock: read_wba_directory(now=), ReadDocumentOptions.now and ReadOptions.Now for the reader; create_wba_offer_directory_fetch(now=), createWBAOfferDirectoryFetch({ now }) and WBADirectoryFetcherOptions, an optional trailing argument of Go’s NewWBADirectoryFetcher, for the fetch, so it can share the offer-key cache’s clock. The constants WBATag, DirectoryResponseTag and AcceptSignatureHeader join the wire constants.
  • TypeScript on edge runtimes: the SDK typechecks under @cloudflare/workers-types, and npm test runs that check. @fora-protocol/sdk/resolvers/edge is a new entry for an edge runtime: the resolvers with an injected fetch and no Node import. The existing @fora-protocol/sdk/resolvers entry keeps its guarded Node default. A guard test fails on any undici or node: import, and any Node global, reachable from the edge entry, src/, core/ or hono/, and on any global that code reaches which the Workers types declare and Fastly Compute lacks, unless the code checks for it first. verifyDirectoryResponse, readWBADirectory, createWBAKeyResolver and createWBAOfferDirectoryFetch take the injectable verifyEd25519 primitive verifyAgentBinding already took, so a runtime without WebCrypto Ed25519, such as Fastly Compute, no longer reads every directory key as unsigned. The document read bounds itself on both TypeScript entries, as Go’s and Python’s readers do: it caps a body at 1 MiB, ends the whole read at 30 seconds, and follows redirects itself, at most five, refusing a hop out of http(s), from https down to plaintext http or carrying credentials, and any redirect for a key directory. Those bounds lived only in the Node transports, so a read through an edge runtime’s own fetch was unbounded; on Node, a document read no longer follows a redirect from https to http either. None of the bounds needs AbortController, which Fastly Compute lacks: where the runtime has one, the read also hands the transport a signal it aborts at the deadline, and on any runtime it cancels a body it is still reading and refuses it rather than return it short. A test runs the edge readers with only the globals Fastly Compute declares.
  • TypeScript strict check: the strict schemas of the messages the SDK checks by name are compiled at build time with ajv’s standalone output, into the generated, drift-gated gen/ts/strict/, so the strict readers and the client’s strict decoding generate no code at run time. On Cloudflare Workers, which refuses code built from strings, every strict reader used to fail with an EvalError. A schema passed to checkStrict as its third argument is still compiled at run time. A test runs the edge readers where code generation from strings is refused.
  • TypeScript bundle size: the packages are marked "sideEffects": false and each generated Zod schema is built in a call marked pure (gen/ts/wire/schemas.ts is regenerated), so a bundler keeps only what a program imports. Importing one constant from the edge entry carried about 0.9 MB and now carries a few bytes; a strict reader carries its own message’s validator and Zod schema. A guard test bundles the edge entry and checks it.
  • Shared vectors, regenerated by the Go oracle and replayed by all three SDKs: sign-request-vectors.json, verify-request-neg-vectors.json (now with the expected Accept-Signature), the new verify-request-accept-vectors.json (the forms a verifier must accept), multisig-chain-vectors.json (each signer with its own directory, so a verifier that resolves through the wrong member fails), pop-vectors.json (now with each vector’s expected refusal token and Accept-Signature, and superset proofs), wire-constants-vectors.json, document-check-vectors.json and the new directory-response-vectors.json. The Go suite also checks the draft’s Appendix E.2 Ed25519 vectors against its signature base builder.

The change is a hard cut with no transition window: verifiers refuse the bare unquoted Signature-Agent that v1.0.8 sends and a directory served as application/jwk-set+json or unsigned, so v1.0.8 clients and directories must upgrade. The reference implementation’s directories are still served as application/jwk-set+json and unsigned, and change when it adopts this release.

Request signatures carry an RFC 9421 nonce (SDK fix; no wire change for verifiers). Ed25519 signatures are deterministic, and created and expires are whole seconds. Two identical requests signed in the same second therefore got the same signature, and a verifier with a replay store refused the second one as a replay (connectserver: request replayed within window). For example, two GetAccountStatus calls in one second failed this way.

The signing transports now add a fresh nonce to every signature: Go core.NewSigningTransport, Python SigningTransport.sign_outbound and TypeScript signOutbound / createSigningTransport. The nonce is 16 random bytes from the platform’s cryptographic RNG, encoded as base64url. If random generation fails, nothing is signed or sent. A transport never signs without a nonce: a TypeScript nonce option that returns an empty string is refused. Resending the same signed bytes is still a replay and is still refused.

The signing helpers take an optional nonce: helpers.SignOptions.Nonce (Go), the nonce keyword of sign_request / append_signature (Python), and SignRequestOptions.nonce (TypeScript). Without it they produce the same bytes as before, and they still read no clock and no RNG, so a caller that uses them directly and needs unique signatures must pass its own nonce. A non-empty nonce must use only base64url characters (A-Z a-z 0-9 - _). Otherwise signing fails: Go returns helpers.ErrInvalidNonce, Python raises ValueError and TypeScript throws. Without this check, a quote in the nonce ended the quoted parameter early, and the three SDKs wrote different bytes.

Verifiers need no change. The v1.0.7 verifiers in all three SDKs already accept a signature with a nonce, and a signature without one is still accepted. The window APIs (MonotonicWindow, monotonic_window, monotonicWindow) are unchanged but are no longer needed for uniqueness. The shared test vectors gain nonce cases: post_with_nonce, post_empty_authorization_bound_with_nonce, append_relay_leg_with_nonce and positive_two_hop_nonce.

The Go music type accepts a whole-number music.bpm written as 90.0 (SDK fix; no wire change). music.Music refused {"music.bpm": 90.0} and {"music.bpm": 1e2}, because its BPM field is an *int and encoding/json accepts only integer literals for an int. The profile’s JSON Schema accepts both values: JSON Schema counts a number with no fractional part as an integer. The value also appears in practice. Offer.ext is a google.protobuf.Struct, which stores every number as a double, and some encoders, for example Python’s json_format, write 90 as 90.0. A schema-valid offer therefore failed to decode.

Music now has an UnmarshalJSON method. It decodes every field as before, and it reads music.bpm as a number: a whole number becomes the int, and a fractional value such as 90.5, a string or a value outside the int range is still an error. BPM stays *int, so the Go API does not change. The shared round-trip cases gain 90.0 and 1e2. The TypeScript and Python types do not decode, so they need no change. The Python docstring now says that music.bpm can be a whole-number float after json.loads.

Typed view of the fora-music-v1 extension profile (SDK addition; no wire change). The Go, TypeScript and Python SDKs gain a type for the flat music.<field> keys of Offer.ext that the fora-music-v1 profile defines, for example music.genre:

  • Go: github.com/FORA-Protocol/protocol/sdk/go/profiles/music (music.Music, music.ProfileID)
  • TypeScript: @fora-protocol/sdk/music (Music, ProfileID)
  • Python: fora_sdk.music (Music, PROFILE_ID)

The types keep the profile’s presence rules: an absent key stays absent (an absent music.vocals is never false), and an empty list stays []. They do not validate values; the profile’s JSON Schema, music-v1.schema.json, does. The proto is unchanged: Offer.ext is still a google.protobuf.Struct, and the type is a view over it. The three SDKs share one set of round-trip cases, and the API-surface parity gate now covers the new Go package.

The website gains the fora-music-v1 page: the ten fields, the label rules, who validates what, and the nine music.* search filter keys with their matching rules and error codes.

GetAccountStatusResponse reports the account’s balance (additive field; no behaviour change for Exchanges that leave it empty). An agent could ask an Exchange whether its account exists and is active, but not how much it can spend. The only way to learn that the balance was too low was a refused execute with DENIAL_REASON_INSUFFICIENT_BALANCE. The new field repeated Cost balances = 5 closes that gap.

Each entry is the amount the account can spend now in one currency, with holds for pending transactions already subtracted. The amount is an exact decimal string and is never negative. The value is a snapshot: it does not guarantee that the next execute passes the balance check.

An empty list means the Exchange does not report balances. It does not mean a zero balance. A currency missing from a non-empty list is also not reported, not zero. An Exchange that reports a zero balance sends the entry with amount "0".

Cost is shared with offers and budgets, where an empty amount and a free-form currency stay valid, so Cost itself is unchanged. Four message-level rules on GetAccountStatusResponse state what a balance entry needs:

  • get_account_status_response.balances_requires_billing_ref: balances are only allowed when billing_ref is set, the same as terms_digest.
  • get_account_status_response.balances_entry_complete: every entry has a non-empty amount and a three-letter upper-case ISO 4217 currency.
  • get_account_status_response.balances_no_unit_cost: no entry sets unit_cost. An empty string counts as set.
  • get_account_status_response.balances_currency_unique: at most one entry per currency.

The cross-field corpus gains one case per rule, plus separate cases for an invalid currency and an empty unit_cost. The TypeScript and Python SDKs apply the same four rules. Field number 5 was unused, and buf breaking against the v1.0.0 tag is clean.

An offer sells exactly one licensing term (documentation correction; no wire change; behaviour-affecting for Exchanges). Offer.pricing has said since the licensing core landed that “each projected LicenseTerm yields its own offer” and that a multi-term resource “produces multiple separate offers, one per term — never one offer with a ‘headline’ picked among them.” Offer.terms said the opposite two hundred lines later: “Multiple terms when the resource has different arrangements by use case.” Both sentences shipped in v1.0.0. The second is now corrected to state what the first always required, and to say why the cardinality is load-bearing rather than stylistic.

pricing is the field a Broker ranks on and the field execute charges. A term that does not sit on its own offer therefore has no price a Broker can compare and no offer_id an agent can name, so fusing several terms onto one offer makes every term after the first unsellable — a dual-licensed resource sells only under whichever arrangement the publisher happened to store first. That is the whole reason the rule exists, and it was not written down anywhere the contradiction could be caught.

The bound is now ENFORCED rather than asked for: Offer.terms carries repeated.min_items = 1 and repeated.max_items = 1 — exactly one, not at most one. An offer with no term would be an offer projected from nothing, so the floor is one; a fused offer fails validation at the boundary instead of travelling as a documented-but-unchecked violation. A rule stated only in prose is a rule nothing keeps, and this one went three months contradicting its own neighbour precisely because no gate watched it. The validation corpus gains an Offer/terms/too_many case, replayed by the Go, Python and TypeScript suites, so the ceiling is cross-language verified; the floor announced itself immediately by rejecting the corpus generator’s own baseline Offer, which carried no term.

No field is added, removed, renamed, renumbered or retyped, and buf breaking against the v1.0.0 tag is clean: terms is still field 19, still repeated, still LicenseTerm. It stays repeated because renumbering a released field is not available within v1, so the cardinality rides as a validation rule instead. N terms on a resource project to N offers on that resource, each selling one of them. ResourceEntry.terms remains genuinely plural, bounded at 32 as before: a resource carries many terms, an offer sells one of them, and nothing about ingestion changes.

An Exchange that emits a fused offer will now fail validation where its consumers mount it, which is the intended effect: the tightening is what surfaces the non-conformance rather than leaving it to be read about.

Implementations that emit one fused offer per resource are non-conforming and must project one signed offer per entitled term, each carrying that term’s pricing, in stored order. Where a term is reachable only under an existing subscription its offer carries subscription_id at zero marginal cost; which terms a requester may see at all is decided by LicenseTerm.scopes.

First public release of the FORA Protocol (Federated Open Resource Access): the wire format, the generated Go and TypeScript SDKs, and this specification site. One protocol for discovering, pricing, transacting, delivering, and verifying access to any digital resource by an AI agent. The reasoning behind the major design decisions is recorded in docs/design-history.md.

The v1.0.0 tag was created after the draft below had been revised several times. These entries, newest first, record those revisions. A compatibility note inside an entry compares against the earlier draft, not against an earlier public release.

The SDK serves the account-setup role, in all three languages (no wire change; conformance-affecting). Register and GetAccountStatus land on the agent client — connect.Client in Go, createClient’s face in TypeScript, Client and its blocking twin in Python — under the same names, over the same signing transport, redirect refusal, request-id and validate interceptors and read cap as the other agent verbs. They sit on that client rather than on a fourth constructor because they are the same party holding the same key, and they route as a usage report does: the destination is read off the request’s own exchange, resolved from that Exchange’s own manifest, and dialled on the guarded leg. An account is per-Exchange and which Exchange is the agent’s choice per call — a denial names where to register — so a configured origin would confine registration to one Exchange. Neither message carries an idempotency key, so neither verb takes call options; ver is stamped when empty; a request that names no bare-domain recipient is refused before it is signed. The client-request corpus gains three rows, replayed in all three languages.

A new resolver face reads what one Exchange asks of a registration — the terms revision submitting one accepts, and the schema its registration_data must match — from that Exchange’s own manifest, and it holds NO document cache. The contract requires a registering client to read the terms digest from a freshly fetched manifest, and the endpoint resolver is built out of exactly the mechanism that value may not touch, so a face with no cache slot to reuse is what makes the rule structural. Register fills terms_digest from it only when the caller left the field unset, applies the four registration_data bounds before anything is signed, and pre-checks the payload against the published schema — skipping the check, never the send, when that schema is one the SDK refuses. The schema is measured over the bytes AS SERVED in every language, which the two JSON ports reach by slicing the member out of the served body rather than re-serialising a parsed value.

The reader dials on the SSRF-guarded transport in all three languages, and is built once with the client rather than once per registration: the Exchange domain comes off the request, so it is an address another party chose, which is the provenance that takes the guard. A deployment reaching a private Exchange injects its own transport or sets the usual two environment flags.

Two cross-cutting changes anyone re-pinning will see. The client’s typed failure now carries the peer’s own developer message as a value — filled where the peer’s own answer is decoded, and empty otherwise: never a transport’s synthesized status line, and never a typed detail the SDK built itself, as the content leg does from an edge’s refusal token. So a consumer reads it instead of parsing it back out of a rendered error; it is unbounded, and bounding it belongs to whoever displays it. And Python’s ClientConfig gains sign_window, so the RFC 9421 freshness knob sits at the tier Go and TypeScript already surface it at.

A refusal the pre-check computes carries what it computed. The offending members travel as a typed RegistrationFailure detail beside the sentence, so a consumer that renders the Exchange’s refusal and the client’s own through one renderer reads one shape either way rather than parsing members back out of prose. Its domain names the client’s own tier: the Exchange never saw the request. The field errors are compared across languages by POINTER only — the constraint text beside each comes from a different JSON Schema library in each port and the contract calls it validator-defined — and an empty pointer, which is how a missing required member is reported, now renders without a leading separator instead of as a member with no name.

Two corrections to the content leg travel with it. The delivery edge’s refusal tokens are recorded in the proto beside each RetrievalAuthFailureReason, and the mapping is not derivable — expired is URL_EXPIRED, pop_expired is PROOF_EXPIRED. Both JSON ports had computed each name by uppercasing the token instead, which matches the record for two of the eleven tokens, so nine refusals a real edge emits reached a TypeScript or Python caller with no typed reason at all while any spelling that happened to match an enum suffix was promoted. Both now carry the record. And an invalid-host refusal from an injected requirements reader is classified not_sent in all three rather than retried in two, which is what each port’s own routing leg already answered.

The requirements seam gains a verdict for a document it cannot use (additive, no wire change). resolvers.ErrManifestUnusable / ManifestUnusableError / ManifestUnusable says the document arrived and this reader cannot use it — final, not a transport failure, because the next fetch returns the same bytes. It exists because the reader seam is injectable: the SDK’s own reader refuses three things and treats every other disappointment as absence or as an outage, so an implementation stricter than it — one validating the whole manifest, or refusing a version — held a permanent refusal the seam reported as transient and a caller retried indefinitely. One consequence for anyone re-pinning: this is a fourth verdict on that seam, and a classifier branching only on the older three drops it into its transport-failure bucket and retries something that will never succeed; add the new sentinel alongside. The SDK’s own reader returns it for one thing, a document version it cannot classify; its other two disappointments are unchanged — an off-spec optional member still reads as absent, and an undecodable document is still a transport failure. Python also stops treating an unrelated ValueError as a verdict on both the routing and the account leg: the invalid-host refusal is recognised by its wording now, as TypeScript already did, so an injected seam raising json.JSONDecodeError is retryable there as it is in the other two.

A new shared corpus pins the details the SDK builds itself rather than receives — the two above — over every recorded edge token, including the two that must stay untyped, replayed in all three languages. Two conformance guards sit under it: one holds every committed ErrorDetail.domain to the shape it claims (a Service suffix names a service the contract defines, a bare noun names a tier that is not one), and one holds the corpus to the token annotations in the .proto source. The first found a vector naming a registration service the contract has never defined; that fixture is corrected.

Also in this change: every leg that builds a well-known URL reads one constant. That path is the protocol’s single bootstrap coordinate — a client holding nothing but a hostname fetches it to learn the endpoint and the keys — and it was already exported in all three languages and pinned across them by the wire-constants corpus, but the TypeScript endpoint resolver still spelled it inline, and the registration-requirements reader added here spelled it inline in all three. All four sites read the constant now. Nothing observable changes; what it forecloses is a drift that fails as a misfetch from the right host rather than as an error.

Also in this change, from review: the requirements reader now applies the manifest version gate before it reads any other member, in all three languages — see the entry below, whose scope this widens. That also settles a divergence the three shipped with: a manifest body of bare null was a final refusal in Go and a retryable transport failure in both ports, so the same bytes told one caller to stop and another to keep trying against a third party’s origin. The Python requirements seam is typed with the value it answers rather than Any; TypeScript narrows registration_data instead of asserting its shape, so a string payload is no longer reported as carrying too many members; and the peer_message rule — the field carries a sentence the peer emitted and nothing else — is pinned by two corpora replayed in all three languages instead of by three hand-written suites. ClientConfig.sign_window and the numeric proto-JSON role form gain the Python tests they shipped without.

Parity record: six new mapped symbols and one Go-idiomatic exclusion; the reader’s Go factory folds into the Python class constructor as every other NewX does, so the shrink-only allowlist baseline moves 16 → 17 as a reviewed bump under that one recorded class.

SDK endpoint resolvers enforce WellKnownManifest.ver, and the manifest version gets its own constant (SDK behaviour change; no wire change). The /.well-known/fora.json document’s ver has stated a consumer-side rule since the manifest was unified, and no SDK applied it: the Go, Python and TypeScript endpoint resolvers decoded the endpoint and dropped ver unread, so a manifest at any version, or with no version at all, was accepted. The rule is now stated in full and enforced. A consumer reads ver before any other member of the document, accepts a recognised major version whatever the minor (a minor revision of the manifest is additive, and a reader ignores members it does not know), and refuses an unrecognised major, a value that is not MAJOR.MINOR, or an absent ver (a ver that is not a JSON string is refused as absent, in all three languages) — 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 the endpoint a signed call is then sent to. The refusal is a verdict, surfaced as resolvers.ErrManifestVersionRefused / ManifestVersionRefusedError / ManifestVersionRefused, classified not sent by the client tier like the other resolver verdicts, and never cached. Both manifest-reading faces apply it — the endpoint resolver, and the registration-requirements reader added later in this section, which answers its own seam’s verdict rather than this sentinel. The well-known key resolver is exempt: it reads a JWK Set document that carries no manifest version, and a test in each language pins that a key document carrying a ver still resolves. The value has one owner, helpers.WellKnownManifestVersion (WellKnownManifestVersion in Python and TypeScript), pinned to the wire-constants corpus beside ProtocolVersion and deliberately not derived from it — the two are separate namespaces, and the coupling runs one way: a manifest layout change bumps both numbers, a protocol change that leaves the manifest untouched bumps only ProtocolVersion. A new shared corpus, manifest-version-vectors.json, pins the verdict across the three languages, and a conformance guard holds the proto comment to every clause the corpus exercises. Compatibility: a manifest served without ver was accepted before this change and is refused after it. The field has been required by the wire shape since the unified manifest was introduced, so a conforming producer already stamps it.

TransactionRequest.agent_request_acceptance adds an agent-signed proof of the complete ordered request set (additive wire change). The agent signs the ordered list of (offer signature, exchange) references it committed to, plus the requester and the idempotency key, with the same detached-Ed25519 convention AgentAcceptance uses. When a Broker projects a mixed-Exchange request into per-Exchange subrequests it forwards the envelope unchanged, and each Exchange requires its subrequest to be the complete in-order projection of the signed set addressed to itself before creating or serving request-level idempotency state. That is what stops a relay from consuming an agent’s idempotency key with a removed, appended, reordered, or valid-subset-first request — a property the RFC 9421 forwarding-signature stack does not provide, since a projected subrequest is a new HTTP request its sender authors and signs (see Projected execute requests).

The field is optional for wire compatibility: an older client that omits it keeps per-item execution semantics and gets no request-level claim. The signed item list is capped at 256 entries, the same ceiling a discovery query’s uris list carries, and verifiers bound their own work to that cap before rendering the payload to canonical form.

A term is now checked for permitted/prohibited disjointness a second time, over the canonicalised tokens (restriction.canonical_disjoint; SDK behaviour change plus a comment clarification, no wire change). restriction.permitted_prohibited_disjoint runs at the wire tier, over the request exactly as received, so it compares token SPELLINGS. Ten registered aliases resolve to eight distinct restriction tokens — scrape is a registered alias of crawl, adapt and derivative both mean modify, personal means individual — and every axis also folds ASCII case. A term naming one spelling under permitted and another under prohibited therefore passed the boundary check and became, once the ingest tier folded it, a stored term with the same token in both lists.

Nothing looked at it again, and the failure surfaced elsewhere: the term rides on offers, an Exchange validates its own responses, and so every discovery request returning that resource answered with an internal error while the push had looked clean. The ingest tier now asserts the same property over the canonical values, under its own rule id, in ValidateLicenseTerm and its Python and TypeScript twins. The boundary rule is unchanged.

Disjointness is now the one property both tiers assert, deliberately: they read different values, neither suppresses the other, and a deployment that does not mount the wire tier still gets the second. The Restriction and CatalogService comments say so, and a conformance guard holds the contract’s statement to the rule the SDKs run.

Signed delivery URLs are documented as Ed25519 signed by the Exchange and verified with its published public key, not HMAC-SHA256 over a shared secret (documentation correction; no wire change). Since the initial public snapshot the proto comments and twenty website pages described a symmetric scheme with a secret shared between the Exchange and the CDN. No implementation ever produced one, and the SDK published from this repository has never contained an HMAC.

An implementer following the documentation got four things wrong at once. The signed message is "GET\n" followed by the canonical URL — the whole URL with the sig parameter removed and the remaining query sorted by key — so scheme, host, path and every publisher query parameter are covered, not four selected fields joined by newlines. The signature is base64url with no padding, not a hex digest. The expiry parameter is exp, documented as expires. There is a kid parameter the pages never mentioned, and no txn_id parameter at all; reconciliation joins on the signed-URL hash, which both the Exchange and the delivery endpoint record.

DomainVerificationConfirmation.cdn_type now documents the value set "edge-ed25519" | "cloudfront". The retired values named vendors rather than schemes, which made "fastly" actively wrong — a Fastly deployment runs the Ed25519 verifier. Each value now names the tenant signing scheme it mirrors. signing_key states its custody model: it carries public key material, and the Exchange signs with a private key it holds and never publishes. Akamai is no longer documented as a supported delivery target, because EdgeAuth verifies with a secret shared with the CDN.

Who fetches the content is now written down. An agent embedding the SDK holds its own key and fetches for itself; a custodial agent never fetches, because its key lives in its identity service, which fetches on its behalf. Both present the same key to the delivery endpoint, which is what makes the binding check work either way. The pages said “the agent is the fetcher”, which covered one case.

The signed-URL verification page is rewritten against the shipped verifier, and now documents what it always did but never said: verification fails closed, agent binding is enforced by default, and the delivery endpoint holds public keys only. It also records that the three SDK verifiers order the signature and expiry checks differently, so the refusal reason for an expired URL is a diagnostic and not a contract.

Offer.offer_id is documented as an opaque unique identifier, not a resource key (comment clarification; no wire change). The comment already said the id is assigned by the Exchange, but an implementation historically derived it from the resource, which made two offers for the same resource collide. The comment now states the id is opaque — not derived from the resource, its URL, or any other field — and that two offers for the same resource have different offer_ids. The wire type stays string.

The offer-signature comments now state the implemented scheme: hex-encoded detached Ed25519, not a JWS (documentation correction; no wire change). Since the initial public snapshot, the comments on Offer.signature and Offer.signature_algorithm described a JWS (alg=EdDSA) envelope. No implementation ever produced one: signing has always been hex(Ed25519) over the RFC 8785 JCS canonical form, verification has always hex-decoded, and every conformance vector carries a 128-character hex signature. An implementer who followed the comment instead of the code emitted a compact JWS that failed verification — and the file contradicted itself, because AgentAcceptance already documented the hex convention and named Offer.signature as the single normative definition.

The proto comments, the website pages, and the SDK comments now state the implemented scheme. signature_algorithm is documented as the JOSE/JWA algorithm identifier (RFC 8037), advisory and excluded from the signed bytes. The v1.0 entries below are left as written; they record what that release said. Delegation.token is unaffected — it genuinely is a JWT (base64url-encoded JWS). The attestation signature envelope remains an open decision, and the file header no longer asserts one.

The restriction axis set is closed, and the rule that walks the restriction list against itself is bounded (breaking, pre-1.0). Two changes; the second is what bounds the cost, and the first does not.

Restriction.kind carries defined_only beside its existing not_in: [0], so a number outside the four defined axes is refused rather than ignored. A custom axis was never a new number — it is RESTRICTION_KIND_OTHER, whose meaning rides in permitted/prohibited — and accepting an undefined one admitted a restriction no consumer can evaluate onto a term whose default is BINDING, which fails open on the axis a publisher most needs enforced.

Closing the axis does NOT bound the one-per-kind rule, and an earlier draft of this entry said it did. That rule compares the list against itself, and defined_only does not make the numbers it refuses EQUAL — each stays distinct, so all() still finds no duplicate to stop on. Nor does the cap on the list help: protovalidate collects every violation rather than short-circuiting, so a field rule that fires still leaves the message rules to walk the whole oversized list. Measured, before the fix below: four thousand restrictions cost 17s to refuse from 20 KB of wire, and the reference Exchange reaches that rule before the caller is authenticated.

So the rule now leads with a size test — this.restrictions.size() > 8 || this.restrictions.all(r, …) — and refuses the same input in 26ms. It short-circuits to TRUE, so a list longer than the cap reports repeated.max_items alone, which is its actual fault. A conformance guard holds the threshold equal to that cap, since raising the cap alone would let a list in between skip the duplicate check and pass. The TypeScript and Python cross-field faces evaluate this rule themselves and carry the same test, so all three agree on the silence.

Measured on this contract, with the restrictions cap above: the most EXPENSIVE conformant push that fits under the SDK’s 4 MiB default read cap is 83 entries of 32 terms, each term carrying one restriction per axis with both token lists at their 64-item caps and every token a single character — 3.97 MiB, ~1.8s to validate; one more entry exceeds the cap. The shortest legal tokens are what make it the worst case: validation cost tracks the number of ELEMENTS walked while size tracks their length, so under a byte cap the expensive shape is the one that spends its bytes on count. The same structure with 64-character tokens is 88 MB and never reaches the validator. A full-cardinality REAL batch — 256 entries, 32 terms each, one restriction per axis and every field populated — is 0.81 MiB and 475ms.

An earlier draft of this entry put that figure at 4.15 MiB and 7.4s and called it a ceiling on conformant work. It was neither: the size was measured in MB and labelled MiB (and so read as larger than the cap it fits under), the seconds came from a different token length than the bytes did, and no ceiling follows from the caps — the enumeration behind it omits quotas, obligations, scopes and attestations, and protovalidate checks a non-conformant push as thoroughly as a conformant one.

A cross-field refinement turned off the wire policy for the whole message (TypeScript SDK fix; no wire change). The composed cross-field schemas are the surface a TypeScript consumer is told to parse with, and each one is a Zod refinement wrapped AROUND the generated object rather than the object itself. parseWire drives its policy by INSPECTING the schema it is handed — strip unknown keys, refuse a lowerCamelCase json_name alias, read a null as no value — and a wrapper it could not see through was returned untouched.

So no TypeScript code path applied the wire policy and the cross-field rules to the same payload. A camelCase answer parsed SUCCESSFULLY into a message with every multiword field missing, which is exactly what the alias refusal exists to prevent, and get_account_status_response.terms_digest_requires_billing_ref could not fire on the payload it was written for: terms_digest had already been dropped as an unknown key, and the call reported success. All eight composed schemas behaved this way, and the rule added earlier in this release was the eighth. Python was never affected — its composed model subclasses the generated model and inherits the wire validator.

The policy seam now peels a refinement when it INSPECTS a schema, and still hands the ORIGINAL schema to safeParse, so the refinement itself runs unchanged. The wrapper is read as a method rather than by naming z.ZodEffects, which keeps the file working under both Zod majors: Zod 4 has no such class, and a refinement there keeps the schema’s own type, so there is nothing to peel.

REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED is deprecated and never emitted, and the schema gate now states its account-creation-only scope (comment and enum deprecation; no wire change). The repeat-registration rule added earlier in this release says a repeat SUCCEEDS — it is answered from the stored record and returns the existing billing_ref. Reason 4 still read “identity already registered”, so the enum and the rule answered the same request in opposite ways: one implementation refuses the repeat, another returns the account.

Reason 4 is now marked deprecated, with Register forbidden from emitting it. The number is retained and MUST NOT be reused, and the value MUST NOT be given a new meaning. That last part is deliberate: it reads like a natural home for a future cross-account identity collision, and this contract defines no way for an Exchange to correlate business identity across accounts, so repurposing it later would silently change what it means for every client already built against this text.

AccountRegistration.data_schema had the same gap. It calls itself the single home of the enforce/pass-through contract and stated its gate without exception, so a reader who went there for the whole rule got the wrong one. It now states that the gate runs on account creation only and points at the repeat-registration rule.

The composed cross-field models accepted payloads the Go oracle refuses (Python and TypeScript SDK fix; conformance-affecting, no wire change). Both ports offer two surfaces for the message-CEL rules: a rule-id function that takes proto-JSON, and a COMPOSED model that layers the cross-field rules onto the generated field-level model. The rule-id function was correct in both. The composed surface — the one a consumer is told to use — was not, in two separate ways, and nothing connected the two lists.

GetAccountStatusResponse had a registered rule and no composed model in either port, so a Python or TypeScript consumer accepted a response carrying a terms_digest with no billing_ref: an acceptance digest for an account that does not exist.

Python had a second, wider fault. The composed model handed the rules a dump that renders an enum member as its Python repr, ObligationKind.OBLIGATION_KIND_SHARE_ALIKE, while the rules compare against the wire token, OBLIGATION_KIND_SHARE_ALIKE. The comparison never matched, so every rule that reads an enum silently never fired — obligation.share_alike.requires_scope_license, pricing.free.zero_rate and pricing.per_unit.requires_unit. TypeScript was unaffected.

Both ports now pin the registry to the composed exports by name, and drive the corpus mutants through the composed models themselves. The second test is what found the enum fault: comparing the two lists proves a model EXISTS, never that its validator runs.

A google.protobuf.Value with no kind set is a second payload with no JSON form, and it was accepted (Go SDK fix; conformance-affecting, no wire change). Value holds its payload in a oneof, and a oneof with no member set is well-formed on the wire: the binary decoder accepts it, and proto-JSON refuses to render it with “none of the oneof fields is set”. So it belongs to the same refusal class as a non-finite number — no canonical encoding, therefore no measurable size — and helpers.CheckRegistrationDataStruct did not catch it, because the class had been written as though a non-finite number were its only member.

It fails in the same shape as NaN, and for the same reason it cannot be caught after conversion: AsMap renders an unset kind as an absent value, which is exactly what a real JSON null gives, and null is a value a payload may legitimately carry. Both members of the class are now refused by the raw walk, which already visited every value, and the contract defines the class by what it means rather than by one example: a payload with no JSON representation. A payload carrying a real null is still accepted.

A registration payload carrying NaN or an infinity was accepted by a Go Exchange and refused by a Python one (Go SDK fix; conformance-affecting, no wire change). Struct’s number_value is an IEEE-754 double, so a non-finite number crosses the wire intact — structpb.NewNumberValue does not refuse one and the binary codec carries it unchanged. JSON can write none of the three, so such a payload has no canonical form and no measurable size, which is what uncanonicalizable names. Go could not see it. The SDK’s own doc comment named RegisterRequest.GetRegistrationData().AsMap() as the call site, and Go’s protobuf runtime renders the three values as the STRINGS "NaN", "Infinity" and "-Infinity" during that call, so the check received a well-formed string and answered accepted. Python and TypeScript decode into objects that keep the real float and refused the same payload. Two conformant Exchanges therefore answered the same signed request differently, and a Go Exchange stored the text "NaN" where the caller had sent a number.

The fix is a new Go entry point, helpers.CheckRegistrationDataStruct, which reads the raw *structpb.Struct. It cannot be a repair of the map-based check, and that is worth stating because it looks like one: after AsMap has run, a non-finite number and an operator legally named NaN are the same three bytes, so a check that refused the text would refuse a valid registration. The conversion destroys the evidence, so the check has to precede it. CheckRegistrationData still exists for a caller whose payload never was a Struct, and its comment now says what it cannot see. Python and TypeScript get no new face: their existing checks already see the case, so a second entry point there would be an alias with nothing to do — it is recorded as a Go-only divergence in the parity matrix instead. No shared corpus can carry the case in any language, because JSON cannot write the value down.

The order of the registration gates is pinned, and two of the orderings were previously free choice (comment-only; conformance-affecting). A payload is now checked in a stated sequence: top-level member count, nesting depth, canonicalizability, canonical byte size, then terms_digest, then the published data_schema.

Two of those were unstated. Canonicalizability precedes the byte cap because the cap is DEFINED as the length of the RFC 8785 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 asserts a measurement that was never taken. The terms gate precedes the schema gate because the schema may itself have changed in the revision the caller has not read. 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 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 earning both is given the one that has to be done first.

A repeat Register is answered from the stored record and runs none of the account-creation gates (comment-only; conformance-affecting). Two rules were each stated absolutely and neither mentioned the other: Register returns the same billing_ref for the same agent, idempotent by design; and a terms_digest that differs from the published one is refused as stale, in a paragraph that calls its four cases “all defined”. For a returning agent after the operator revises its terms the two give opposite answers, and an implementer reading the second paragraph alone will refuse the caller and believe they are conformant — which breaks every returning agent on the day the terms change, because the account it already holds becomes unreachable through the RPC that exists to return it. The rule is now written where the idempotency promise is made, together with the reason: a repeat discards registration_data entirely, so checking a member about to be thrown away reports an error about a value that has no effect. What a repeat still runs is stated too — signature verification and caller identity, the recipient check on RegisterRequest.exchange, and the field-level constraints — so “no gates” is not read as “no checks”.

GetAccountStatusResponse.terms_digest (field 4) makes the accepted terms readable. The protocol already required an Exchange to record the accepted digest with the account, and said plainly that this is what makes “which terms did this operator accept” answerable later. Nothing could ask. No RPC and no field returned the value, so the only party who could check what an operator had agreed to was the party holding the database — and the MUST NOT beside it, that an Exchange publishing no digest must ignore a presented one and must not record it as an acceptance, could not be tested through a public surface at all. Absence of the new field has exactly one meaning: no acceptance is recorded, either because the Exchange publishes no digest or because the account predates one. An Exchange holding a digest MUST return it — 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; comparing it against a freshly fetched WellKnownManifest.terms_digest is how an agent discovers the terms moved under an account it already holds, which a repeat Register will no longer tell it. A message rule (get_account_status_response.terms_digest_requires_billing_ref) joins the digest to the account handle it hangs on, so a reader can never take an acceptance from a response that carries no account.

The catalog lists are bounded, and the bound names the quantity it controls (breaking, pre-1.0). LicenseTerm.quotas and .obligations carry at most 64 items each — the bound every per-message list in this contract carries when no rule walks it more than once — and PushResourcesRequest.entries and RemoveResourcesRequest.paths at most 256, the bound a caller-chosen batch carries at ResourceQuery.uris. Every committed feed is under ten entries, so the batch cap sits far above real traffic and a larger feed is pushed in several submissions.

LicenseTerm.restrictions carries at most 8, and like the others this bounds the DOCUMENT rather than the rule — an earlier draft claimed otherwise. Only one restriction per axis is valid and Restriction.kind is now defined-only, so four is the longest conformant list and eight leaves room for an axis this version does not have. The largest downstream feed carries three.

The tighter bound still earns its place, for a different reason: this is the one list a message rule walks against ITSELF, so the number is also the threshold of the size test that 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.

What these caps bound is the DOCUMENT: how large one entry may be, what a push can store, and how much a rejection has to name back. They do NOT bound the work of checking a push, and the ResourceEntry comment no longer says they do. A validator walks every element it is handed and reports every violation before any cardinality rule is applied — measured on this contract, an entry with 100,000 terms costs 393ms and allocates 100,001 violations even though terms was already capped at 32. A bound belongs on the phase whose cost it models, so the work is bounded one layer down, by the maximum request size the recipient will read.

CATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDED is recorded as retired on the PushResources path. An over-cap entry always refused the whole submission — a catalog push is all-or-nothing — so moving the cap onto the wire changed WHEN it is refused, not what survives: the refusal now happens at the boundary, before any per-entry classification runs, and no rejection naming that reason can be produced for a push. The value stays for a deployment that applies the cap somewhere the wire rules do not reach.

Tooling: the corpus grows from 602 to 607 cases — one too_many mutant per newly bounded field; LicenseTerm goes from 4 to 7, PushResourcesRequest from 20 to 21 and RemoveResourcesRequest from 28 to 29. The entries mutant carries 257 full ResourceEntry instances and is the reason cases.json roughly doubles; that cost is accepted rather than paid for with a smaller cap. The catalog-path pattern gains the descriptor-derived membership guard the domain and digest patterns already have (wantResourcePathFields = 2) — it was the one shared pattern without one, and the generator keys its killer table by the pattern string, so a drift would have silently emitted no mutants at all.

The SDK’s server binding bounds what a handler reads (no wire change; conformance-affecting). connectserver sets a per-request read cap on all three handler bindings, defaulting to 4 MiB and overridable with WithMaxRequestBytes. It bounds two quantities, because a caller can exhaust a server through either: the decompressed Connect message, refused as resource_exhausted, and the raw HTTP body the verify face must buffer whole to check an RFC 9421 signature over the exact bytes — which it does before it knows who the caller is, so an unauthenticated caller reaches that one. The body bound is composed inside request-id and outside verify, so a refusal still carries its X-Request-ID, and a body past the cap is now classified resource_exhausted rather than told its credentials were wrong. Measured: a full-cardinality push — 256 entries each carrying the full 32 terms, one restriction per axis, every field populated — is 0.81 MiB and validates in ~475ms, while the most expensive conformant shape that still fits under the cap is 83 entries with both token lists at their caps and single-character tokens, 3.97 MiB and ~1.8s. That is what the default buys: not a small worst case, but a bounded one. It is a measurement of one shape rather than a ceiling over all of them — raising the cap raises the worst case roughly linearly, with no value past which it stops mattering.

The decompressed bound is not a bound on decompression WORK. Connect drains the remainder of an over-cap stream to size the error it returns, so the body is fully inflated before it is refused; what keeps that finite is the raw-body bound, and at 4 MiB of compressed input it is 4.32 GB inflated in ~850ms of CPU on a request that is then rejected. The byte figures size a representative batch, not a ceiling on a conformant one: the contract’s caps bound how many entries and terms a push carries, never how many bytes, so a conformant push can exceed this cap and be refused.

The server binding’s refusal answer is exported, so a consumer stops re-deriving it (SDK only; no wire change). connectserver now exports RejectCode, IsBodyTooLarge and WriteReject — the classification, the over-cap predicate and the writer that pairs the Connect error body with its HTTP status. They were unexported, so a third-party Exchange, or any mount composed by hand rather than through NewCatalogServiceHandler, had to write its own. That copy does not stay level: Connect maps ResourceExhausted to 429 for every cause, and this binding answers 413 for a body past the read cap because that is the one refusal a caller fixes by sending less. A re-derivation lands on the specification’s answer and diverges silently. WriteReject takes the Connect code as a parameter rather than deriving it, so a gate carrying its own resource-limit sentinel answers that case itself and defers every other to RejectCode — a delegation, not a fork. The over-cap arm is now gated on the code as well as the error, so a rejection classified as something other than a resource limit cannot be answered 413 over a body that names a different verdict; on the path that existed before, that guard is always true, so no response changed.

The contract states that a push is all-or-nothing, and the prose follows it (docs). CatalogService described both validation tiers in full — folding, alias resolution, which checks reject and which warn — and never said whether a rejection costs the entry or the submission. Readers therefore inferred it from an implementation, and inferred wrong. The rule is now in the contract: a hard rejection at either tier refuses the entire submission and persists nothing, and the per-entry detail a refusal carries is reporting, never partial acceptance. CatalogRejection.rejected_paths says which entries a refusal is about rather than calling itself a partial-batch failure, and the JSONL ingestion page states the rule once, for both tiers. The publisher-onboarding and verification-vendor pages had told a reader to build the catalog client against the Exchange’s advertised catalog_endpoint; the address is configuration, and a deployment that does read it from a manifest MUST itself check the host binding that field states — no SDK reads the field, so nothing else will. The one TypeScript SDK import sample on the site named a package that does not export those symbols.

ResourceEntry carries envelope rules, and the catalog request lists are bounded (breaking, pre-1.0). The terms inside an entry were guarded by the LicenseTerm rules; the envelope around them was not, so an entry with an empty domain, a path without a leading slash, or a domain carrying a scheme or a path reached the Exchange and was refused — or quietly synthesised into a wrong catalog URI — only after ingestion had started. domain now carries the shared bare-host rule every addressed exchange field carries (a port is allowed; a scheme, path, query or userinfo is not; 260 characters); path is an absolute URL path (^/[^?#\x00-\x20\x7f]*$, 1–2048 characters); title (512), content_id and content_hash (255), hash_method (64) and provenance_source (260) are length-bounded — every one of these counts CHARACTERS (Unicode code points), which is what protovalidate’s max_len counts, so a conformant value can exceed its character count in bytes; word_count and estimated_quantity are non-negative; attestations carries at most 64 entries and terms at most 32 — the cap CATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDED named, stated on the wire so every implementation refuses the same size. content_hash is deliberately not format-checked: a bare hex digest and a method:hexdigest form both travel today, and hash_method names the algorithm. PushResourcesRequest.entries and RemoveResourcesRequest.paths require at least one item, and each removed path carries the same absolute-path shape. Adding a rule changes no signed bytes — a protovalidate rule is a field option, not a field — and buf breaking cannot see it. Every committed fixture and the reference e2e catalog (ports on domain, single-label hosts, bare-hex hashes) passes the new rules; what changes is that a malformed push is refused at the boundary rather than after ingestion, and a path without a leading slash — accepted before, and synthesised into a URI that named the wrong resource — is now refused.

WellKnownManifest.catalog_endpoint states the same host binding as endpoint: on the host and port that serve the manifest or a subdomain of it, no userinfo, and a consumer refuses anything else and does not fall back to endpoint when the field is absent. A publisher’s push is a signed call to that address; without the rule a manifest could redirect it to a host the signature never covered. CatalogService and ResourceEntry also document the two validation tiers a push passes — the wire rules, then canonicalisation and registry membership over the terms — so a publisher can run both before sending.

Tooling: the corpus grows from 549 to 602 cases; ResourceEntry goes from 3 to 48 and RemoveResourcesRequest from 20 to 28. The generator’s sample list gains "/x" (append-only, so no existing field re-values) and the path pattern gains its own killer table — no leading slash, ?, #, whitespace, a control byte. The guard counting the shared domain rule’s fields moves from 17 to 18, and the manifest endpoint-comment guard now reads catalog_endpoint too.

Restriction-token aliases are authored in the proto and generated into every SDK (additive, no wire break). The licensing core’s vocabulary table always recorded that train-ai is AIPREF’s spelling of ai-train, generative-ai the industry’s spelling of ai-input, scrape of crawl, tdm of text-and-data-mining, copy of reproduce, and adapt and derivative of modify; the reference Exchange resolved them from a private map, and the user-type aliases (personal → individual, business and enterprise → commercial_entity) existed only in that code. They are now (fora.v1.vocab_enum_alias) entries beside the tokens they resolve to, in the form alias=canonical, and protoc-gen-foravocab emits an Aliases map and a Canonical lookup (canonical in TypeScript and Python) per axis in all three languages — an axis without aliases carries an empty map so every axis has the same face. Codegen refuses an alias that is itself a token, a canonical that is not one, a duplicate, and a spelling that is not already trimmed and lowercase: the SDK folds a token before it looks it up, so any other spelling could never match. The generated lookup does no folding of its own. The docs’ vocabulary tables render the aliases from the same descriptor option.

Tooling: the three generated alias maps are held to one answer per axis, and to the registry, the way the token sets already are.

The SDK serves the publisher role, in all three languages (no wire change; conformance-affecting). Three additions, one per gap. A catalog client — connect.NewCatalogClient in Go, createCatalogClient in TypeScript, CatalogClient (async, with a blocking twin under fora_sdk.sync) in Python — issues PushResources, RemoveResources and RefreshCatalog under the same names in each language, over the same signing transport, redirect refusal, request-id and validate interceptors and read cap as the agent verbs. It is a separate constructor because CatalogService is a separate address (WellKnownManifest.catalog_endpoint) and its caller holds a contributor key named by caller_id; the publisher chose the Exchange, so the leg runs on the plain transport. The messages carry no idempotency key and the client mints none; ver is stamped when empty; a request that names no bare-domain recipient is refused before it is signed. The client-request corpus gains the three verbs, replayed in all three languages.

The ingest-tier license-term checks moved out of the reference Exchange into the L1 helpers — NormalizeLicenseTerm, NormalizeResourceEntry, ValidateLicenseTerm, ValidateResourceEntry, CanonicalRestrictionToken, KnownRestrictionToken in Go, the same faces in snake and camel case in Python and TypeScript — so a publisher runs the checks the Exchange will run before sending: RFC 8259 trim, ASCII-only case fold, alias resolution through the generated vocabulary, a hard reject for a bare unregistered Pricing.unit or Quota.metric, a warning for an unregistered restriction token or an OBLIGATION_KIND_OTHER obligation without detail, and a per-entry verdict composing the wire tier and the ingest tier in the Exchange’s order. Warning messages are the exact wire strings. A new Go-emitted corpus (licenseterm-vectors.json: fold, normalize, known, validate, entry) is replayed by both ports, and a conformance guard holds the SDK rule ids to the descriptor’s CEL-id namespace without collisions.

connectserver.NewCatalogServiceHandler composes the same request-id · verify · validate · error-detail stack over the generated CatalogService handler, so an Exchange implementer has a server-side starting point for the publisher-facing RPCs; contributor authorisation, tenant binding and per-entry verdicts stay the handler’s job. The validate step is opt-in on all three bindings and always has been — ValidationOff is the enum’s zero value — so a deployment that wants the contract’s boundary rules passes WithValidation(ValidationStrict) on the mount. The handler docs now say so rather than leaving it to be inferred from a stack diagram, and a handler asked for strict validation that cannot build the validator now fails at construction instead of serving without it.

Parity record: thirteen L1 exports and the catalog client are mapped at three-language parity (130 symbols, 34 corpora). Two documented divergences join the record — the catalog client’s Go factory folds into the Python constructor as every other NewX does, and the Catalog handler binding is a third symbol under the recorded full-Connect-handler decision — so the shrink-only allowlist baseline moves 14 → 16 as a reviewed bump, and the gate now names that one sanctioned growth shape.

The TypeScript and Python SDKs gained a client, and it changed what they accept from a peer (no wire change; conformance-affecting). Neither could SEND a FORA request before; both now speak the Connect-unary JSON form the protocol’s unary RPCs are fully described by. Seven consequences reach anyone re-pinning, and none of them moves a field, a message or an encoding — buf breaking reports nothing.

Every signed request now carries authorization and signature-agent, empty values included. The RFC 9421 covered set binds both unconditionally — that is what stops a later injection piggy-backing an existing signature — and a verifier rebuilds the base from the request it RECEIVED, so a value bound but never sent is not bound at all. Both ports bound them and attached neither, so every signed RPC was refused with header "authorization" missing from request while all three languages agreed byte-for-byte on the signature itself. Re-pinning changes the header set your clients put on the wire; nothing about the bytes they sign moves.

Three further consequences of the same rule. The TypeScript signer emits the covered header names lowercase, so a caller supplying its own Authorization has it replaced rather than duplicated — two field lines under one covered name are joined with ", " before the base is rebuilt, which breaks an otherwise valid signature. And every server-verify face in both ports now refuses a request that OMITS a covered header while still accepting one that carries it empty: defaulting an absent header to empty invents a value the signer may never have bound, and accepted exactly the request the Go verifier refuses.

Those faces also read every header the way the wire defines it, which changes what they accept. Names are matched case-insensitively, so a header bag spelling them Authorization / Signature-Agent now verifies where it used to be refused. Repeated spellings of one name are joined with ", " rather than one being picked, so an unsigned Authorization: Bearer … placed beside a signed empty one changes the covered value and is refused — previously it was read past and the request was accepted, on every face. If you built a header mapping for these faces by lowercasing keys, nothing changes for you; if you passed one straight from a framework, it now behaves as the oracle does.

The Go entitlement-coverage check reads every field line too, and this one is a behaviour change in sdk/go/helpers. It resolved X-Entitlement-Token with Header.Get — the first line only — at both the sign site (whether the covered set commits to the header) and the verify site (whether an unsigned token is being slipped in). A request that simply sends the header twice, an empty line ahead of a real capability token, made both answer "": the signer left the header uncovered and the verifier skipped the rule, so the token rode in under a signature that never committed to it. Ordinary HTTP, no unusual client. Both sites now join every line, so any second line makes the value non-empty. Nothing in a single-line request moves — no API changes, both functions are unexported — but a peer that sends the header twice is now refused where it was accepted.

A null means the field has no value, and the TypeScript wire policy now reads it that way. The canonical wire is proto-JSON, where a null is a field’s default — for any field, not only a message-typed one — so the policy drops it wherever the schema does not require a value, and leaves it for the schema to refuse where it does. EmitUnpopulated — what connectserver’s codec emits, and therefore what a FORA Exchange serves — renders an unpopulated non-optional field as null rather than omitting it, so {"ext":null} is the ordinary shape of a real response. (An unset map renders {}, and an unset optional field is omitted outright.) Every generated Zod schema rejected null, which meant a TypeScript consumer could not read a conformant answer at all. Pydantic already spelled those fields X | None.

An earlier form of this note said only a message field and a Struct arrive as null. That described what the codec emits and was read as a bound on what a client must accept, which it is not: an unset google.protobuf.Timestamp arrives as null too, and because the type generator flattens it to a string schema, a rule written for message-typed fields refused it. An attestation without an attested_at, or a rate limit without a reset_at — both conformant, neither carrying a validation rule that requires the field — took the whole answer down for a TypeScript reader while Go and Python read it. The rule now asks about presence rather than type, and a shared corpus pins it in all three languages.

Where that policy lives matters to a consumer: the schemas themselves are unchanged, and parseWire() in wire/base.ts is what applies it — together with the naming refusal below. @fora-protocol/sdk now exports ./wire/base and ./wire/names so a consumer of the generated types can reach it; parse an answer off the wire with parseWire(Schema, body) rather than Schema.safeParse(body).

A lowerCamelCase answer is refused, at every depth, with the reason not_canonical_wire_naming. The FORA wire is snake_case proto-JSON and the json_name alias is out of contract; a stock connect-go server that registers no UseProtoNames codec serves the alias, and the generated clients accept snake_case only and drop what they do not recognise — so such an answer parsed successfully into a message with every multiword field missing. A deployment must register the codec on every JSON-serving listener; the reference services already do.

Offer.title, ResourceEntry.title and UsageAsset.title exist in the generated Pydantic and Zod models for the first time. They are proto fields whose NAME is title, and the types pipeline removed every key of that name while meaning to drop the JSON-Schema keyword.

mimeTypeOf narrowed in Go: text/plain; ; now reduces to text/plain instead of the default, and a bare token carrying no slash now reduces to the default instead of passing through. The rule is stated rather than delegated — the text before the first ;, trimmed and lowercased, must be token "/" token — so all three languages answer alike. The function is unexported with one call site, so no Go caller depended on the old behaviour.

A hex signature carrying a sign, whitespace or an odd length is refused in TypeScript, as Go and Python already refused it. And Python’s edge refusal-token anchor no longer admits a trailing newline, which Go and TypeScript never did — the token is echoed into a caller’s logs.

Nine TypeScript modules the root export map could not reach are importable, and the root manifest now carries undici and the peer metadata sdk/ts declares. exports carries no wildcard, so an unlisted subpath failed with ERR_PACKAGE_PATH_NOT_EXPORTED and had no deep-path workaround; ./resolvers was listed and still unimportable for want of the dependency.

Five shared vector files are new against the previous revision, all replayed by all three SDKs: connect/testdata/{connect-error,client-request,transport-failure}-vectors.json, helpers/testdata/wire-names-vectors.json and resolvers/testdata/content-fetch-vectors.json. The transport-failure set records what class an answer that did NOT come from the service falls into, captured from a real connect-go client rather than transcribed; the wire-names set pins the two textual rules above. The null and naming rules are pinned beside the generated types instead, in gen/{ts,python} — they belong to the schema seam every message routes through, not to one tier’s corpus. The endpoint rule’s existing corpus now replays through the CLIENT as well as the resolver in all three languages, which is what holds the re-check an injected resolver’s answer gets. Per-language surface: docs/sdk-parity-matrix.md.

The endpoint rule is enforced in all three SDKs, and it now refuses a credential it used to accept (no wire change; conformance-affecting). WellKnownManifest.endpoint has stated its rule as a MUST since the previous revision — the advertised endpoint must be on the host and port that served the manifest, or a subdomain of that host on that port, and must not carry userinfo — but only the Go SDK enforced it. Python and TypeScript shipped endpoint resolvers that returned whatever the manifest said, and neither language exported the predicate the rule is built from; each carried a private near-namesake in its WBA module that answered a slightly different question. Both resolvers now vet the advertised endpoint before returning or caching it, both refuse a host argument that is not a plain hostname before building the fetch URL from it, and hostOf/isBareHost/hostAnchored (host_of/is_bare_host/host_anchored) are public in both. The private copies collapsed into the shared predicate, so the WBA revocation poll compares its candidate by the same rule as the endpoint. The value it anchors AGAINST is still derived by each platform’s own URL parser, so a directory that spells out a default port is read differently in TypeScript than in Go and Python; aligning that is the port of the fetchable-directory policy, tracked separately.

The refusal itself also got one shape wider, in all three. It was decided over a plain URL parse of the advertised value while the anchoring half re-read the same string through its own parse, and the two disagreed on exactly the shape the refusal exists to stop: u:p@exchange.example names no scheme, so a plain parse takes u for one and reports no userinfo, while the anchor check reads the value as https and matches the host. An endpoint carrying credentials without a scheme was therefore accepted; it is refused now. Both halves read the reference once, the same way.

Two consequences for anyone re-pinning. Resolution can fail with a new verdict — EndpointRefusedError in Python, EndpointRefused in TypeScript, joining Go’s ErrEndpointRefused — which is FINAL, not something to retry; and a deployment whose manifest advertises an endpoint on an unrelated host, on another port, or with credentials in the URL will now be refused by a Python or TypeScript consumer that previously accepted it. buf breaking reports nothing: no field, message or encoding moves. The rule is corpus-locked to two new shared vector files (helpers/testdata/host-rule-vectors.json, resolvers/testdata/endpoint-vet-vectors.json) that all three SDKs replay. What that buys is narrower than “the three now agree about everything”, and worth stating precisely: a divergence on a rule the corpus covers fails CI instead of shipping, and the corpus covers the rule’s boundaries rather than every string a caller can construct. Per-language surface: docs/sdk-parity-matrix.md.

One consequence for Go consumers who set a GODEBUG. net/url’s host-colon strictness is the urlstrictcolons setting, and a GODEBUG belongs to whoever builds the program: under urlstrictcolons=0, from the environment or a //go:debug line, helpers.IsBareHost was reading exchange.example::443, exchange.example:44:3 and five near relatives through a parser that accepts them, while the vectors this repo publishes record every one of them refused. GODEBUG exists so an operator can back out of a behaviour change, so one set for an unrelated URL reason would quietly loosen a predicate standing in front of a signed call. The second colon is refused by the predicate itself now. A consumer running the default posture sees no change, and no committed vector moves.

The registration schema’s rules become checkable, and two of them were wrong (comment-only, no wire change). The previous revision stated a rule set for AccountRegistration.data_schema and shipped an SDK to enforce it. Reviewing what shipped found the central promise — that two conformant validators agree about which payloads a published schema accepts — was false, and that the resource caps bounded the wrong thing. Both are corrected here, and the shared corpus gains the dimension that would have caught them.

pattern needed two mechanisms, not one. Draft 2020-12 patterns are ECMA-262 and the engines implementations run disagree about them in two different ways. Some constructs one engine cannot express at all, and those are refused — that much the previous revision had. The rest every engine compiles and then reads DIFFERENTLY, and refusing those would have gutted the feature, because they are $, \d and \w, which appear in almost every real pattern. ^[A-Z]{2}[0-9]+$ — the example this contract itself gives — accepted "DE12345\n" under one implementation and refused it under two, with nothing logged. So an implementation whose engine differs is expected to correct it: match ASCII character classes, and anchor $ at the end of the text and nowhere else. \s and \B are refused instead, because for those there is no single meaning to correct TO — RE2 reads \s as [\t\n\f\r ], Python adds the vertical tab, and ECMA-262 adds that plus every Unicode space separator, while \B finds no word boundary in the empty string for two engines and finds one for the third. An explicit character class says what was meant.

The alphabet is now stated as what a pattern MAY contain. The previous revision enumerated the divergent escapes, and that list was wrong in both directions and could not be finished: the set of escapes three engines disagree about grows with every dialect and library version, so it needed a new entry each time somebody found one, and \B, \cA, \a, \012, \x{41}, \uHHHH and the identity escapes were all admitted until somebody did. The portable set is small and closed — the shorthand classes, the control characters, \xHH, and the metacharacters that stand for themselves — and it was derived by running every ASCII escape through all three engines in three positions rather than by reasoning about them. An author who wants anything else writes the characters out.

Four more shapes join the refused list, each of which two engines read differently without erroring: a POSIX name anywhere inside a bracket expression (not merely at its start, which is all the previous rule checked, so ^[a[:alpha:]]+$ slipped through and produced three different answers); a counted repeat over 1000, which RE2 refuses and the others expand; a bracket expression that opens with ] or never closes; and a range whose endpoint is a shorthand class ([\w-x]), which RE2 reads as a range while the other two refuse it outright.

Where a pattern may appear is now stated. pattern carries its regex as a value and patternProperties carries its regexes as KEYS. Both are patterns, both are held to the alphabet, and they are the only two keywords in the dialect that carry one. Saying so matters because it is exactly what an implementation gets wrong: correcting the pattern keyword alone leaves patternProperties uncorrected, and a property name that is a non-ASCII digit then matches ^\d+$ for one implementation and not the other two — the same silent split, on the keyword the rules already single out for pattern SAFETY.

Nested quantifiers are refused outright. (a+)+, (a|a)* and ([a-z]+)* are the catastrophic-backtracking forms, they need neither lookaround nor backreferences, and the previous revision’s claim that excluding those two “falls out of the same rule” was simply wrong — every one of them was admitted. It has to be a PUBLISHING rule rather than a runtime bound, because a regex spin holds its interpreter: a consumer cannot reliably interrupt one it has already started.

The caps bounded the document, not the work. 16KB and 32 containers say nothing about how expensive checking a payload is. Branches multiply along a reference chain, so a 1,675-byte schema five containers deep — a tenth of the size cap, a sixth of the depth cap — cost 16.7 million evaluations and twenty-seven seconds against a two-member payload. A new bound of 10000 evaluations, counted statically before anything runs, is the missing one; it is about fifteen milliseconds of work, several hundred times what a schema describing a business entity needs. It bounds the SCHEMA: it is the cost of applying the schema at one location in a payload, so a subschema under items is counted once here and evaluated once per element at runtime.

Reference cycles are refused. A $ref chain that returns to a schema already on it is legal JSON Schema and has no static cost bound; it is also what made validators recurse until they aborted, out of an API documented as returning a verdict rather than throwing. {"$ref":"#"} is twelve bytes. The same walk that counts evaluations follows every reference to its target, so it decides this and the resolvability of a same-document reference at the same time — three questions the libraries had been answering three different ways.

The encoding is pinned, because it decides which document the rules are read against. The bytes MUST be well-formed UTF-8 and MUST NOT begin with a byte order mark. RFC 8259 forbids adding a mark and permits a parser to ignore one, so both policies conform and the contract makes the choice once: a parser that strips a mark validates a different document than the one served, and counts three bytes against the size cap that the schema does not contain. Ill-formed bytes MUST NOT be repaired either — one implementation’s parser silently substituted U+FFFD and enforced a pattern with a different character inside it, while the other two refused the same bytes. The JavaScript-only literals NaN and Infinity are not JSON (RFC 8259 §6) and are refused with them; one implementation’s parser accepts all three as an extension.

The payload’s nesting is bounded too, at 32 containers — because without it the answer depended on who was reading. A deeply nested payload is small and has few top-level members, so neither the byte cap nor the member cap saw it, and canonicalising one walks it recursively. Where that walk runs out of stack is a property of the runtime, not 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. Two deployments of the same SDK on different runtimes therefore disagreed about the same registration. The bound is the same number and the same counting rule as the schema’s own depth cap, it is checked before anything walks the payload, and the walk that checks it is iterative — a recursive check would hit the very limit it exists to keep a caller away from.

A reference chain is bounded on its own axis: 100 hops. A chain of definitions each referring to the next is three JSON containers deep however long it is, so the depth cap never saw it, and it costs one evaluation per link, so the work cap did not either. Both caps passed a five-hundred-link chain and every SDK called it valid — and then the recursion each validator performs while resolving that chain exhausted one implementation’s stack outright, raising out of a face documented as returning a verdict. A third bound is what stops the document being published, rather than asking three libraries to survive it. A schema describing a business entity chains one or two references; the deepest chain in a conformance vector that is accepted is eleven. The refusal is its own verdict, ref_chain_too_long, because it is its own rule — a cycle still reports ref_cycle, which is the more specific answer.

Two more brace rules, found the way the bracket rules were. A counted repeat MUST state its first bound, and a } outside a bracket expression MUST close one. a{,5} is five literal characters to RE2 and a repeat of zero to five to Python, so both engines compile it and then disagree about which payloads match, with nothing logged — the silent kind. An unmatched } is a literal to RE2 and Python and a syntax error to ECMA-262 under the u flag, which is the loud kind and exactly what the unmatched ] rule already refuses. A literal brace is written \}, which the alphabet admits. {n,} is unaffected.

Tooling: three corrections in the SDKs behind those rules, none of them contract changes. The dot’s line-terminator set is now corrected in the TypeScript port rather than left to diverge — RE2 and Python exclude only \n where ECMA-262 excludes all four terminators, so ^.$ against a carriage return conformed in two SDKs and violated in the third; that port gains the same kind of source-level correction the Python $ rewrite already uses, which reaches every place its validator compiles a regex. A multi-part repeat like a{1,2,3} was admitted by the TypeScript scanner alone, because String.split’s second argument caps the result and discards the remainder where Go’s SplitN and Python’s maxsplit keep it. And the Python compile face no longer runs out of interpreter stack on a long flat $ref chain: a chain is three containers deep however long it is, so the depth cap never saw it, and the walk that follows references is now iterative. The payload face answers with a verdict for an oversized integer and a deeply nested payload instead of raising, out of an API documented as never raising.

The payload is bounded too, and the bound names its unit. A published schema is applied to RegisterRequest.registration_data, and the cost of that is roughly the schema’s own cost multiplied by the elements in the payload — a subschema under items is counted once by the evaluation cap and evaluated once per element. The multiplier was unbounded. It now is: at most 64 members at the top level, and at most 16384 bytes, measured as the payload’s RFC 8785 (JCS) canonical JSON encoding.

The unit is the load-bearing half. Every other cap in this contract is over bytes a party actually served; registration_data is never served as bytes — it is a Struct, decoded before any consumer sees it — so “16KB” 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. Both bounds are checked before the schema runs, and a payload breaking either is a malformed request rather than REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, which names non-conformance to a published schema and applies only where one is published.

“No schema published” is defined at the byte level, because it is the enforcement switch. Absent means no bytes, or only JSON whitespace — RFC 8259’s space, tab, carriage return and line feed, and no others. Each implementation had been asking its own language what “blank” means, which is three different questions: U+00A0 and U+3000 are whitespace to some runtimes and not to others, and a decoder that strips a byte order mark makes a mark followed by a space look like nothing at all. That last one silently bypassed the rule refusing a mark, so an Exchange whose configured schema was an empty file saved with one would have run with validation OFF while the other two implementations called the same bytes malformed. A document that is not empty and not JSON is malformed, which is a refusal; it is never silence.

Data is not schema, stated explicitly. const, enum, default and examples hold arbitrary JSON values whose contents are never read as keywords, so the $ref and $schema rules stop at them — a const carrying a “$ref” member states a value a payload may equal, not a reference to resolve. The rules said “every $ref” and “$schema, wherever it appears”, which an implementor following the text literally would apply inside those four keywords and refuse schemas every SDK accepts.

Smaller corrections. The value MUST be a JSON object: 2020-12 admits a bare boolean as a schema, but a Struct cannot carry one, so the previous rules pinned behaviour for a document the wire cannot transport. A consumer that refuses a schema MUST skip its local pre-check rather than decline to send — stated before as a SHOULD in one sentence and a MUST in another, in the same comment. And the escape list is spelled out character by character rather than ranged, so a conformance guard can compare the contract’s set with the SDK’s in both directions; the previous guard compared five of eight rules against string literals in its own test file, and dropping an escape from every SDK left every gate green.

Tooling: the shared corpus gains a fourth dimension recording what an admitted pattern MATCHES, not merely which schemas are admitted. Its absence is why none of the above was visible: 141 cases across three dimensions all passed while the three implementations disagreed about payloads. The corpus can now also state a case as raw bytes, because the encoding rules above are defined over the bytes as served and ill-formed UTF-8 cannot be written into a JSON string at all. Also corrected in the SDK: the refusal list is clamped by CHARACTERS rather than bytes or UTF-16 units, which is what string.max_len counts and what stopped one implementation naming a different member than the others; a refusal carrying a non-ASCII constraint no longer cuts mid-character into a message proto.Marshal rejects; and the JSON Schema library is given an explicitly REFUSING loader, since leaving it unset does not mean “resolves nothing” — one library’s default reads a reference off local disk and another’s fetches it over HTTP.

The published registration schema states its safety rules as numbers (comment-only, no wire change). AccountRegistration.data_schema already required a self-contained schema, forbade resolving a remote $ref, and capped the document at 16KB. What it did not say was how deep a schema may nest, which regex constructs a pattern may use, or whether format is asserted — it said only that a consumer SHOULD bound validation time and recursion depth, and left every implementation to pick. Those are numbers chosen privately at BOTH ends of one registration: the Exchange enforces the schema and a client pre-checks against it before signing, so a bound one side invents refuses payloads the other accepts, which is the disagreement the field exists to prevent. The rules are now stated. Every $ref, $dynamicRef and $recursiveRef begins with #; every $schema in the document names 2020-12, and a document declaring none is read as 2020-12; 16KB as served; 32 nested JSON containers; and a pattern alphabet in which a group opens ( or (?: and nothing else, the escapes \1-\9, \k, \p, \P, \A, \z, \Z, \Q, \E, \C, \G and \K do not appear, and [[: does not appear.

The pattern rule is the one that is not obvious, and it is a MUST rather than a SHOULD because half of what it excludes fails SILENTLY. Draft 2020-12 pattern is ECMA-262 and the engines implementations run do not agree on it. The loud half is RE2 refusing the lookaround, atomic groups and backreferences ECMA allows: a schema using them compiles for one implementation and fails for the next, which is visible and merely annoying. The quiet half is the reason for the rule — inline flags, Unicode property classes, text anchors and POSIX bracket names are accepted by one engine and either refused or read DIFFERENTLY by another, so two conformant validators both compile the pattern and then disagree about which payloads match it, with nothing to log and no error to catch. Excluding catastrophic backtracking, the hazard the field comment already named, falls out of the same rule instead of needing its own. format, contentEncoding and contentMediaType are pinned as annotations for the same reason at a smaller scale: the libraries default differently on each, so a schema whose verdict depended on which library read it had no single answer.

Tooling: the rules ship as an executable SDK face rather than as prose alone — CompileRegistrationSchema returns a verdict naming WHICH rule a schema broke, because the two callers act on it differently: a client that cannot check locally sends anyway and lets the Exchange decide, while an Exchange that cannot compile its own configured schema is looking at a misconfigured deployment. A refusal names the offending members as RegistrationFieldError values, built from the failed keyword rather than from the validating library’s own message, which quotes the value that failed — the field forbids carrying an operator’s business data back out, and the obvious implementation leaks. The reported set is deduplicated by pointer and keyword and sorted before the 64-item cap, because the three libraries surface duplicates and orderings that differ, and a list whose length depends on the library is one no shared corpus could pin. A conformance guard reads the SDK’s numbers back out of the shared corpus and fails if the field comment stops stating them, which is the only drift gate available for a bound that lives in a comment: data_schema is a Struct, and no field-level rule can reach inside one. The depth bound is measured lexically over the raw bytes, BEFORE the document is parsed: every JSON parser involved descends recursively, and one of them aborts on a deep document by raising an error that is not a verdict at all, so a check placed after the parse is reached only for documents harmless enough to parse.

Every addressed request names its recipient: exchange becomes required (breaking, pre-1.0). ResourceQuery (field 10), DisputeRequest (field 10), RegisterRequest (field 3), GetAccountStatusRequest (field 2), DomainVerificationRequest (field 4), DomainVerificationConfirmation (field 6), PushResourcesRequest (field 5), RemoveResourcesRequest (field 4) and RefreshCatalogRequest (field 3) gain the field; UsageReport.exchange keeps field 8 and is promoted from optional, which is the breaking half — an absent or empty value used to skip the recipient check entirely, so the check was opt-in for the caller, and it is now a rejection. The value is the bare host of the recipient (“exchange.example”, “exchange.example:8081”), never an endpoint URL: an endpoint in the payload would hand the caller the choice of where the next hop dials, which is the lever the well-known resolver exists to remove. A recipient MUST reject a request whose exchange is not its own domain, with INVALID_ARGUMENT and no typed reason — a mis-addressed request is malformed rather than a domain-level failure.

The signature does not already establish this. It proves the sender signed the URL it dialled, not that the URL was right: the dial target is resolved from a fetched, cached manifest, so a poisoned or stale resolution redirects the request while every signature still verifies. The body field states whom the sender meant, independently of that resolution, and the genuine recipient rejects a request naming someone else. The field is stamped by whoever authors each request — the agent on the requests it signs, a Broker on the legs it authors as sender — so it is a statement by that sender, not tamper-evidence against it. On a verbatim-forwarded path the agent signs its @target-uri against the final recipient’s endpoint, not the Broker’s. Two messages are exempt and both absences are load-bearing: DiscoveryRequest travels one direct hop and terminates at the Broker, which authors fresh per-Exchange ResourceQuery messages rather than forwarding it, and the agent cannot name the fan-out set in any case (RequestConstraints.exchanges stays its optional filter); TransactionRequest carries no top-level field because its audience statement is per item.

The pattern is deliberately permissive — one or more labels, an optional port, no scheme, path, query, userinfo or trailing root dot, and case normalised (both sides lowercase before comparing). It matches the structural bare-host rule the reference clients already apply, whose job is to stop a path or query being smuggled into a value that gets concatenated into a URL, not to check that a name looks like a DNS record. A stricter rule demanding a dotted name with an alphabetic suffix would reject single-label service hosts such as exchange:8081, which real deployments use.

The same constraint is applied in one pass to the domain-carrying fields that until now accepted any string at all: ResourceResponse.exchange, RequestConstraints.exchanges, RequestConstraints.preferred_exchanges, Requester.domain and AuthorizedExchange.domain. Tightening them is breaking for the same reason the UsageReport promotion is — a value accepted today becomes a rejection — and it is done now because one value space with two contracts is the state that produces the bugs. Requester.domain earns it most: a verifier concatenates that value into {domain}/.well-known/fora.json and fetches the result, so a smuggled path or query would choose WHAT gets fetched, not merely from where. The port group spells the range out rather than counting digits: :0 and :99999 are refused like any other value that cannot name a listening service, where a [0-9]{1,5} group would have admitted both. And TransactionDenial.exchange is documented as a HINT, not an instruction — it rides in a response, a relayed response passed through an intermediary, so nothing signs it. A caller MUST check it against a domain it already trusts for the transaction (the denied item’s signed offer.exchange, or its own RequestConstraints.exchanges) before acting, because registering hands an operator’s business data and a signed acceptance of that Exchange’s terms to whoever answers. A conformance guard walks the descriptor for every field carrying the shared pattern and asserts each refuses a scheme prefix, a path or query suffix, userinfo, a malformed port, a trailing root dot and an empty label — shapes the corpus generator cannot produce, because its bad-string table is shared with the money and token fields and widening it there would add a mutant to every pattern-ruled field in the contract.

Offer.exchange is presence-enforced (breaking, pre-1.0). It was a plain string with no rule, so an empty value passed. It is the execute-routing target, the value a relaying Broker groups a mixed batch by, and — because TransactionRequest has no top-level exchange — the audience statement of an execute: on receipt an Exchange MUST reject the request unless EVERY item’s offer.exchange names its own domain. An empty value is unroutable, and the swap-protection the offer signature is supposed to provide is vacuous when the signed bytes carry no recipient at all. Adding the rule does not change any signed bytes: a protovalidate rule is a field option, not a field.

Manifest registration becomes a block: WellKnownManifest.account_registration (field 30) replaces registration_schema (field 29), and terms_digest (field 31) joins terms_uri (breaking, pre-1.0). The new top-level AccountRegistration message carries the same JSON Schema, now as data_schema, with the same publish-is-enforce contract and the same safety rules. The block exists because registration has more than one publishable facet: field 2 is left free for a future web mode, a URL to a page where a human completes steps an API call cannot carry. The precedence rule is fixed now, while it is still cheap: an Exchange publishing data_schema MUST accept registration through the API, and a registration URL is an additional option an agent may offer its user, never a replacement. Field 29 is not reused — the manifest already leaves 5 and 6 free after the WBA split, and appending keeps the numbering legible.

terms_digest pins the document served at terms_uri in the existing method:hexdigest form, and RegisterRequest.terms_digest (field 4) echoes it. A URL alone cannot say WHICH terms were accepted: its content changes, so after the first revision every earlier registration points at text that no longer says what was agreed. The echo is covered by the request signature, and the Exchange records the accepted digest with the account — which also makes keeping the historical terms documents retrievable the Exchange’s obligation, since a digest identifies a document only while a copy of it still exists. It sits at the top level rather than inside the block so an Exchange with pass-through registration can still pin its terms version; a message rule (well_known_manifest.terms_digest_requires_terms_uri) keeps it from being published without the address it pins, mirroring the existing license.digest_required_with_uri. Operators should treat first publication as a coordinated change: it refuses every client that does not yet echo the value.

REGISTRATION_FAILURE_REASON_TERMS_DIGEST_STALE (additive, no wire break). All four digest cases are now defined rather than only the stale one. Matching echo: the registration proceeds. Differing echo: refused with the new reason. Absent echo while the Exchange publishes a digest: 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. An echo sent to an Exchange that publishes no digest: ignored, and explicitly NOT recorded as an acceptance, since an Exchange publishing none cannot verify what document the value refers to and storing it would put an unverifiable claim exactly where the 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 client cannot detect staleness locally — only the Exchange can — so 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.

DENIAL_REASON_BILLING_REF_INACTIVE splits into DENIAL_REASON_ACCOUNT_INACTIVE (keeping its wire number) and DENIAL_REASON_ACCOUNT_NOT_REGISTERED, and TransactionDenial gains exchange (field 4) (breaking, pre-1.0). The agent hits this wall at execute, not at register, and the old single reason conflated two states of the caller with two different remedies: wait for an operator to activate an account that exists, versus call Register because none does — an action an agent can take unattended. The denial names the Exchange that produced it, which on a relayed or fanned-out execute need not be one the agent named, so the agent learns where to register without fetching a manifest to work it out. This reverses a recorded decision; the reasoning, and why the neighbouring DELEGATION_EXPIRED → DENIAL_REASON_DELEGATION_INVALID broadening still stands, are in docs/design-history.md under “DenialReason consolidation”.

Contract text: registration_data is documented as business-registration data (no wire change). The details an Exchange needs to open a commercial account — legal entity, address, jurisdiction, tax identifiers. The specific members stay operator-defined; what is now explicit is that this is not an identity claim, since the caller’s identity comes from the verified request signature and nothing in the payload is trusted as authentication.

Tooling: the corpus grows from 208 to 319 cases and Offer enters it for the first time, because a message with no field rules produces no cases at all and Offer previously had none. WellKnownManifest gains a seed: auto-fill populates terms_digest (it carries a pattern) but never terms_uri (no field rule to trigger on), so the generated baseline would otherwise violate the new message rule. The reviewable part of that diff is the seed and the pattern, not the generated output.

WellKnownManifest.endpoint states its host binding (no wire change; conformance-affecting). The field said only “Exchange-only. ExchangeService endpoint URL”, so nothing told an Exchange operator that the address it advertises must stay on its own domain. It now does: the endpoint MUST be on the host AND PORT that SERVE the manifest — not the self-asserted domain member inside it — or on a subdomain of that host on that port, and MUST NOT carry userinfo. The manifest 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 offer’s signature never covered — and a dial-time address guard has no objection to an unrelated PUBLIC host. Another port is another service, which the party publishing 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; the scheme itself is not compared, and the default-port folding is scheme-relative so that it cannot become a scheme check by accident.

This is the first entry in this changelog that changes what conforms without changing the wire. The classifier is deliberately not (breaking): this change moves no field, message, or encoding, and buf breaking reports nothing — while the bare (breaking) entries below all mark a descriptor delta, and the one qualified use (“breaking for the generated clients”) names the audience it breaks. What this change does instead is narrow what a conformant manifest may say.

Two shapes that are conformant today will be refused after this. The first is an Exchange serving its API from a separate DOMAIN — a CDN, a hosting provider. The second is an Exchange on a separate PORT: a single-domain deployment serving /.well-known/fora.json on its default port and advertising "endpoint": "https://exchange.example:8443/v1" is refused, as is the mirror image (a portless endpoint under a manifest served on :8443) and a subdomain reached across ports. A single domain is therefore no longer sufficient on its own — the authority must match on both halves.

Remedies, by shape. For a separate domain, front the API under a subdomain of the domain serving the fora.json. For a separate port, either move the API onto the port the manifest is served from, or serve the manifest from the API’s own authority — https://exchange.example:8443/.well-known/fora.json alongside https://exchange.example:8443/v1. Writing a scheme’s default port out in full is NOT a mismatch and needs no change.

Both are refused as ErrEndpointRefused, which classifies as a FINAL verdict rather than a transport failure — so a client will not retry its way out of a misconfiguration, and the symptom is a usage report that never lands rather than one that is slow.

Enforcement moved with the rule: it now runs in the SDK’s shared endpoint resolver rather than in one client, so every consumer of that resolver inherits it without changing a line. Two consequences for anyone re-pinning. Resolution can now fail with a new ErrEndpointRefused sentinel, which is a VERDICT — the Exchange answered and the answer is unusable — and a classifier that branches only on the older ErrNoEndpoint will drop it into its transport-failure bucket and retry something that will never succeed; add the new sentinel alongside. And a Broker that resolves endpoints through this package inherits the rule for the paths that use it. gen/ and the website mirror are regenerated; proto comments only.

Go SDK: the delivery fetch correlates, and the offer-key cache is bounded (additive, no wire change). resolvers.ContentFetchOptions gained a RequestID hook, and connect.NewClient feeds it the same mint the RPC legs read — so WithRequestIDFunc now reaches all three legs and a delivery GET carries X-Request-ID. It did not before, and could not: the RPC legs correlate through a Connect interceptor, which a plain GET never traverses, and there was no seam to add one. This changes what arrives at a delivery edge. An edge that mints its own id when the header is absent will now see the caller’s instead, which is the point — a refused delivery used to produce two log records under two ids with nothing joining them, on the one leg where delivery failures are diagnosed. A fetcher built directly with no RequestID still sends no header: this tier mints nothing of its own.

resolvers.CachedOfferKeyResolver’s per-domain cache now evicts least-recently-used at a fixed cap, like the endpoint cache and the per-origin client pool. Its key is a domain off Offer.exchange, so which entries appear is driven by incoming offers, and an entry’s expiry is a freshness check rather than a removal — a stale entry held its slot indefinitely. Reaching it needed a resolvable host serving a valid directory per domain, so the case was narrow rather than open, but two sibling structures over the same key space were already bounded and this one was not.

Go SDK: the Connect client covers the agent verb set, and its signing knobs are reachable (additive, no wire change). connect.Client gained ReportUsage, Dispute and Fetch, and connect.NewBrokerClient gained Resolve — the client previously exposed Discover and Execute alone, so a caller needing any of the rest had to assemble its own from forav1connect plus core.NewSigningTransport, which is the duplication the SDK exists to remove. Resolve returns the same fail-closed {verified, rejected} split Discover does, through the same core.Verifier; Fetch performs proof-of-possession on an agent-bound URL and dials only through the SSRF-guarded client.

Five client options join them, each because a value the tier below already accepted had no way in: WithSignWindow (the RFC 9421 request freshness window — pair it with core.MonotonicWindow when the peer screens replays on (key id, signature), since one-second timestamp resolution makes two identical requests inside a second sign to the same bytes), WithSignatureAgent (the WBA directory origin the client signs as), WithProofWindow, WithContentTimeout and WithMaxContentBytes.

WithSignatureAgent is worth reading twice if you verify signatures. signature-agent is one of the five REQUIRED covered components, so the header is signed whether or not a value was supplied — a client that does not set it signs an EMPTY one. A peer that resolves the caller’s key by fetching the WBA directory at that origin then has nothing to resolve and refuses the call at verification, which surfaces as a 401 from an otherwise healthy Exchange rather than as anything the routing checks would catch. The value is stamped set-if-absent, so a relay forwarding an originating agent’s request does not overwrite the value that agent’s own signature covers. See docs/sdk-parity-matrix.md for the per-language surface.

SDK (all 3 languages): the registration-failure builder can carry the field errors (additive, no wire change). helpers.RegistrationFailureDetail (Go), registration_failure_detail (Python) and registrationFailureDetail (TS) now accept the offending registration_data members alongside the reason — variadic in Go, an optional trailing argument in Python and TS, so the six reasons that carry no per-member detail keep their three-argument call. Without this a service refusing a non-conforming registration had to build the ErrorDetail by hand or mutate the builder’s result, defeating the rule these helpers exist for: one place per language where the ADR-019 envelope is constructed. This is the only *Detail builder that reaches past the reason enum — the schema refusal is useless without naming what failed, whereas the sibling detail lists (TransactionDenial.restriction_mismatches, CatalogRejection.rejected_paths) stay caller-set after construction.

The shared oracle gains a registration_failure_field_errors vector and a field_errors projection, replayed on both halves in all three languages: the construct replays feed the members back through the builder and assert byte-parity with the Go wire, and the read replays assert a reader extracts them positionally. The vector carries both member shapes — a pointer into the payload and the empty root pointer for a whole-object failure. That second one caught a real divergence: canonical proto-JSON omits an empty scalar, so the wire form of a root-pointer entry has no path key at all, while the generated Pydantic model defaults path to "" and the generated Zod schema declares .default(""), both materializing a member Go omits. Both builders now map an empty path to unpopulated, the exact inverse of the read side normalizing an absent path to "".

Registration data becomes schema-enforceable: WellKnownManifest.registration_schema (field 29) + REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA (additive, no wire break). An Exchange MAY publish, in its fora.json, a JSON Schema (draft 2020-12, max 16KB) describing the registration_data object it expects on Register. Publication and enforcement are one decision: an Exchange that publishes the schema validates incoming registration_data against it and refuses a non-conforming payload with the new failure reason; an Exchange that publishes none accepts the payload uninspected and passes it to its system of record exactly as before, so existing Exchanges stay conformant with no change. This replaces the former unconditional contract text (“the Exchange passes it through to its system of record without inspecting it”) on the Agent Account Registration banner and on RegisterRequest.registration_data, both of which now defer to the field that owns the contract rather than restating it.

The field carries normative safety rules, because a consumer reads this schema out of a third party’s manifest: the schema MUST be self-contained and a consumer MUST NOT resolve a remote $ref out of it — doing so would turn every reader into an SSRF vector aimed at a URL the schema’s author chose — and a consumer SHOULD bound validation time and recursion depth, since draft 2020-12 pattern admits regexes with catastrophic backtracking. The 16KB cap is measured as the UTF-8 bytes of the member as served in fora.json; an oversized schema SHOULD be rejected and its local pre-check skipped rather than truncated, which leaves the Exchange’s own enforcement deciding exactly as it does when no schema is published. These are prose, not protovalidate rules: the field is a Struct, and no field-level rule can reach inside it.

The refusal names what to fix: RegistrationFailure gains field_errors (field 2, ≤64 items) carrying the new top-level RegistrationFieldError {path, error}. path is an RFC 6901 JSON Pointer relative to registration_data ("/vat_id", "/address/postal_code"); the empty string addresses registration_data itself, which is how whole-object failures (oneOf, minProperties) that belong to no single member are reported. A free-text pair rather than a closed kind enum because JSON Schema’s composite keywords do not attach to any one member and the standard is extensible by design, so a closed vocabulary could not stay complete. error is developer-facing and NON-authoritative — wording is validator-defined and varies across Exchanges, clients branch on reason — and, like ErrorDetail.message, it states the violated constraint and never the submitted value, so a refusal cannot echo an agent’s business data back over the wire. A machine-readable kind can join at field 3 later without a wire break.

Motivation: an agent integrating the SDK directly signs and sends Register itself and passes through no registration front-end, so a check only a front-end performs is a suggestion, not a rule — and the agent had nowhere to learn which fields a given Exchange expects. Both now resolve against the manifest the agent already fetches to find the Exchange’s endpoint.

Tooling: RegistrationFailure is now seeded in the corpus generator with the new reason and an empty-path field error, so the cross-language oracle exercises the reason this change adds and pins the empty-path accept boundary in all three languages; without the seed the auto-filled baseline picked the first allowed reason and published a DOMAIN_NOT_VERIFIED refusal carrying field_errors as valid — the pairing the field comment rules out. The generator also gained valid-item construction for repeated message fields (seed-or-autofill, mirroring the top-level baseline). field_errors is the contract’s first repeated message field carrying its own repeated.max_items, and the generator previously produced only scalar list items.

The ver envelope field states its contract, and the version string gets one owner (no wire change). All 29 ver fields — 25 in fora.proto, 4 in admin.proto — now name the expected value "1.0" and the receive-side rule. Before this, 27 of them said only “Protocol version” or “FORA protocol version”, and DiscoveryResponse.ver carried no comment at all — 28 fields from which an integrator could not learn what to stamp. Only WellKnownManifest.ver named the value. The contract: senders MUST stamp ver from a single constant, and receivers treat it as advisory — ver is not an authenticity or authorization control and MUST NOT be used as one, a receiver is not required to check it, and one that does check it MAY reject an unrecognised MAJOR version but MUST NOT reject an unrecognised MINOR version. Version negotiation, where it is needed, happens out of band via WellKnownManifest.protocol_versions_supported, which is why the in-band field need not be a rejection gate. ver deliberately carries no protovalidate rule: an exact-match rule would make every peer reject a "1.1" message outright, contradicting the reject-unrecognised-majors policy the manifest already states, and a major-version pattern would additionally make ver structurally required on every message — a wire change no consumer has asked for. The full reasoning is recorded under “Protocol version” in fora.proto. WellKnownManifest.ver keeps its stronger MUST-equal rule and now says why it differs: it versions the /.well-known/fora.json document schema, a namespace deliberately separate from the RPC envelope and not coupled to it.

SDK (all 3 languages): ProtocolVersion exported (additive, no wire change). The FORA ver value is now a public SDK symbol — helpers.ProtocolVersion in Go, ProtocolVersion in Python and TypeScript — pinned to the shared wire-constants-vectors.json oracle alongside the existing wire constants. It is the FORA protocol version, not the Connect transport version that ConnectProtocolVersion carries. Consumers import it instead of minting their own constant, so a protocol bump is one edit here plus a re-pin rather than a literal hunt across every message builder. Two structural guards keep the pair honest: a conformance guard fails the build when a contract message declares ver without documenting its value and receive-side rule, and an SDK guard fails when a message builder in non-test sdk/go source stamps a bare string literal on a Ver: struct-literal field. Both bind what this project emits; neither can bind a third party, which is the accepted limit of an advisory field.

ResourceEntry gains typed resource_mutability (field 14) (additive, no wire break). Publishers submit resource_mutability as a typed ResourceEntry field instead of inside ext/ext_critical; the Exchange reads the typed field, not ext. The field is optional — when omitted the Exchange defaults to STATIC at Offer build; an explicit RESOURCE_MUTABILITY_UNSPECIFIED is rejected (not_in:[0], matching the Offer-side twin). Offer-side ResourceIdentity.resource_mutability is unchanged.

Go SDK: network-fetching resolvers move sdk/go/helpers → sdk/go/resolvers (source move, no wire change). The IO-bearing key/endpoint resolvers — well-known JWKS (NewWellKnownKeyResolver), revocation-aware WBA directory (NewWBAKeyResolver), the fora.json endpoint resolver (WellKnownEndpointResolver / NewWellKnownEndpointResolver / WellKnownOptions / ErrNoEndpoint), and the SSRF-guarded fetch client — now live in a new L2 I/O package, one tier above the pure, IO-free sdk/go/helpers, so no network dial enters the trust core. Import them from github.com/FORA-Protocol/protocol/sdk/go/resolvers. No alias shim is shipped — it is a hard rename; consumers import the resolvers from sdk/go/resolvers. The pure KeyResolver interface and NewStaticKeyResolver stay in helpers.

SDK (all 3 languages): new public faces (additive, no wire change). Document-order active-key selection (ActiveEd25519Key / …WithExpiry and revocation-aware …Screened variants; active_ed25519_key* in Python, activeEd25519Key* in TS), a CachedOfferKeyResolver, an injectable Ed25519 verify primitive on the TS signed-URL verify (Ed25519Verifier), and cross-language ErrorDetail readers (Go AttachErrorDetail / AttachDetail; parse_error_detail / error_detail_from in Python; parseErrorDetail / errorDetailFrom in TS). The SSRF-guarded transport is a single env-driven client governed by two flags (SKIP_SSRF, ALLOW_INSECURE).

Go SDK: helpers.CanonicalOfferBytes exported (additive, no wire change). The offer-canonical-bytes accessor — RFC 8785 JCS over canonical proto-JSON with signature/signature_algorithm cleared, expires_at included, byte-identical to what SignOffer signs and VerifyOffer verifies — is now a public Go symbol. It exposes the single canonicalization the signer and verifier already share, so a caller can persist the signed offer as verbatim, independently re-verifiable evidence. Python (canonical_offer_payload) and TS (canonicalOfferPayload) already expose the equivalent public accessor; this brings the Go surface to parity.

Go SDK: helpers.CanonicalAcceptanceBytes exported (additive, no wire change). The acceptance-canonical-bytes accessor — RFC 8785 JCS over canonical proto-JSON of AgentAcceptancePayload{offer_sig, requester_id, requester_domain, idempotency_key}, byte-identical to what SignOfferAcceptance signs and VerifyOfferAcceptance verifies — is now a public Go symbol, completing the pair with CanonicalOfferBytes. A caller can persist an agent’s acceptance as verbatim, independently re-verifiable evidence rather than re-deriving the bytes at verification time, which would pin an already-signed acceptance to whatever canonicalization the SDK implements later. Python (jcs_acceptance_payload) and TS (acceptancePayload) already expose the equivalent public accessor; this brings the Go surface to parity.

Acceptance canonical-form text corrected (documentation only, no wire change). AgentAcceptance and AgentAcceptancePayload still described the RETIRED signing form — “the deterministic protobuf serialization”, “proto.Marshal(Deterministic: true)” — contradicting the canonical-signing block on Offer.signature in the same file, which already states that RFC 8785 JCS over canonical proto-JSON “applies to the agent offer-acceptance signature”. The acceptance text now points at that single normative definition instead of restating a superseded recipe: AgentAcceptancePayload fixes the field set, Offer.signature fixes the byte layout. Implementations that followed the stale text would have produced non-verifying signatures. No field, message, or wire change — comments only, with gen/ and the website mirror regenerated.

Python + TS SDK: the hand-built acceptance payload omits every unpopulated field (bug fix, no wire change). jcs_acceptance_payload (Python) and acceptancePayload (TS) assemble the AgentAcceptancePayload JSON object key by key, and omitted only an empty requester_domain — requester_id and idempotency_key were always emitted. Go renders the same object through protojson with EmitUnpopulated=false, which omits EVERY unpopulated field, so the three SDKs signed different bytes whenever requester_id was empty. That input is wire-valid: Requester.id carries no min_len. Verification failed closed on it (a byte mismatch, never a bypass), but the byte-equivalence the canonical-bytes accessors promise did not hold. Both hand-built faces now drop each empty string field, and two new vectors in sdk/go/helpers/testdata/acceptance-vectors.json — empty_requester_id and empty_idempotency_key, one per omittable field left uncovered — pin the agreement across Go, Python and TS. Without them the omission can be dropped in any one language with every gate still green. The corpus change is purely additive — the pre-existing vectors and their signatures are byte-identical, so no already-issued signature is affected.

Canonical signing refuses messages carrying unknown fields (normative; Go SDK behavior change, no wire change). Offer.signature — the single normative definition of the canonical form — now states the rule, which turns on whether a canonicalizer omits or preserves content it has no schema for. An OMITTING canonicalizer (proto-JSON emits only schema-defined fields) cannot reproduce the signed bytes of a message carrying unknown fields, so it MUST refuse the message rather than emit the reduced bytes, at EVERY depth — a nested message and each element of a repeated or map field carries its own unknown-field set. A PRESERVING canonicalizer carries unrecognized members through, reproduces the signed bytes faithfully, and has nothing to refuse.

Either way an APPENDED field cannot pass, which is the point: the omitting case refuses the message, and the preserving case renders the appended member into bytes the signer never covered. Without the refusal the omitting case failed OPEN — an intermediary could add unknown fields to an already-signed Offer without invalidating its signature, smuggling unauthenticated content through a message the recipient treats as verified. That is what the Go SDK now closes: helpers.VerifyOffer surfaces the refusal as ErrOfferSignatureInvalid (a message that arrived carrying extra bytes is a tampered offer, not an internal fault) wrapping the new helpers.ErrUnknownFields, so a caller can branch on either; helpers.CanonicalOfferBytes and helpers.SignOffer return ErrUnknownFields directly.

Python (from_wire_offer) and TypeScript (canonicalOfferPayload) are preserving canonicalizers and need no change — they already reject the appended-field case on a byte mismatch. They are NOT expected to reject a message whose signer covered the unknown member: they reproduce those bytes exactly and verify, which is the forward-compatible outcome. Go, being an omitting canonicalizer, cannot reconstruct such a message at all and refuses it; that asymmetry is inherent to the renderer, not new here — before this change Go rejected the same message on a byte mismatch instead.

No legitimate traffic regresses: an offer signed WITH a field this build cannot render already failed to verify; the refusal only makes the reason explicit. Extensions are unaffected — they ride in ext / ext_critical, defined fields that sit inside the signed bytes, never undeclared field numbers. One new exported Go symbol (ErrUnknownFields, registered as a Go-idiomatic exclusion in the parity map); no field, message, or wire change — proto comments only, with gen/ and the website mirror regenerated.

Requester.billing_ref removed (breaking, pre-1.0). The caller-written billing label on Requester is gone; the field is deleted outright with no reserved statement — pre-v1 the number returns to the free pool, and reserved becomes the tool for field removals only once v1.0.0 is tagged. Nothing read it: billing and cost attribution key on the verified caller identity and the account handle minted at Register (RegisterResponse.billing_ref), which the Exchange resolves from the request signature — never from anything the caller sends. Dropping the field also removes the name collision between the caller-written label and the authoritative account handle. Binary wire-compatible: an old caller still sending field 5 has it ignored as an unknown field. JSON tolerance is a decoder property, not a protocol guarantee: a decoder that discards unknown fields (as connect-go’s default codec does) ignores a stray billing_ref inside requester, but a strict protojson decoder rejects the whole message — endpoints that hand-roll protojson decoding should set DiscardUnknown: true if they want to keep accepting old callers. For anyone who used the field: who pays is always the account minted at Register, resolved from the request signature. For a cost-allocation label, Broker callers use RequestConstraints.budget_scope on DiscoveryRequest (a Broker-side spend-tracking key; it does not reach the Exchange). Direct-to-Exchange callers who need to attach one use Requester.ext — but do not name the key billing_ref: it is not an account handle, and the Exchange will not read it as one.

Agent account registration + status RPCs (additive). ExchangeService gains Register and GetAccountStatus — the agent-account front door. Registration creates the agent’s account with the Exchange and mints billing_ref, the opaque, long-lived, per-Exchange account handle; the caller’s identity is derived from the verified request signature, never from the body, and the operator-defined business payload rides in a flexible RegisterRequest.registration_data (google.protobuf.Struct) that the Exchange passes through uninspected. A repeat Register for the same agent returns the same billing_ref (idempotent by design — no idempotency_key). GetAccountStatus is the read-only “is my account active” check; its request deliberately carries no identifying field. Refused registrations use the pre-existing ErrorDetail.registration_failure / RegistrationFailureReason path. Pre-v1 additive change.

Operator plane: new fora.admin.v1 package with AdminService (additive). Two full-replace, idempotent setters for Exchange operators: SetTenantFeeRate and SetReportingPolicy. Each request and response is a thin {ver, <payload>} envelope wrapping a required nested payload — TenantFeeRate (fee rate in basis points, 0 <= fee_rate_bps < 10000, plus an optional operator note) and ReportingPolicy (required report fields, quantity tolerance 0–1, reporting window ≤ 1 year). Field-level protovalidate constraints live on the payload messages — shared by request and response, so each rule is stated once and the echoed read-back cannot drift from the write — and flow into the generated Pydantic/Zod types; responses echo the state as persisted. Deliberately a separate service/package from ExchangeService — the operator plane is not part of the agent contract, carries no idempotency_key and no ext/ext_critical maps, and is expected to be network-isolated by deployments. See Proto: Admin v1 for the full reference. SetOfferPrice and SetDeliveryWitnessMode are deferred to follow-up work.

Biscuits removed; the entitlement mechanism is unchanged (breaking). JWT is now the only entitlement/capability token format. The mechanism itself is format-neutral and does not change: the capability token rides a covered header, renamed X-FORA-Entitlement-Biscuit → X-Entitlement-Token, and the verifier enforces that header’s signature coverage without ever parsing the token. Removed are the Biscuit-specific pieces — the token_format value "biscuit-v3" (JWT was already the default) and DENIAL_REASON_ENTITLEMENT_STALE_ATTENUATION (18), since attenuation is a Biscuit concept. The generic entitlement denial reasons (12–17) are unaffected. This is a pre-v1 breaking change.

Protocol standardization — unified error/response contract + a Connect RPC for every role (breaking). Three threads land together:

  • Unified error model. A typed ErrorDetail (plus its detail messages and DenialReason/OfferAbsenceReason reason enums) is carried out-of-band in the transport error; a failed action returns a transport error while a successful query — including a “no result” answer — returns in-body. Response messages are standardized: ver is field 1 on every request and response.
  • A Connect RPC for every role. Added BrokerService with Resolve(DiscoveryRequest) → DiscoveryResponse, and DiscoveryResponse.absence_reason (field 16) for “the resolve ran but produced nothing licensable”.
  • Idempotency. A required idempotency_key (min_len: 1, max_len: 255, deduped per verified RFC 9421 signer) is added to every state-mutating RPC: TransactionRequest, UsageReport, and DisputeRequest. Broker Resolve (DiscoveryRequest) is pure discovery — it executes no transaction and takes no key.

Also: renamed PushContent → PushResources; removed AccessPolicy / ResourceAccessPolicy and DELIVERY_METHOD_INLINE; removed in-body correlation — the request_id fields and the residual id fields on ResourceQuery/DiscoveryRequest — in favor of an X-Request-ID header; extended DenialReason with values 12–18 and added OFFER_ABSENCE_REASON_BUDGET_EXCEEDED. Accepted pre-v1 breaking change; buf breaking reports the deltas as expected.

Also removed the vestigial single-offer offer_id correlation scalar from TransactionRequest (field 3). It was left stranded by the items-only migration: never authoritative — the Exchange keys binding, billing, and audit off each item’s signature-verified offer identity inside the signed Offer, never this scalar — and read by nothing. A single-offer transaction is the degenerate one-element items list. Deleted outright with no reserved (pre-v1); buf breaking reports the delta as expected.

Money as an exact decimal string + field validation as standard constraints (breaking).

  • Money is a decimal string, not a double. Pricing.rate/unit_cost, Cost.amount/unit_cost, and RequestConstraints.max_unit_cost change from double to string carrying a decimal string.pattern (^([0-9]+([.][0-9]+)?)?$). Binary double cannot represent most decimal money values exactly, so it drifts and breaks settlement sums; a decimal string is exact and supports arbitrary sub-cent precision (e.g. "0.0001234"). Accepted pre-v1 breaking change; buf breaking reports the five field-type deltas as expected.
  • Field-level validation moved to standard protovalidate constraints. 18 of 25 field-level rules moved from custom CEL to standard constraints (11 enum discriminators to enum.not_in: [0], 7 formats to string.pattern) so they flow through JSON Schema into the generated Pydantic/Zod types export; the 7 genuine cross-field rules stay server-authoritative CEL.

Wire is snake_case proto-JSON (breaking for the generated clients). The generated Pydantic/Zod clients and the shared conformance corpus use the proto field names (snake_case) as the wire form (Go protojson with UseProtoNames), matching the .proto and the docs. protojson still accepts the camelCase json_name on input, but it is out of contract for the generated clients: they emit and accept snake_case only, and — because JCS-canonicalized signing is over the JSON field names — snake_case is the canonical form the signature bytes are computed over. Accepted pre-v1 breaking change.

Discovery/offer response model (breaking). The Agent-to-Broker discovery messages are renamed and the response re-modeled to carry offers rather than a single transaction result:

  • Renamed FORARequest → DiscoveryRequest and FORAResponse → DiscoveryResponse — the Agent-to-Broker pair (Steps 1 and 6), the same pair carried by BrokerService.Resolve.
  • Re-modeled DiscoveryResponse as discovery-only: removed the per-transaction fields (now carried solely by TransactionResponse) and added repeated OfferGroup offer_groups, one group per requested URI. A group with no offers carries its absence_reason.
  • Added Offer.exchange (field 8): the issuing Exchange’s canonical domain and the execute target, inside the signed Offer bytes so a relaying Broker cannot redirect execution without invalidating the signature.

WBA identity split — keys move to the WBA directory (breaking). Identity keys are split out of fora.json (WellKnownManifest) into a pure WBA key directory served at {domain}/.well-known/http-message-signatures-directory:

  • Added WBAFile (the directory body) carrying the role’s JWKs and an optional revocation_url; removed the manifest’s inline key fields.
  • Keyed by thumbprint, no kid. The RFC 9421 keyid is the key’s RFC 7638 JWK Thumbprint, computed locally; the attestation keyid now holds the verifier key’s thumbprint, resolved against WBAFile.keys.
  • Added KeyRevocationList (served at WBAFile.revocation_url) — the complete set of revoked thumbprints, polled on a 300s cadence.

CoMP re-baseline to canonical V1 (breaking). proto/comp/v1/comp.proto is re-aligned to be a 1:1 mirror of IAB Tech Lab Content Monetization Protocols CoMP V1 (finalized 2026-04-28, CoMP-1.0.md). The prior snapshot mirrored a pre-final draft. This breaks the comp.v1 generated types (accepted pre-v1 of the CoMP profile; buf breaking reports the deltas as expected). Changes:

  • Removed the License message, the LicenseUse enum, and Package.license — canonical V1 has no separate License object.
  • Folded licensing into Scope: added ause (AllowedUse), pricetype (PriceType), pricetier, unitprice, cur (default "USD"), country (ISO-3166 numeric), and licensedur (days).
  • Added Package.reporturl (usage-reporting URL).
  • Added per-media taxonomy cattax (default 9), cat, and language to Text, Video, Image, and Audio.
  • Removed the FORA-invented non-CoMP fields: Text.authority/originality, Image.alt/caption, Video/Image/Audio.c2pa, and Retrieval.ratelmt.
  • Added RETRIEVAL_AUTH_OTHER = 4.

The request-side model (AISystem/AISystemUse, Function, SubFunction, AuthMethod, ScopeType, ContentType) is unchanged.

  • ExchangeService — DiscoverResources, ExecuteTransaction, ReportUsage, DisputeTransaction, and domain-verification RPCs. Agents and Brokers are interchangeable clients.
  • ResourceQuery / ResourceResponse — query an Exchange for available resource offers.
  • DiscoveryRequest — Agent → Broker entry point, with natural-language query and structured search_filters for Broker-side discovery.
  • Requester — universal actor identity (AGENT, HUMAN_TOOL, SERVICE, DELEGATED, RESEARCH).
  • Delegation — holder-bound JWT (RFC 7800 cnf/jkt + RFC 9421 proof-of-possession; chain of cnf-linked JWTs). token_format is "jwt". Scoped, time-limited, spend-capped, narrowable offline.
  • Scope-based access control — the Exchange filters its catalog by the requester’s scopes; subscriptions are scopes.
  • SubscriptionQuotaInfo — proactive, multi-dimensional quota signaling on Offer and TransactionResponse.
  • ResourceMutability — STATIC (hash stable), DYNAMIC (hash drifts), LIVE (streaming, no content at offer time).
  • Data freshness — Offer.data_as_of + RequestConstraints.max_data_age for staleness filtering.
  • Unit-agnostic metering — unit_cost + estimated_quantity + unit: tokens, pages, seconds, records, bytes, sq_km, and domain-specific units.
  • ResourceAttestation — Ed25519-signed claim envelope for resource integrity. Three levels: none, self-attested, third-party verified.
  • Dispute resolution — three-tier (automated <1s, rule-based <24h, human escalation). Evidence chain: Transaction → UsageReport → Dispute.
  • Domain verification — ACME HTTP-01-style provider onboarding.
  • CatalogService — PushResources, RemoveResources, RefreshCatalog.
  • ext_critical — critical-extension signaling (COSE crit pattern, RFC 9052): a consumer MUST understand listed keys or reject the message.
  • Resource previews — lightweight Preview assets on Offer for pre-transaction evaluation (URLs only, zero Exchange memory impact).
  • Signature-stack forwarding chain — schain-inspired forwarding for the Agent → Broker → … → Exchange path carried as a stack of RFC 9421 HTTP Message Signatures in HTTP headers. Each forwarding party adds one labeled signature covering the request plus the prior hop’s signature; the ordered set of signatures is the chain. (Replaces the in-message IntermediaryHop / ResourceQuery.intermediaries and the removed broker_signature.)
  • Chain-depth caps — RequestConstraints.max_hops (agent-side) and WellKnownManifest.max_intermediary_hops (Exchange-published).
  • Direct response path — the terminal Exchange returns directly to the originating agent; intermediaries are forward-path only.
  • WellKnownManifest — a single canonical document served at /.well-known/fora.json by every participant, role-tagged via the Role enum (AGENT, EXCHANGE, BROKER, PUBLISHER). Carries inline keys, optional invalidation_url, publisher authorization (exchanges[], catalog_contributors[]), and exchange capability fields (pricing, delivery, auth methods, OIDC issuer, GNAP endpoint, base currency, supported profiles).
  • JsonWebKey — inline RFC 7517 JWKs (Ed25519: kty="OKP", crv="Ed25519", alg="EdDSA") with explicit not_before / not_after RFC3339 bounds. Key-validity window is half-open [not_before, not_after).
  • KeyInvalidationList — snapshot-semantic kid revocation list served at invalidation_url for emergency revocation.
  • Caching contract — /.well-known/fora.json MAY be cached (minutes–hours); the invalidation_url body SHOULD be short/no-store.
  • Domainless requesters — accommodated via a registry-hosted WellKnownManifest (the agent sets Requester.domain to the registry host).
  • Auth-agnostic — the Exchange advertises supported methods: GNAP (RFC 9635), OAuth + DPoP, OAuth Bearer, mTLS.
  • Content signatures — the v1.0 documentation described Offer and attestation signatures as JWS Compact Serialization (alg=EdDSA). That was incorrect for Offer signatures, which have always been hex-encoded detached Ed25519 over the RFC 8785 JCS canonical form. The attestation signature encoding remains unspecified.
  • RFC 9421 for request signatures — HTTP Message Signatures authenticate agents and each intermediary hop.
  • Retrieval-URL identity binding — the Exchange MAY bind a signed retrieval_endpoint to the agent via agent_identity_hash (RFC 7638 JWK Thumbprint, DPoP-style per RFC 9449), verifiable fully offline by a capable edge function.
  • ResourceIdentity C2PA fields (c2pa_manifest, c2pa_status, soft_binding, soft_binding_method) and the C2PAStatus enum (TRUSTED, VALID, INVALID, ABSENT).
  • The fora-c2pa-v1 extension profile bridges C2PA X.509/COSE trust into FORA Ed25519 attestations.

PRICING_MODEL is the charging structure only: FREE, PER_UNIT, FLAT (plus UNSPECIFIED=0, rejected at ingest). The metering basis (“per what”) moved to the Pricing.unit vocabulary; revenue-share was removed (settlement is off-protocol); attribution/contribution are Obligations.

BILLING_REF_INACTIVE, INSUFFICIENT_BALANCE, RATE_LIMITED, CONTENT_UNAVAILABLE, RESTRICTION_NOT_SATISFIED, REPORTING_OVERDUE, OFFER_EXPIRED, SIGNATURE_INVALID, QUOTA_EXCEEDED, DELEGATION_INVALID, SCOPE_INSUFFICIENT.

Domain-specific metadata carried in ext fields:

  • fora-news-v1 — articles, podcasts, broadcasting (IPTC NewsML-G2, Podcasting 2.0)
  • fora-academic-v1 — scholarly articles, preprints (CrossRef, OpenAlex, COUNTER 5.1)
  • fora-legal-v1 — legislation, case law, patents (ELI, ECLI, Akoma Ntoso)
  • fora-comp-v1 — IAB CoMP V1 metadata (Package, Scope with folded licensing, Retrieval, per-media taxonomy) as optional ext fields
  • LicenseTerm — universal licensing unit. A resource carries zero or more terms; each term is a complete, self-contained access arrangement (restrictions + quotas + obligations + pricing). Same shape at ingestion (ResourceEntry.terms) and emission (Offer.terms).
  • License — identifies the governing license document (uri, id, name, immutable, uri_digest). uri_digest pins the document hash and is required whenever a License carries a uri (any semantics, mutable or not).
  • Restriction — constrains one axis: FUNCTION (what), GEOGRAPHY (where), USER_TYPE (who). Tokens are proto-native ((fora.v1.vocab_enum) on the RestrictionKind values). restrictions are binding by default (advisory=true downgrades an unverifiable restriction to non-blocking).
  • Quota — usage cap that gates term validity, not billing. Metrics are proto-native ((fora.v1.vocab) on Quota.metric): accesses, tokens, display-words, impressions, units-manufactured, and more.
  • Obligation — post-use behavioral requirement. Replaces the retired PRICING_MODEL_ATTRIBUTION / PRICING_MODEL_CONTRIBUTION (attribution and contribution are obligations, not payment models). Kinds: ATTRIBUTION, CONTRIBUTION, SHARE_ALIKE, NETWORK_COPYLEFT, NOTICE, OTHER. Obligation.scope_license is a License (not a bare string), so a SHARE_ALIKE target inherits the uri ⇒ uri_digest tamper-evidence rule.
  • TermSemantics — ENUMERATED (machine fields are the complete, authoritative term; enforced downstream at reconciliation; Pricing required) vs REFERENCE_ONLY (the document at License.uri is the authoritative, complete source; machine restrictions/quotas/obligations are optional but, when present, must be accurate and are enforced; Pricing still required).
  • PricingMetering — ONLINE (default), NONE (one-time sale, no ongoing tracking), OFFLINE_SELF_REPORTED (agent self-reports; Exchange audits). Added as Pricing.metering (field 9).
  • Offer.terms (field 19) — repeated LicenseTerm from the publisher catalog. Offer.restrictions (the flat AccessRestrictions field) removed.
  • ResourceEntry.terms (field 13) — publisher-declared terms pushed via CatalogService.PushResources.
  • PushResourcesResponse.warnings (field 3) — non-fatal ingestion warnings (e.g., unrecognized vocab token).
  • PRICING_MODEL_ATTRIBUTION (6) and PRICING_MODEL_CONTRIBUTION (7) removed from PricingModel. Migrate to Obligation.kind = ATTRIBUTION / CONTRIBUTION in a LicenseTerm.
  • Wire-enforced validation — licensing presence and coherence rules are now expressed as protovalidate CEL embedded in the descriptors (not prose-only): pricing required on every term, REFERENCE_ONLY ⇒ license.uri, uri ⇒ uri_digest, the required discriminator enums reject UNSPECIFIED (LicenseTerm.semantics, Pricing.model, Restriction.kind, Obligation.kind, Obligation.trigger, Quota.window), one restriction per kind, permitted/prohibited disjoint, and token-format rules. A conformance/ test suite evaluates the CEL against valid/invalid instances and validates the doc examples, wired into CI; a guard derives the discriminator set from the proto (each such discriminator field rejects the enum’s zero via its not_in:[0] rule) and fails if any field of one is left unenforced, so the set can’t silently drift. Enforced and tested in the Go SDK today; the TypeScript SDK is generated (protovalidate-es not yet wired) and Python is a tracked follow-up.

Protocol Buffers. Package fora.v1. Dual transport via Connect (HTTP/JSON + binary protobuf from the same handler).