Skip to content

Changelog

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

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 firedobligation.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 (personalindividual, business and enterprisecommercial_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_EXPIREDDENIAL_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 advisoryver 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/helperssdk/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_domainrequester_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.jsonempty_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 01, 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-BiscuitX-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 PushContentPushResources; 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 FORARequestDiscoveryRequest and FORAResponseDiscoveryResponse — 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.

  • ExchangeServiceDiscoverResources, 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 freshnessOffer.data_as_of + RequestConstraints.max_data_age for staleness filtering.
  • Unit-agnostic meteringunit_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.
  • CatalogServicePushResources, 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 capsRequestConstraints.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.
  • TermSemanticsENUMERATED (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).
  • PricingMeteringONLINE (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).