Skip to content

Web Bot Auth signing, Broker purchases, the final money rules and the SDK harness capabilities - #52

Merged
legendko merged 167 commits into
mainfrom
feat/sdk-harness-capabilities
Oct 15, 2026
Merged

legendko merged 167 commits into
mainfrom
feat/sdk-harness-capabilities

Conversation

@KonstantinMirin

Copy link
Copy Markdown
Contributor

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)

  • Every FORA request signature is a Web Bot Auth signature per draft-ietf-webbotauth-httpsig-protocol-00: an sf-dictionary Signature-Agent whose 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-digest and authorization.
  • Several signatures are each verified on their own, against the directory that their own covered Signature-Agent member 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.
  • Key directories: https, application/http-message-signatures-directory+json, 200 with no redirect, and a signed response. Revocation lists and fora.json may follow up to five redirects. Each signature's revocation is scoped to its signer.
  • The delivery proof of possession at the edge is a Web Bot Auth signature plus @method and @target-uri.
  • Interop: Cloudflare's web-bot-auth 0.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.ExecuteTransaction returning BrokerTransactionResponse (items in request order, per-Exchange outcomes, totals per currency). An Exchange's whole-sub-request refusal rides on each item as UpstreamRefusal. A provider's relay opt-out is DENIAL_REASON_RELAY_NOT_ACCEPTED, per item.

Money and reports

  • Offer.pricing is an offer's only price; the term copy carries none.
  • A metered purchase charges E × R, or 1 × R when no estimate is stated, and that charge is final. A usage report is a record of the quantity consumed; its quantity is unrestricted. Disagreement about consumption is a dispute. Budget arithmetic is left to the party that applies it; the budget is reported as BUDGET_EXCEEDED.
  • Report refusals: a wrong billing_id is MALFORMED (invalid_argument); a report for another agent's transaction is answered as TRANSACTION_NOT_FOUND (not_found).

Requests and refusals

  • Requester.id and Requester.domain are required; a direct request's domain is its signer's.
  • RequestAuthFailure (SIGNATURE_MISSING / INVALID / STALE) on every signed surface, the catalog included.
  • UNKNOWN_CRITICAL_EXTENSION for push, purchase item and report. EXCHANGE_NOT_LISTED for a push the manifest does not authorize.
  • SCOPE_INSUFFICIENT, RESTRICTION_NOT_SATISFIED and restriction_mismatches are deprecated and never sent. Restrictions are declared terms, not enforced by the Exchange.
  • Discovery has no side effects and is safe to retry. GetAccountStatus answers NOT_FOUND for an agent the Exchange knows with no account.
  • RemoveResources names resources by domain and path. PushResourcesRequest.caller_id is deprecated: the caller is the signer.
  • Edge discovery headers X-Content-Rules and X-FORA-Exchange are specified.
  • MCP tool errors carry the FORA ErrorDetail unchanged.

SDKs (Go, Python, TypeScript)

Checks

scripts/ci-local.sh passes, as do the Python (2569) and TypeScript (3568, under Zod 3 and Zod 4) suites. buf breaking passes against v1.0.0 and v1.0.8.

Breaking for implementers

Verifiers built from this line refuse the v1.0.8 bare Signature-Agent and application/jwk-set+json directories. The Python KeyResolver.resolve and the TypeScript RequestKeyResolver.resolve take the signature's directory; see the changelog for every API change.

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
legendko merged commit 0b2f33e into main Oct 15, 2026
4 checks passed
@legendko
legendko deleted the feat/sdk-harness-capabilities branch October 15, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants