Repository navigation
Web Bot Auth signing, Broker purchases, the final money rules and the SDK harness capabilities - #52
Merged
Merged
Conversation
FORA's design goal is that every participant signs and verifies requests according to Web Bot Auth, so any WBA library interoperates. The authentication page claimed FORA was "automatically compatible" with WebBotAuth while mandating a profile no WBA library produces or accepts, and the transaction-flow and Exchange request-flow pages listed a third, contradictory covered set. The docs now specify one profile, pinned to draft-ietf-webbotauth-httpsig-protocol-00: - every request signature is a WG-00 signature: a dictionary Signature-Agent naming an https origin and covered by member, tag="web-bot-auth", a fresh 64-byte nonce, alg="ed25519"; - bodied FORA RPCs additionally cover @method, @target-uri, content-digest and authorization, advertised through Accept-Signature; - relay hops follow WG-00 section 5.2.2; - the retrieval proof of possession is a WG-00 signature that the edge still verifies offline against the key bound into the delivery URL; - key directories are served over https as application/http-message-signatures-directory+json, with a signed response; - the bare Signature-Agent value the current SDKs send is refused. JWK alg stays "EdDSA", the JOSE name RFC 7517 section 4.4 defines, which departs from WG-00 section 5.5.1; the authentication page records it as the one deliberate deviation. The SDKs and conformance vectors do not implement this profile yet; the changelog entry lists the change as upcoming.
WG-00 section 5.2.2 lets a signer cover another signature only when it leaves every component that signature covers unchanged. A signer that changed one of them must not cover the inner signature; it signs the request on its own terms and leaves the inner signature untouched. The Broker posts to the Exchange's URL with a new body, so it changes what the agent's signature covers. Its signature now covers its own request components and only its own Signature-Agent member, and the agent's signature headers travel untouched and uncovered. The Exchange authenticates the caller as the Broker and takes the agent's consent from the AgentAcceptance signed into the body, which does not depend on the route. A party that forwards a request unchanged may still cover the previous signature, and only then do the signatures form a chain. Regenerate the Broker example with a re-packaged body and a signature that covers one member, which Cloudflare's web-bot-auth verifier accepts, and drop the note that said it refused the Broker. Remove the claim that the signatures form a forwarding chain from every page that describes the Broker path, and update both changelog entries.
The previous change still described one Broker path: a relay that re-packages the agent's request and leaves the agent's signature headers in place. That is not how the Broker works. The agent does not know the Exchange at discovery time, so the Broker chooses the Exchanges and originates its own queries. On execute it builds a fresh request for each Exchange. In both cases the Broker signs alone, and its signature covers its own request and only its own Signature-Agent member. The WG-00 section 5.2.2 rule for unchanged forwarding stays as the general rule, and the pages now say plainly that the Broker never forwards that way. On a relayed purchase the agent's identity and consent at the Exchange come from AgentAcceptance, verified against the agent's registered key resolved from Requester.domain. That key gives the delivery-URL binding, and a delegation's cnf.jkt is checked against it, so a delegation stays bound to the agent's private key through the Broker. A Broker is never delegated to. The pages that said a delegated request through a Broker needs a delegation to the Broker's key, and the Broker overview section that described the Broker narrowing a delegation, are corrected. How an agent proves a holder-of-key entitlement at discovery time through a Broker is recorded as an open question rather than given a mechanism. The Broker example is now a query the Broker originated, with one signature and one Signature-Agent member. The scenario walkthrough no longer derives the delivery-URL binding from the transport keyid, which is the Broker's on a relayed purchase. Both changelog entries are updated.
…tity helpers to the Python SDK Four additions for callers that drive FORA services through the SDK and need to check what the services answer: - ClientConfig.before_sign receives every request as an httpx.Request just before it is signed. The request it returns is signed and sent, and the reply is decoded as usual, so a deliberately altered message still goes through the SDK's own signer and decoder. A hook that changes the method or URL, or sets a header the signer emits, is refused as MALFORMED; the Host and Content-Length a hook sets never reach the peer. - CallError.code holds the Connect code of a peer's answer, from an error envelope or a non-JSON error status, and is None for local failures, refused redirects and the content leg. - Every verb that takes a request accepts the generated wire model as well as a dict. to_wire renders a model as the JSON object the SDK sends: the fields the caller set, under their proto names. A model and the equivalent dict reach the wire as the same bytes. - generate_key, directory_document and signing_transport_for mint an Ed25519 identity a verifier resolves: a key and its thumbprint, the Web Bot Auth key directory publishing it with the validity window the resolver requires, and a signer that signs as it. prepare now reads its signing settings through a SigningSettings protocol that ClientConfig satisfies, which keeps it within the argument limit. The additions are Python-only for now; Go and TypeScript parity follows.
An RPC request whose RFC 9421 HTTP message signature failed verification was refused as Connect unauthenticated with only a message. The contract tells clients to branch on a typed reason and never on the message, and ErrorDetail had no reason for this failure. ErrorDetail's reason oneof gains RequestAuthFailure request_auth_failure = 17, whose RequestAuthFailureReason is SIGNATURE_MISSING, SIGNATURE_INVALID or SIGNATURE_STALE. The values are deliberately coarse: each names what the caller does next, never which validation step failed. connectserver.WriteReject, which the verify seam answers with, now adds one fora.v1.ErrorDetail to the details of every unauthenticated refusal. A missing or malformed Signature-Input or Signature is SIGNATURE_MISSING; an expired signature, one created in the future, or a replay is SIGNATURE_STALE; every other error, including one the mapping does not know, is SIGNATURE_INVALID. The envelope's code and message are unchanged, a resource_exhausted refusal still carries no detail, and a test holds the details encoding equal to what connect-go's ErrorWriter writes. The detail's domain is empty, because the writer is not given the request. The detail builders gain helpers.RequestAuthFailureDetail, request_auth_failure_detail and requestAuthFailureDetail, and the readers return the new enum. The error-detail and connect-error corpora carry the new block, so the Python and TypeScript readers replay it. Neither of those SDKs has an RPC refusal writer, so they read the detail but do not emit it.
…fusal is documented The reference page and the Exchange request flow already named RequestAuthFailure. The other pages that describe a request refused for its RFC 9421 signature still said only CodeUnauthenticated: - the forwarding-chain verification sample in the Exchange request flows; - the CatalogService authentication step; - the agent SDK's error-recovery table, which now says a stale signature is re-signed and retried while a missing or invalid one is terminal until the signer is fixed; - the production architecture's retrieval-vocabulary paragraph, which names the RPC counterpart; - the forwarding-chain forgery threat, which records why the reason is coarse.
BrokerService had only Resolve, and the contract described the agent buying at each Exchange directly, with a Broker on the path forwarding the request byte-for-byte under a stack of hop signatures. Implementations relayed purchases through the Broker anyway, by re-packaging them outside the contract. This defines that relay, additively. BrokerService gains ExecuteTransaction(TransactionRequest) returns BrokerTransactionResponse. The Broker verifies the agent's request, groups the items by each signed offer's exchange, and sends one ExchangeService.ExecuteTransaction per Exchange signed with its own key. The agent's AgentAcceptance per item and its AgentRequestAcceptance travel in each sub-request body, so every Exchange still verifies the agent's consent. The comments state why re-packaging is safe: each item is atomic and its integrity is per resource, and in a result item the one signed value is the Exchange-signed retrieval_endpoint. New wire surface: - BrokerTransactionResponse: items in request order, one ExchangeOutcome per Exchange contacted, and charged totals per currency, never summed across currencies. - ExchangeOutcome: exchange, offer_ids, agent_identity_hash and subscription_quota. - UpstreamRefusal: exchange, the Connect code and the Exchange's ErrorDetail. - TransactionResultItem.refusal = 14: an Exchange's refusal of a whole sub-request rides on each affected item and is never turned into a Broker error. - DenialReason.DENIAL_REASON_RELAY_NOT_ACCEPTED = 19: the provider at this Exchange does not accept relayed purchases; buy the offer directly. The Broker's own refusals are non-OK errors: request_auth_failure for an invalid agent signature, and SIGNATURE_INVALID when requester.domain is not the agent's verified signing directory; invalid_argument for a malformed request; failed_precondition for an Exchange it cannot route to or does not approve. It forwards the agent's idempotency_key unchanged. CONTENT_UNAVAILABLE is documented as not a catch-all for upstream failures. The authentication header, ResourceQuery, the request-recipient section, Delegation, AgentRequestAcceptance, TransactionRequest.idempotency_key, TransactionResponse.agent_identity_hash and the Broker protocol section now describe this model: the hop-signature chain covers requests forwarded byte-for-byte such as discovery, while a purchase through a Broker is re-packaged. On a re-packaged purchase the delegation holder binding and agent_identity_hash use the key the AgentAcceptance verifies under, and an Exchange scopes the idempotency key per Broker, requester and key. Generated code, the descriptor, the SDK types and the validation corpus are regenerated. The shared-domain ratchet counts the two new exchange fields, and ErrorDetail leaves the out-of-band roots because UpstreamRefusal makes it reachable from an RPC body.
BrokerClient.Execute (Go), BrokerClient.execute (Python, async and sync) and BrokerClient.execute (TypeScript) buy verified offers through a Broker in one call, however many Exchanges issued them, and return the BrokerTransactionResponse. The client builds the same TransactionRequest a direct purchase sends: every item with the agent's AgentAcceptance and one AgentRequestAcceptance over the complete ordered set, signed by the client's signer, with ver and the configured requester stamped. Refused locally, with nothing sent: no requester, no signer, no offers, an unsigned offer, an offer that names no exchange, and a requester.domain that is not the host of the directory the client signs as (its Signature-Agent). The last mirrors the Broker's own check, which it refuses with request_auth_failure SIGNATURE_INVALID; the comparison is the recipient-identity rule, exact and case-folded with :443 the same as no port. Python's SigningTransport gains a read-only signature_agent property for it. The exchange client buys several offers from one Exchange in one request, built by the same code: Client.ExecuteBatch in Go, and execute given a sequence (Python) or an array (TypeScript). Offers from more than one Exchange are refused locally, pointing at the Broker purchase. The client-request corpus gains the brokerExecute verb, replayed by all three languages. The Go tests run the real client against the SDK's BrokerService handler; the Python tests run both faces against the signed peer; the TypeScript tests use the injected send the other execute tests use. They cover single and batch purchases, acceptances that verify for each Exchange's projection, a combined answer with one refused group and per-currency totals, the local refusals, and the Broker's own refusal. The connectserver BrokerService mount needs no change; its comment now names the relayed purchase.
The prose pages described a Broker forwarding the agent's purchase under a stack of hop signatures, and the agent buying only at each Exchange. They now describe BrokerService.ExecuteTransaction and keep the forwarding chain for requests forwarded byte-for-byte, such as discovery: - transaction flow: the two ways to buy, the re-packaging steps, why combining per-resource purchases is safe, a combined response with a refused group and per-currency totals, and the Broker's own refusals; - authentication: which key the holder binding uses on a re-packaged purchase, and the requester-domain check; - the Broker overview, the Exchange request flows (sub-requests from a Broker), the agent SDK pages, the threat model (tampering with and misreporting a relayed purchase), role composition, the scenario and PoC walkthroughs and the Identity Service tool table. Both changelogs gain an Unreleased entry for the wire change and the SDK verbs.
…bes it The field was documented only as "Health check endpoint URL", and the Broker deployment page described a HEAD to the endpoint that no Broker performs. The comment now states the contract: - the URL a consumer probes to judge whether the Exchange is serving; - absolute, or a path resolved against the manifest's own URL; - a 200 answer means serving; - when absent, the consumer judges reachability by fetching the manifest itself, and never assumes a path the protocol does not name. The manifest reference table and the Broker deployment page say the same.
…ee packages The SDK build generated a JSON Schema per message with protoschema (protoc-gen-jsonschema v0.6.0) and discarded it after generating the Pydantic and Zod models. gen_jsonschema.py now keeps that output as release artifacts under gen/jsonschema/: one self-contained draft 2020-12 file per message of fora.v1 and fora.admin.v1, named by the fully-qualified message name, in a default variant that accepts unknown fields and a strict variant (.schema.strict.json) with additionalProperties false on every message object at every depth. Both variants describe canonical proto-JSON: - field names are the snake_case proto names; the lowerCamel aliases are dropped, so a camelCase key is an unknown field; - google.protobuf.Struct stays open, so every ext accepts any object; - a 64-bit integer accepts a decimal string or a JSON integer, with its bound encoded in the string pattern too; other numbers are JSON numbers only, so a string cannot carry a value past a numeric bound. A bound the generator cannot encode fails the build; - the buf.validate constraints protoschema carries, plus required for every field whose zero value its own rule rejects and uniqueItems for repeated.unique, from the manifests the models already use. The schemas ship in the Go module (gen/jsonschema/, embedded by package gen/jsonschema with Load and FS), in fora-protocol as package data read by wire.schemas.load(name, strict=False) and wire.schemas.names(), and in @fora-protocol/sdk under the export path ./jsonschema/*, from the root manifest and the built package alike. The models, unique.py and schemas.ts regenerate byte-identical. The drift gate (ci-local.sh, sdk-types-ci.yml) regenerates and compares gen/jsonschema/ like the generated code. A Go conformance test checks the file set, field names and unknown-field policy against the descriptors. Python (jsonschema) and TypeScript (Ajv) tests reach the schemas through the loader and the export path, show the strict variant refusing an unknown field and a buf.validate violation failing, and require both variants to match Go protovalidate on every corpus case. The release smoke tests load a schema from the built wheel, sdist and tarball.
A new reference page, JSON Schemas, says where each package ships the schemas, how to validate a message with jsonschema (Python) and Ajv (TypeScript), and what the two variants accept: snake_case proto field names, an open google.protobuf.Struct, 64-bit integers as a decimal string or a JSON integer with the bound on both forms, other numbers as JSON numbers, and the carried buf.validate constraints. It also names what the schemas do not check: cross-field rules, oneof exclusivity and formats. The repository README lists the schemas next to the generated types, and both changelogs gain an Unreleased entry.
…ctors The JSON SDKs read a Connect error's typed reason from the debug projection and never from details[].value, the binary ErrorDetail that connect-go always sends. Two corpora now pin reading value first: - connect-error-vectors.json gains expect.detail, the whole ErrorDetail as canonical proto-JSON, and three kinds of row derived from captured envelopes: a .value_only twin of every case with a detail, value_wins_over_debug, and undecodable_value_skipped, whose debug must not be read in place of a value that fails to decode. - error-detail-wire-vectors.json carries the ErrorDetail subtree's field and enum tables read from the compiled descriptor, and binary encodings with their proto-JSON. Every field of every message in the subtree is set in at least one vector, which the emitter checks; the rest cover an unpacked repeated enum, a singular field written twice, unknown fields of every wire type and the empty message. Go replays both through the generated code.
error_detail_from read a Connect error's typed reason only from the debug projection, which connect-go adds for JSON readers and may omit. It now reads details[].value first: the binary ErrorDetail, base64 in either alphabet, padded or not, decoded by a table-driven reader for the ErrorDetail subtree so the SDK still takes no protobuf dependency. debug is read only when value is absent; a value that is present but does not decode makes the entry unreadable, and its debug is not read in its place, which is what Go reports for the same entry. The reader follows protobuf's rules: unknown fields and known fields with the wrong wire type are skipped, a repeated enum is accepted packed or not, the last value of a singular field wins, a singular message written twice is merged, and only the reason written last is kept. An enum number with no name stays a number, which the generated model then refuses. The decoder's tables are asserted equal to the descriptor tables in error-detail-wire-vectors.json and every vector there decodes to Go's proto-JSON. The connect-error replay asserts CallError.code and the whole detail on every row, and the transport-failure replay asserts the code a non-envelope status implies.
…nt to the Python SDK Four additions for callers that drive FORA services through the SDK and check what the services answer: - RawBody(body), passed to any verb in place of its request (to execute in place of the offers), sends the body exactly as given: bytes verbatim, a string as UTF-8, any other value serialized once as compact JSON. Nothing is filled in and nothing about the message is refused locally; the body is still signed, before_sign still runs and the answer is decoded as usual. A routed verb still reads its destination from the body's exchange member, and a body with no usable exchange is refused as not sent. - ClientConfig.strict refuses an answer carrying an unknown field at any depth, or breaking a field-level or cross-field rule. The shape is the published strict JSON Schema of the response message and the existing cross-field rules, which the schema also points at the right objects. A null member of a message object is read as absent first, because a FORA server emits unpopulated fields. - execute and BrokerClient.execute verify every retrieval URL in the answer: the signature against the key the issuing Exchange publishes in its Web Bot Auth directory (named by kid, resolved through ClientConfig.delivery_keys, by default a guarded WBAKeyResolver built once per client), the binding to this agent and to the agent_identity_hash the answer states, and the expiry. They return ExecuteResult and BrokerExecuteResult, the generated responses plus deliveries, one Delivery per item. fetch takes a Delivery, or a URL with exchange=, verifies it before dialling and returns the binding on Content.binding; a bare URL is still fetched as given. A URL that does not verify is refused as MALFORMED with a synthesized retrieval_auth_failure detail naming the edge's reason, and an unreachable directory is UNREACHABLE. delivery_verification=Mode.OFF is the opt-out for URLs in another signing scheme. SigningTransport.thumbprint names the agent key. - AdminClient, async and sync, covers AdminService.SetTenantFeeRate and SetReportingPolicy and ExchangeService.RequestDomainVerification and ConfirmDomainVerification, at the configured base URL. The two domain requests must name a bare-domain recipient. The in-process Exchange of the README example now publishes its delivery key in its directory and names it by thumbprint, as the protocol says, and the example fetches the verified delivery. BeforeSign, RawBody, Delivery, DeliveryKeyResolver, ExecuteResult, BrokerExecuteResult and AdminClient are exported from fora_sdk and fora_sdk.client.
… delivery verification to the Go client The Go client gains what a conformance harness needs to drive FORA services through it rather than around it: - WithBeforeSign(hook) receives every RPC request just before the signing transport signs it. The request the hook returns is signed and sent and the reply decoded as usual. A hook that fails, returns nil, changes the method or URL, or sets a header the signer writes is refused as CallMalformed with nothing sent; Host and Content-Length are recomputed. - WithRawBody(body) is a per-call option on every verb (the verbs that took no CallOption now take them). The bytes are sent as the binary protobuf body exactly as given: nothing is stamped and no local refusal about the message applies, while the call is still signed and its answer decoded. A verb that routes on its request decodes exchange from the body; one that names none is refused as CallNotSent. - WithStrictDecoding() refuses an answer carrying an unknown field at any depth, or breaking its field or cross-field rules under protovalidate. - CallError.Code holds the Connect code of the peer's answer: an error envelope, or a non-JSON error status. connect-go does not mark a code it derived from a status as one the server sent, so the client's transport records that a response arrived; a dial failure, a refused redirect and a local refusal carry no code. The connect-error and transport-failure replays assert it for every row. - Execute, ExecuteBatch and BrokerClient.Execute verify every retrieval_endpoint before returning: the Ed25519 signature against the issuing Exchange's URL-signing key, resolved by kid from its WBA directory (WithDeliveryKeyResolver), the agent_id binding against this agent's key and the answer's agent_identity_hash, and the expiry. A URL that does not verify fails the call as CallMalformed with a retrieval_auth_failure detail in the client's domain. WithDeliveries(&out) receives the verified bindings as Delivery values, Fetch with WithDeliveryExchange verifies a URL before minting a proof for it and returns the binding on Content.Binding, and WithDeliveryVerification(core.Off) opts out for URLs in another signing scheme. The tests run the real client against real Connect handlers behind the SDK's server verify face, and an Exchange that publishes its URL-signing key in its own WBA directory.
NewAdminClient(baseURL, opts...) covers every RPC of fora.admin.v1.AdminService, SetTenantFeeRate and SetReportingPolicy, and the two domain-verification RPCs of ExchangeService, RequestDomainVerification and ConfirmDomainVerification. Both services are reached at the one base URL, each at its own path; a deployment that serves the admin listener apart from the Exchange endpoint builds one client per address. Every verb fills an empty ver, signs when a signer is configured, and takes the per-call options, so raw mode applies. The two domain-verification requests are refused before sending when exchange is missing or not a bare domain, as the catalog verbs are. The test enumerates AdminService's methods from the compiled descriptor, so an RPC added to the proto fails it until the client carries a method of the same name, and calls each against the generated handler.
Three steps a caller otherwise assembles by hand to mint an identity a FORA verifier can resolve: - helpers.GenerateKey returns a fresh Ed25519 key and its RFC 7638 thumbprint, the keyid every signature names. - helpers.DirectoryDocument returns the Web Bot Auth key directory for a key set as the generated WBAFile, each key carrying the validity window the WBA resolver requires: opening five minutes before now and lasting DefaultKeyValidity (365 days) unless told otherwise. - core.SigningTransportFor returns a signing transport that signs as the key, with its thumbprint as keyid and the directory as Signature-Agent. The test mints a fresh agent, serves its directory, and has the SDK's own server verify face accept a request signed through the transport by resolving the key from that directory.
The Go README gains a section on testing services through the client, and the connect package comment names the operator verbs and the new options. The parity map records the new Go symbols. AdminClient, Delivery, BeforeSign, WithRawBody, GenerateKey, DirectoryDocument, DefaultKeyValidity and SigningTransportFor map to the Python and TypeScript faces; NewAdminClient folds into the Python AdminClient constructor, the same recorded divergence as every other NewX, so the allowlist baseline moves from 16 to 17. The six new functional options are Go-idiomatic exclusions. The parity matrix is regenerated.
…Script SDK errorDetailFrom read a Connect error's typed reason from the debug projection only. It now reads details[].value, the binary ErrorDetail connect-go always sends, with a table-driven decoder for the ErrorDetail subtree, and falls back to debug only when value is absent. A value that is present but does not decode makes that entry unreadable; its debug is not read in its place, which is what the Go client does with the same envelope. The decoder follows protobuf: unknown fields and known fields with a mismatched wire type are skipped, a singular field written twice keeps the last value and a singular message merges, a repeated enum is read packed or unpacked, and only the last member of the reason oneof survives. A truncated buffer, a group, an over-long varint or a string that is not UTF-8 makes the value undecodable. Its tables must equal the ones the shared error-detail-wire corpus reads from the compiled descriptor, and every vector there decodes to the proto-JSON Go renders for it. ForaCallError.code holds the Connect code of a peer's answer: from an error envelope, or from a non-JSON error status classified by the code it implies. It is undefined for local failures, refused redirects and the content leg. The connect-error replay asserts the code and the whole detail on every row, the derived value-only and value-before-debug rows included; the transport-failure replay asserts the code.
generateKey, directoryDocument and signingTransportFor mint an Ed25519 identity a verifier resolves, as the Python SDK's generate_key, directory_document and signing_transport_for do: - generateKey returns a key pair, its private key non-extractable, and its RFC 7638 thumbprint, the keyid every FORA signature names. - directoryDocument returns the Web Bot Auth key directory publishing a set of public keys, each with the not_before / not_after window the WBA resolver requires: it opens five minutes before now and lasts DEFAULT_KEY_VALIDITY_MS (365 days) unless validForMs says otherwise. - signingTransportFor wraps a send in a signing transport whose keyid is the key's thumbprint and whose Signature-Agent is the directory. They ship under the new export path ./identity. The test mints an agent, serves its directory to the SDK's WBA resolver and has verifyRequestServer accept a request the transport signed, and refuse it when the directory publishes another key; a client built from the minted key is accepted the same way.
…cation to the TypeScript client Four capabilities for callers that test FORA services through the client, matching the Python SDK: - ClientOptions.beforeSign receives every RPC request just before it is signed, as a Fetch API Request. The request it returns is signed and sent and the reply decoded as usual. A hook that throws, returns something else, changes the method or URL, or sets a header the signer emits is refused as malformed with nothing sent; the host and content-length it carries are dropped. - RawBody: every verb accepts one in place of its request (execute in place of the offers). The bytes go out as given, signed, without ver, idempotency_key or requester stamped and without validation or local refusals; the answer is decoded into the verb's response schema. A verb that routes by exchange still reads it from the body and resolves it. - ClientOptions.strict checks every success answer against the published strict JSON Schema of its message (Ajv, draft 2020-12) and the cross-field rules, on the answer and every nested message that carries any, after reading a null member as absent. An unknown field or a broken rule is refused as malformed. - execute and BrokerClient.execute verify every retrieval_endpoint: its Ed25519 signature against the URL-signing key the issuing Exchange publishes in its WBA directory (ClientOptions.deliveryKeys, the WBA resolver by default), its expiry, and its agent binding against this agent and the agent_identity_hash the answer stated. The verified bindings ride on the result as a non-enumerable deliveries list. fetch verifies a Delivery, or a URL given with the Exchange that issued it, before dialling, and returns the binding on Content.binding. A URL that does not verify is refused as malformed with a retrieval_auth_failure detail; an unreadable directory is unreachable. deliveryVerification: "off" skips all of it. The verbs are typed with the generated request input types, alongside plain records and RawBody. The options, the resolved holders and the one call path move to client/options.ts and the stamping helpers to client/stamp.ts, so every face shares them; the single-reader guard now reads the whole client directory.
createAdminClient covers every RPC of fora.admin.v1.AdminService (setTenantFeeRate, setReportingPolicy) and the two domain-verification RPCs of ExchangeService (requestDomainVerification, confirmDomainVerification), whose models already existed. Every call goes to the configured base URL over the plain send, as the operator chose the address; a deployment serving the admin listener and the Exchange endpoint apart builds one client per address. ver is filled when empty, the request is checked against its generated schema, and the two domain-verification requests must name a bare-domain recipient or are refused as not_sent. A configured signer signs every call. RawBody and strict decoding work as on every other face. The test reads the RPC list from the proto files, so an RPC added to AdminService fails it until the client has a verb, and drives each verb against a peer that verifies the signature.
…ports The client's strict decoding imports the published strict schemas as JSON modules, so the release build stages gen/jsonschema beside gen/ts and compiles with resolveJsonModule; the compiled client imports them with a JSON import attribute, which plain Node resolves from the same dist/gen/jsonschema directory the ./jsonschema/* export already ships. Ajv is CommonJS, and NodeNext types its default import as the module namespace while a bundler types it as the class, so the engine is now read from whichever arrived and typed by the one method the client uses.
…atrix helpers.DefaultKeyValidity maps to the Python DEFAULT_KEY_VALIDITY, which the identity module defined but the package did not export, so the API-surface gate could not find it. The matrix now records error-detail-wire-vectors.json as replayed by all three SDKs.
The agent SDK overview gains a section on testing a FORA service through the SDK: the pre-signing hook, raw mode, strict decoding, the Connect code, the verified delivery bindings, the admin client and the identity helpers, named per language, with the rules all three hold. It also says that both execute verbs verify every retrieval URL, and lists the operator role beside the publisher and account-setup roles. The fetch flow shows the Go client collecting the verified deliveries and fetching with the issuing Exchange named, adds CallError.Code, and says what a CallMalformed for an unverifiable delivery URL means. The JSON Schemas page describes strict decoding, the admin reference names the three admin clients, and the proto reference says the SDKs read an error detail's binary value before its debug projection. The Unreleased entry in both changelogs now covers the capabilities in all three languages, replacing the Python-only entry.
WithStrictDecoding checked a success answer and read an error answer the way connect-go does, which ignores an unknown member, turns an unknown code into one derived from the status and skips a detail that does not decode. A harness relying on strict mode as a shape check therefore missed a malformed envelope and an ErrorDetail that breaks its contract. Under strict decoding the client's transport now keeps the error body, and the strict interceptor checks it before the error is classified: - the envelope carries only code, message and details, a null member read as absent; code is one of the sixteen Connect codes; message is a string; - each details entry carries only type, value and debug, a non-empty type, a base64 value (standard or URL alphabet, padded or not) and at least one of value and debug; - an entry of type fora.v1.ErrorDetail decodes from its value with no unknown field and passes protovalidate, and its debug projection, when present, parses as proto-JSON with no unknown field and passes the same rules. A refused envelope is CallMalformed with CallError.Code still holding the peer's code, and reports no detail. An empty or non-JSON error body is a gateway's answer and is classified by its status as before, and a gzip error body is checked after it is decoded. Without the option nothing changes. connect-error-vectors.json gains a strict_malformed column on every row, declared by the emitter rather than taken from Go's own strict run, and three rows for it: envelope_unknown_member, debug_projection_unknown_field, and the captured registration_failure_field_errors_out_of_scope, whose field_errors break the RegistrationFailure message rule, with its .value_only twin. undecodable_value_skipped is refused under strict decoding. The Go replay reads every row through a strict client too, and a table test covers the envelope rules one by one.
ClientConfig.strict checked a success answer against the strict JSON Schema of its message and read every error envelope the lenient way. It now checks the envelope too, before the error is built, with the rules the Go client applies: - the envelope carries only code, message and details, a None member read as absent; code is one of the sixteen Connect codes; message is a string; - each details entry carries only type, value and debug, a non-empty type, a base64 value and at least one of value and debug; - for fora.v1.ErrorDetail, the binary value decodes with no unknown field, which the table-driven decoder now reports when asked, and the debug projection is read under the proto field names; each must set at most one reason and pass the published strict ErrorDetail schema and the cross-field rules. A refusal is CallError(MALFORMED) keeping code and status, the code the lenient read reports, with no detail and no peer message. An empty or non-JSON error body is still classified by its status. Without strict nothing changes. The strict check's core is now strict_violation(name, payload), shared by the success path and the ErrorDetail check. The connect-error replay decodes every row strictly as well and asserts strict_malformed, and a table test covers the envelope rules one by one. The test that pinned an error envelope as read the same way under strict now pins the refusal.
ClientOptions.strict checked a success answer against the strict JSON Schema of its message and read every error envelope the lenient way. The transport now checks the envelope first when the client is strict, with the rules the Go and Python clients apply: - the envelope carries only code, message and details, a null member read as absent; code is one of the sixteen Connect codes; message is a string; - each details entry carries only type, value and debug, a non-empty type, a base64 value and at least one of value and debug; - for fora.v1.ErrorDetail, the binary value decodes with no unknown field, which the table-driven decoder now reports when asked, and the debug projection is read under the proto field names; each must set at most one reason and pass the published strict ErrorDetail schema and the cross-field rules. A refusal is a malformed ForaCallError keeping code and status, the code the lenient read reports, with no detail and no peer message. An empty or non-JSON error body is still classified by its status. decodeResponse takes the strict flag as an optional third argument; without it nothing changes. The strict check's core is now strictViolation(raw, message), shared by the success path and the ErrorDetail check, and the ErrorDetail strict schema joins the schemas the client imports. toProtoNames exposes the reader's lowerCamelCase-to-proto-name rewrite. The connect-error replay decodes every row strictly as well and asserts strict_malformed, and a table test covers the envelope rules one by one. The test that pinned an error envelope as left alone under strict now pins the refusal.
…er's estimate A metered purchase charges the offer's estimate × rate, or one unit's rate without an estimate, and the charge is final. The ingestion and billing pages still described the older model: the pipeline set every offer's estimated_quantity from word_count × 1.32, a ±20% tolerance applied between estimate and report, and "the actual token count from the UsageReport is used for billing". Under the current rule that would let the Exchange, not the publisher, choose the quantity a purchase is charged for, and would apply a token count to prices in other units. The pages now say what the protocol says. The pipeline's token count sizes a resource for unit_cost, the comparison metric; an offer carries only the estimate the publisher states, in the price's own unit; a usage report records consumption and never changes the charge. - normalization: the token estimate is a sizing input; the ±20% paragraph becomes what an estimate error can and cannot move. - catalog-compilation: the mapping table and the snippet carry the publisher's estimate unchanged and compute unit_cost from decimal strings, as Pricing holds them. - billing-adapter, configuration and storage-model: the same rule, and the table names the charge per model. - for-exchange-operators: rate, unit, estimate and unit_cost are defined as the protocol defines them, the per-access row no longer reads as tokens, and an omitted consumed_unit is the only tokens default. - jsonl-ingestion: estimated_quantity is the publisher's estimate in the term's unit, and the worked example no longer charges 474 × $0.05. - threat model: no pricing model makes the charge depend on the report, and the prior-art row no longer names a minimum billable token count. The metered-purchase changelog entry records the change, mirrored on the site.
…rried but enforced by no party, in the protocol The file header and ResourceQuery said an Exchange enforces both RequestConstraints.max_hops and WellKnownManifest.max_intermediary_hops by counting signatures, and max_hops said a Broker must not forward a request past it. max_hops rides only on DiscoveryRequest, which ends at the Broker, and a Broker originates its own queries, so no Exchange ever receives it and no party enforces it. Its comment now says so and keeps the field for multi-hop Broker discovery, an open question; it is not deprecated. The header and ResourceQuery now say the Exchange counts signatures against max_intermediary_hops before verifying any. That field drops the "Agent → Broker → … → Exchange" chain framing: a request signed only by the party that sent it carries one signature, so the cap bounds a request a party forwarded unchanged and signed again. The hop-cap changelog entry is relabelled from "comments only": the meaning of a released field changed, which Go deployments configured with the old "+1" must follow. Comments only on the wire; gen/ and the descriptor are regenerated, and the corpus is unchanged.
… where it is enforced FORA's Broker signs alone, so a FORA request carries one signature, yet several pages still described a request travelling Agent -> Broker -> ... -> Exchange under a stack of hop signatures, said an Exchange enforces RequestConstraints.max_hops, and placed the hop cap after verification with the wrong refusal (or said only that longer chains "SHOULD be rejected"). - transaction-flow, authentication, the Exchange request flows, the reference page, role-composition, what-is-FORA and the Exchange overview now say the Exchange counts signatures against max_intermediary_hops before verifying any and refuses with resource_exhausted (429) and no typed reason, that max_hops reaches no Exchange, and that each request is answered to the party that sent it. Authentication makes the count step one; its "Multi-hop forwarding" heading keeps its text, so the links to it still resolve. - The authentication sub-agent example signs alone; the parent's authority travels in the delegation's JWT chain. - role-composition looks a key up in the directory of the party expected, not by (domain, role), and no longer cites a published reconciliation tolerance. - The academic walkthrough buys directly; the due-diligence walkthrough buys through BrokerService.ExecuteTransaction with the agent's acceptances, and the agent, holder of the bound key, fetches the content. Who files usage reports is unchanged. The live-demo purchase goes through the Broker, as the PoC walkthrough says. - The exchange-manifest page documents max_intermediary_hops, the PoC walkthrough explains its published value, the configuration error table gains the hop-cap and request-signature rows, and the operator checklist's hop step names the order and the code. - The threat model's T-DEL-4 covers signer impersonation, not only a chain. The design history marks the hop-by-hop section and the verbatim-forward sentence as superseded and records why max_hops is kept without deprecation. The hop-cap changelog entry lists the pages, mirrored on the site.
…wn outcome TransactionResultItem.refusal said an item carrying an Exchange's refusal "was not purchased", while UpstreamRefusal.code said a timeout may have completed the purchase, and BrokerTransactionResponse.totals counted such an item as uncharged without saying so. The comments now agree. With the code "unavailable" or "deadline_exceeded" the item's outcome is unknown, whether the Exchange gave no answer or answered that code, and retrying the agent's request with the same idempotency_key through the same Broker settles it, since each Exchange answers a repeat from its stored result. With any other code the Exchange decided no item and the item was not purchased. totals does not count an item with an unknown outcome, so it is then a lower bound of the charge. The wording stays within the current idempotency scope. The Broker purchase verbs' doc comments in Go, Python and TypeScript, the Python README, and the transaction-flow, Broker, Identity Service and fetch-flow pages say the same. Comments only on the wire; gen/ and the descriptor are regenerated, and the corpus is unchanged. The changelog entry is mirrored on the site.
…itical, in the protocol The critical-extensions rule told every receiver to answer with "the UNKNOWN_CRITICAL_EXTENSION reason of its RPC", but only four RPCs had one. DisputeTransaction, Register, RequestDomainVerification and ConfirmDomainVerification carry ext_critical with no such reason; GetAccountStatus has no reason family at all; BrokerService.ExecuteTransaction was told both to deny every item in the body and never to stamp a denial; and nothing said whether a Broker copies the agent's ext onto the requests it authors. - New values, appended and additive: DISPUTE_FAILURE_REASON_ 6, DOMAIN_VERIFICATION_FAILURE_REASON_ 7 and REGISTRATION_FAILURE_REASON_ 8 UNKNOWN_CRITICAL_EXTENSION (4 stays retired), each refused with invalid_argument, as the usage-report value is. A repeat registration applies the check before returning the stored record. - GetAccountStatus answers invalid_argument with no typed reason. - BrokerService.ExecuteTransaction answers OK with every item denied as DENIAL_REASON_UNKNOWN_CRITICAL_EXTENSION and contacts no Exchange, after its signature, requester and malformed-request checks and before routing. The rule against the Broker stamping a denial gains this one stated exception. - A Broker never copies the agent's request-level ext or ext_critical onto a ResourceQuery or TransactionRequest it authors; messages it carries unchanged keep their own. The file header lists every RPC's answer. The Python and TypeScript ErrorDetail tables gain the three names, error-detail-wire-vectors.json and the validation corpus are regenerated (the undefined-value cases move up by one), and the transaction-flow, extension-profiles and medical-imaging pages state the rule. Both changelogs record it.
Requester.id and Requester.domain are required, but a rule on a field of an absent message never runs, so a ResourceQuery, DiscoveryRequest or TransactionRequest that named no requester at all passed both, and the direct-request comparison of requester.domain with the signer's directory had nothing to compare. requester is now field-required on the three messages, in the style of TransactionItem.offer. Register, GetAccountStatus, reports and disputes carry no requester. The validation corpus seeds a Requester equal to the one auto-fill built, so every Requester case is unchanged, and gains a requester/missing case for each of the three messages; the other cases of those messages now carry one. The generated Pydantic models, Zod schemas and TypeScript input types require the field, so the Python and TypeScript clients, which validate an outgoing request by default, refuse a discover left with no requester before sending it. Go validates only when asked. The client requester options and the discovery stamping comments state the rule instead of describing what reference services happen to do, and a Broker comment no longer says a Broker resolves the agent from requester.id. Tests that built a query or purchase with no requester now name one. The authentication page and both changelogs record it.
…ner, in the protocol An Exchange receives the same ResourceQuery and TransactionRequest from an agent and from a Broker, and Requester.domain had one rule for a direct request and another for a request a Broker authored, with nothing saying how an Exchange tells which applies. The file header still said the Exchange does not distinguish. The file header gains "Direct and relayed requests", the one statement of the rule. On those two requests an Exchange compares Requester.domain with the host of the directory the verified signature's covered Signature-Agent member names, by the request-recipient identity rule. Equal is direct. Different is relayed, acted on only when the Exchange admits the signer as a Broker by its own policy, and otherwise refused as unauthenticated with request_auth_failure SIGNATURE_INVALID, as a direct mismatch is. No field marks a relay, and neither the manifest role nor Requester.type admits a Broker. A Broker is never relayed to, so a mismatch there is always refused. The Requester.domain comment, the ExchangeService header and the Role values point at it; a Broker's manifest declares ROLE_BROKER, which describes the document and admits nothing. The authentication page gains the rule and lists a Broker under ROLE_BROKER; the transaction-flow, production-architecture, proto-fora, Broker overview, exchange-manifest, request-flows and threat-model pages state it or link to it. The design history records why the decision rests on the signature and the Exchange's own admission policy rather than a new wire field. Both changelogs record it.
…s that exist Several samples could not work as written. The proof-of-concept curl sent a bare Signature-Agent value, no Content-Digest and no Authorization, and posted its body with --data, which strips newlines from the file the digest covers. Client samples on the walkthrough-v1, catalog-sources and Broker selection-engine pages named no signing directory, so their signers refuse to sign; the selection-engine pool did not sign at all. The fetch-flow sample read UpstreamRefusal.exchange, renamed to party, and the for-AI-agents quota sample read fields SubscriptionQuotaInfo does not have. The curl now sends the whole Web Bot Auth header set with --data-binary and points at the single-hop example. The client samples set WithSignatureAgent, signatureAgent or signing_transport_for to the requester's own directory, and the Broker pool wraps its transport in core.NewSigningTransport with the Broker's directory. The budget-reporting page names the option among the elided ones, and the scenario-walkthrough and production-architecture pages list the full covered set. Both changelogs record it.
…hat links to the protocol The for-Exchange-operators page said the Requester carries an Ed25519 signature, gave a key directory a role, and split request verification into a signature section and a forwarding section that missed the hop-count order, the refusal shapes, replay, signer-scoped revocation and how to tell a direct request from a relayed one. It is now one ordered checklist, each step linking to the authentication page rather than restating it: count the signatures before verifying any, verify each under the Web Bot Auth profile with its typed refusal and Accept-Signature, refuse a replay, check revocation against the signer's own directory, tell a direct request from a relayed one and act on a relay only from an admitted Broker, verify the agent's acceptances on a purchase and deny a non-projection in the body, and read a Broker-led query's requester as the Broker's statement. The denial list gains DENIAL_REASON_RELAY_NOT_ACCEPTED, and the for-AI-agents page drops the same key-directory role slip. Both changelogs record it.
The Unreleased section described each change in its own entry, and several entries labelled a behaviour change as comments only, so a reader upgrading from v1.0.8 had to find the breaks one paragraph at a time. Unreleased now opens with "Breaking against v1.0.8", grouped by who must act: every party, Exchange operators, agents and other clients, publishers and catalog contributors, Brokers, and SDK users. Each bullet gives the direction of the break and the remedy, and each was checked against the v1.0.8 tree: the signature hard cut, which also covers the delivery proof at the publisher edge; reason values v1.0.8's Python and TypeScript models refuse, and the request_auth_failure detail they drop; priced offer terms and non-positive metered estimates; the one-item denial in the body; the admitted-Broker rule for relayed requests; the hop cap counting every signature; three refusals that changed shape; rules that stop applying; the required requester; paths in RemoveResources; Broker-led discovery; and the SDK API changes. The remaining entries follow under "Changes since v1.0.8". The GetAccountStatus, catalog signature and idempotency entries are relabelled as behaviour changes rather than comments only.
main now carries the route-retirement gate: website/routes.txt lists every URL the site serves, and docs CI fails when the built site and the manifest disagree, or when a URL main served stops answering without a redirect. CI runs the gate on the pull request merged into main, where the manifest was main's and lacked the JSON Schemas reference page this branch adds, so it failed with "built, not in the manifest: /reference/json-schemas/". main also sends publishers from the onboarding preview to the publisher console. Resolution ledger: - textual conflicts: none. - mechanical: website/routes.txt -> regenerated from the merged build (npm run build && npm run routes); it gains /reference/json-schemas/ and nothing else, and no URL main served is retired. - semantic, auto-merged, reviewed: scripts/check-doc-conformance.sh -> union (this branch's denylist entries and deprecated-name guard, main's publisher console host allowance in the org-reference guard; disjoint regions). website/astro.config.mjs -> union (main's publisher console env field, this branch's JSON Schemas sidebar entry). - taken from main unchanged: the docs CI workflow, amplify.yml, the redirect and routes scripts and tests, redirects.json, the URL retirement guide, and the onboarding page, preview client and contact-module removal. The onboarding page's imports resolve against this branch's onboarding modules. - generated code, the validation corpus and the lockfiles: identical to this branch; main did not touch them.
The design history marked the forwarding chain superseded but recorded no reason. WG-00 section 5.2.2 lets a signer cover an earlier signature only when it leaves every component that signature covers unchanged, and a Broker changes them: on execute it posts a new body to each Exchange's URL, and on discovery, where the agent does not know the Exchanges, it originates its own queries. So it signs alone, and the Exchange takes the agent's identity from the acceptances in the body. The new section records that reasoning; the rules that verify the acceptances against the requester's directory and bind the delivery URL and a delegation to the key they verify under; what Requester.domain means on a query a Broker originated; and that holder-of-key proof at discovery through a Broker is an open protocol question. The hop-by-hop section's banner points to it.
…seded Nine sections still stated as current a rule the Broker and Web Bot Auth changes reversed. Each gains a "Superseded in part" banner that says what changed and what still stands, and keeps the text under it as the record: - the delegation-claims profile and the JWT section, where the request signer was always the holder; - the covered-header section, with a bare or empty signature-agent and a no-directory vector; - "One agent identity, one key", where an acceptance was verified against the caller's key; - the signed-leg redirect and SSRF sections, where key directories followed redirects and ALLOW_INSECURE chose their scheme; - the client defaults, with Python's ten-minute window; - the peer's-sentence section, with the monotonic window as remedy; - the catalog client, with a contributor key named by caller_id.
…ign history Four more sections stated rules this branch changed: a scope shortfall answered with SCOPE_INSUFFICIENT, a denied one-item purchase answered as a transport error, one term shape at both ends with the price inside it, and a Requester.id that could be empty. Each gains a "Superseded in part" banner. The Web Bot Auth section's lead said no option fetches a key directory in plaintext. Only the key resolvers lost that option; the public document readers still take a scheme. The lead now says so.
…er's key The Go and TypeScript agent-key options and the Python acceptance signer said an Exchange verifies an acceptance against the key of the caller its request signature identified, so a second key would be refused at execute. An Exchange verifies it against the keys currently valid in the directory requester.domain names, and on a purchase a Broker relays the request signer is the Broker. The comments now say so, and keep the reason the SDKs hold one key: the key a delivery URL is bound to is then always the key the fetch proves possession of. The sign-request vector generator described the covered set as ending in a bare signature-agent and one vector as signing with no Signature-Agent. The set ends in the signer's own keyed member, and every vector names an https origin because a signer refuses to sign without one. The vectors are unchanged.
…n all three SDKs WG-00 does not require the alg signature parameter: its signatures must carry created, expires, keyid and tag, and its own example request and signed directory response (Appendix E.2.3) carry no alg. RFC 9421 lets a verifier take the algorithm from the key (section 3.2, step 6.3), and every key a FORA verifier resolves is Ed25519. The three SDKs refused a signature without alg all the same. Every verifier now accepts a signature with no alg: the request verifier, the delivery proof of possession and the key-directory response verifier, in Go, Python and TypeScript. An alg that is present must still name ed25519, in any letter case, so an empty alg and another algorithm are refused as before. Go and Python could not tell an empty alg from a missing one, so their parsers now record whether alg is present, and a parameter set built by hand reads as present. Go's VerifiedRequest.Algorithm reports ed25519 when the signature declares none. Signers still send alg="ed25519", which widely deployed Web Bot Auth verifiers require. No refusal token, sentinel or Accept-Signature value changes, and the parity map is unchanged. The shared vectors gain a request accepted without alg, requests refused for an empty or another alg, the same three cases for the delivery proof and the directory response, and WG-00's Appendix E.2.3 response verbatim. Every existing vector is byte-identical.
…take the algorithm from the key The authentication page said a FORA verifier accepts a signature made by any Web Bot Auth library and that FORA departs from WG-00 in one place, the JWK alg EdDSA. Two more directory rules depart from the draft: a directory response must be signed, where WG-00 Appendix B.1 only recommends it, and each key must carry not_before and not_after, which WG-00 does not define, so a key without both is never valid. A directory written to WG-00 alone fails both. The page now lists the three departures in one place, with the two limits WG-00 leaves to a verifier: Ed25519 keys only, and a Signature-Agent member whose type is not directory is ignored. It states that a verifier accepts a signature with no alg, the key deciding the algorithm, and refuses a present alg that is not ed25519, an empty one included. The note that called the signature alg one WG-00 requires is corrected; the draft does not require it. The JsonWebKey and WBAFile comments state the same, so the reference page renders it; gen/ is regenerated. The Unreleased Web Bot Auth entry, in both changelogs, lists the departures and the accepted missing alg, and the design history records why a verifier takes the algorithm from the key and why the validity bounds stay required.
…the agent's on every route, in the protocol Nothing said whose purchase a Broker relays. The account was resolved from the verified signature, which on a relayed purchase is the Broker's, and the idempotency key was scoped to the pair (Broker, requester), so one signed consent could execute again under a second Broker identity, against the acceptance's own promise that it cannot be replayed under a different transaction. The file header gains "Accounts". An account at an Exchange belongs to the agent's key directory: every currently valid key it publishes acts for the account, a key rotation keeps the account and its billing_ref, and Requester.id never selects one. The request signature authenticates the directory on a direct request, and the agent's acceptances on a relayed purchase. Billing, the account denials, the reporting gate, ownership of the transaction and the right to report and dispute refer to the agent's account; the Exchange keeps the Broker for attribution only. The agent files its usage reports and disputes itself. "Idempotency" scopes a key to the agent whatever the route, keeps the another-tenant rule, verifies the agent's authorization before any idempotency state is created or answered from, and replays a stored result with its delivery binding. An unknown outcome is retried on any route; a new attempt after a stored denial takes a new key. The authentication, transaction-flow, request-flows, threat-model and reference pages state the rule, the request-flows sample resolves the agent's account before its idempotency check, the SDK Broker retry comments and the Python README say any route, both changelogs record it, and the design history records why. gen/ is regenerated.
…e guides The Broker overview and deployment pages described a Reporting Relay and a Dispute relay that forwarded an agent's usage reports and disputes, with a ReportingRelay Go interface, a metric and a diagram of the agent reporting through the Broker. The Broker service has no call for either, and the agent files its reports and disputes itself, with the issuing Exchange, for a purchase made through a Broker too. The Broker pages drop the relay, its interface, metric, repository entry and diagram edges, and state the retry and idempotency rule on any route. The agent SDK, due-diligence, scenario, money-flow and getting-started pages say the agent holds the account and files its own reports. The request-flows self-signup path registers through ExchangeService.Register, and the Exchange scaling and storage pages key the idempotency store by the agent and its key. Two request-flows sequence diagrams did not parse, because a note carried a semicolon, which mermaid reads as the end of a statement; the notes now use commas. The changelog entry for the change lists these pages.
Requester.scopes was called the enforcement mechanism for enterprise roles and subscription entitlements, LicenseTerm.scopes said an Exchange returns an offer for a term iff the requester's scopes cover it, and the threat model listed scope pollution as preventable at protocol level. Nothing verifies those scopes: they are part of the signed request, attributable to the signer, and unverified. The comments and docs now say so. Scope coverage decides which terms an Exchange presents, and it is not access control. A false statement is traceable to the signing key, its directory and its account, and is settled through reconciliation and the account relationship. An Exchange MAY require a verified delegation that grants a term's scopes before it presents the term, and answers a requester that presents none as if the term did not exist. The purchase-time entitlement denials are unchanged. The threat model's scope-pollution entry says what reconciliation compares and moves to the reconciliation and contractual rows. Its holder-binding entries name the key the request proves possession of, which on a relayed purchase is the acceptance key. The design history records that verifying scopes is deferred. Comments only in the proto; regenerated artifacts carry the new text, and the generated-client test that pins the opening words of the Requester.scopes comment pins the new ones.
… the protocol A delegation's holder binding needs a key the request proves possession of: the request signature on a request the agent sends itself, the agent's acceptances on a purchase a Broker relays. A query a Broker originated has neither, because the Broker signs it alone and is never delegated to. The pages left the outcome open, and the academic walkthrough had an Exchange verify a delegation against the key that signed a query the Broker sent, which can only fail. The Exchange does not honour such a delegation and answers as if none were present, so the query gets what a query without a delegation gets. An Exchange that requires a verified delegation before it presents a scoped term therefore presents no such term on that query, and an agent that needs those terms queries the Exchange directly. How an agent proves a holder-of-key entitlement at discovery time through a Broker stays an open protocol question. The Delegation, Requester.delegation and Requester.domain comments state the rule, and max_hops no longer says its open question is tracked elsewhere. The request-flows discovery sample tells a direct query from one a Broker originated and admits the Broker, verifies a delegation and consults an account only on a direct query, and resolves that account from the directory rather than the key. The academic walkthrough states the subscription scope its term requires and verifies the delegation at purchase, and its balance check names the directory's account. The enterprise guide has the Broker verify the IAM delegation and state the scopes. The design history records the rule. Comments only in the proto; regenerated artifacts carry the new text.
Two subscription examples relied on a delegation to reach a term whose scopes the request never stated. Walkthrough-v1's Scenario B stated quote, earnings and news scopes while its term requires subscription:enterprise-sub-HF-001, and the authentication page's subscription agent stated subscription:marketdata-2026 while its delegation granted only quote and earnings scopes. Scope coverage is what decides presentation, so neither term would have been shown. Both examples now state the subscription scope and have the delegation grant it, and they describe the Exchange presenting the term because the stated scopes cover it, with the delegation as this Exchange's own requirement. The Scenario B Go sample sets the scopes on the requester and the delegation. The money-flow page listed a broker_fee field that no message has. A Broker's fee is disclosed out of band, as the selection engine page says, so the page now names one transparency field and says so.
…fuse a mismatch only on a purchase, in the protocol The protocol said a Broker refuses a requester.domain that is not the signer's directory on every request it receives, Resolve included. Before that sentence it named only BrokerService.ExecuteTransaction, and the protocol's author had decided Resolve the other way in the reference implementation's conformance suite: the Broker verifies the agent's signature, refuses nothing for requester.domain, and names the host of the agent's verified directory on every query it fans out. The protocol now states that decision. At ExecuteTransaction a mismatch is still refused, because the agent's acceptances sign requester.domain and every Exchange resolves the acceptance keys from it. At Resolve the Broker answers the request, and every query it originates names the host of the directory the agent's verified signature names, so a host the agent wrote that names any other directory is never forwarded. The file header's "Direct and relayed requests", Requester.domain and DiscoveryRequest.requester say so, and gen/ is regenerated (comments only). The authentication, Broker overview and scenario pages, the Go Broker handler's doc comment, both changelogs and the design history follow.
…te is, in the TypeScript SDK Fastly Compute's JavaScript runtime has no AbortController or AbortSignal. fetchDocument built an AbortController on every read, before its try, so on Fastly every document read of the edge entry threw a ReferenceError: the manifest, a key directory, a revocation list, a license document. createWBAOfferDirectoryFetch answers absent for any failure, so a Fastly edge could not load the Exchange's offer-signing keys and refused every paid fetch. The Workers types and the no-eval sandbox both declare AbortController, so nothing noticed. The read now needs no AbortController. Its one deadline is still a timer the read races against the transport and the body. Where the runtime has AbortController, the transport is also handed a signal the read aborts then; otherwise init carries only redirect. At the deadline the read refuses first, then aborts the signal, then cancels a body it is still reading and releases an answer that arrives late. So a transport that fails as its signal aborts no longer reports its own error in place of the deadline, and a body cut off there is refused rather than returned short. Every failure of the read, its setup included, is a DirectoryUnavailable with its cause. The cap, the redirect rules and the key directory's no-redirect rule are unchanged. The bounds tests run on a runtime with AbortController and on one with neither global, and each refusal now shows its transport was reached. A new test runs the bundled edge entry with only the globals Fastly Compute declares: every reader, a key directory signed in that context and read through createWBAOfferDirectoryFetch and createWBAKeyResolver, the cap, the redirect rules and the deadline. The edge guard now also bundles the edge-safe surface and fails on any global it reaches that the Workers types declare and Fastly lacks, unless the code checks for it first. The edge sandboxes share their build and context in tests/edge-sandbox.ts. Go and Python are unchanged, and the parity map is unchanged.
… the changelog, the design history and the Fastly adapter notes The changelog's entry on TypeScript edge runtimes said the read passes its signal to the transport. It does so only where the runtime has AbortController. The entry now says the bounds need none, that the read cancels and refuses a body cut off at the deadline, that a test runs the edge readers with only the globals Fastly Compute declares, and that the edge guard fails on any global the edge-safe code reaches that the Workers types declare and Fastly lacks. The website mirror carries the same text. The design history's entry on the reader's own bounds records why the deadline is the read's own timer: Fastly Compute has neither AbortController nor AbortSignal and its fetch takes no signal, so a polyfill would add a dependency and stop nothing. It also records why the read refuses before it aborts and cancels, and why only a test with Fastly's globals and the guard could have noticed. The Fastly section of the CDN adapters page says what the TypeScript SDK needs there: verifyEd25519, since the runtime has no WebCrypto Ed25519, and nothing for AbortController.
… and let the directory reads take a clock, in the SDKs Every failure of a document read was one error, so a caller that must answer a missing key directory differently from an unreachable one could only read the message: a 404, a 503, a refused redirect and a dial failure all read as an unavailable document. The reads now name two answers, each still an unavailable document: a 404 on any read (Go ErrDocumentNotFound, Python DocumentNotFoundError, TypeScript DocumentNotFound), and any 3xx answering a key-directory read (ErrRedirectRefused, RedirectRefusedError, RedirectRefused). Go wraps each together with ErrDirectoryUnavailable, and Python and TypeScript subclass it, so every existing check for an unavailable document still matches. Go's key-directory read now takes every redirect back as the answer and refuses any 3xx by its status: its client consulted the redirect hook only for a followable status with a Location, so a 300, a 304 or a redirect with no Location read as a plain status, and a refused one copied the raw Location, credentials included, into the error. A response whose Content-Digest does not match its body is unsigned in Go too, as Python and TypeScript already reported it. The directory reader and the offer-directory fetch judged response signatures at the wall clock, while the key resolver and the offer-key cache took an injected one. Both now take a clock: ReadOptions.Now, and an optional trailing WBADirectoryFetcherOptions on Go's NewWBADirectoryFetcher, whose released call still compiles; read_wba_directory(now=) and create_wba_offer_directory_fetch(now=) in Python; ReadDocumentOptions.now and WBAOfferDirectoryFetchOptions.now in TypeScript. The Python and TypeScript offer fetches still contain every failure as None and undefined. A new corpus, document-fetch-vectors.json, emitted by driving the Go read, pins the verdict for each status in all three SDKs. The parity map and matrix list the two new failures and the new options struct. The READMEs name the failures and the clocks; the Go list also names the two failures it had left out, and a Python docstring no longer says the offer-key prefetch gathers without return_exceptions. A TypeScript test of a transport that follows redirects anyway now reaches its server rather than failing on TLS.
…changelog, the design history and the SDK overview The changelog and its website mirror record the two failures, the new corpus, the Go digest-mismatch change and the clocks, under the document readers and the Web Bot Auth directories. The design history records why the reads name a 404 and a redirect and leave their meaning to the caller, why a redirect is told by its status, why a directory that does not decode stays unavailable, and why one clock serves the offer-key cache and its fetch. The SDK overview names the two failures, and no longer says every reader fetches through the guarded client: the TypeScript edge entry reads through the fetch it is given.
legendko
approved these changes
Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For review. No version is cut and nothing is tagged: this is the unreleased line after v1.0.8. The complete list, with the reasons, is in the Unreleased section of
proto/CHANGELOG.md(mirrored on the website changelog).This branch contains the Web Bot Auth profile from #48 and builds on it, so it supersedes #48.
Request signing: Web Bot Auth everywhere (#47)
Signature-Agentwhose member key equals the label,tag="web-bot-auth", a 64-byte nonce, and a lifetime of at most five minutes. FORA RPCs also cover@method,@target-uri,content-digestandauthorization.Signature-Agentmember names. Covering an earlier signature is optional; if present it must be complete. A Broker signs its own re-packaged request and never forwards an agent's request unchanged.application/http-message-signatures-directory+json, 200 with no redirect, and a signed response. Revocation lists andfora.jsonmay follow up to five redirects. Each signature's revocation is scoped to its signer.@methodand@target-uri.web-bot-auth0.2.0 verifies the SDKs' signatures, and the SDKs verify its signatures once FORA's components are added. The page examples and the draft's Appendix E.2 vectors reproduce.Broker purchases
BrokerService.ExecuteTransactionreturningBrokerTransactionResponse(items in request order, per-Exchange outcomes, totals per currency). An Exchange's whole-sub-request refusal rides on each item asUpstreamRefusal. A provider's relay opt-out isDENIAL_REASON_RELAY_NOT_ACCEPTED, per item.Money and reports
Offer.pricingis an offer's only price; the term copy carries none.BUDGET_EXCEEDED.billing_idisMALFORMED(invalid_argument); a report for another agent's transaction is answered asTRANSACTION_NOT_FOUND(not_found).Requests and refusals
Requester.idandRequester.domainare required; a direct request's domain is its signer's.RequestAuthFailure(SIGNATURE_MISSING/INVALID/STALE) on every signed surface, the catalog included.UNKNOWN_CRITICAL_EXTENSIONfor push, purchase item and report.EXCHANGE_NOT_LISTEDfor a push the manifest does not authorize.SCOPE_INSUFFICIENT,RESTRICTION_NOT_SATISFIEDandrestriction_mismatchesare deprecated and never sent. Restrictions are declared terms, not enforced by the Exchange.GetAccountStatusanswersNOT_FOUNDfor an agent the Exchange knows with no account.RemoveResourcesnames resources by domain and path.PushResourcesRequest.caller_idis deprecated: the caller is the signer.X-Content-RulesandX-FORA-Exchangeare specified.ErrorDetailunchanged.SDKs (Go, Python, TypeScript)
ErrorDetaildecoding, an admin client, document readers, identity helpers, and a public strict check (SDK capabilities for conformance harnesses: pre-signing hook, raw mode, strict decoding, error decoding, admin client #51).Checks
scripts/ci-local.shpasses, as do the Python (2569) and TypeScript (3568, under Zod 3 and Zod 4) suites.buf breakingpasses against v1.0.0 and v1.0.8.Breaking for implementers
Verifiers built from this line refuse the v1.0.8 bare
Signature-Agentandapplication/jwk-set+jsondirectories. The PythonKeyResolver.resolveand the TypeScriptRequestKeyResolver.resolvetake the signature's directory; see the changelog for every API change.