Metadata-Version: 2.4
Name: fora-protocol-sdk
Version: 1.1.0
Summary: FORA protocol SDK for Python: the IO-free protocol mechanics (thumbprint, signed-URL verification, RFC 9421 request signing and verification, offer and acceptance signatures, cross-field validation), the key and endpoint resolvers and the Connect-unary JSON client.
License-Expression: Apache-2.0
Project-URL: Homepage, https://fora-protocol.org
Project-URL: Source, https://github.com/FORA-Protocol/protocol
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fora-protocol==1.1.0
Requires-Dist: cryptography>=44
Requires-Dist: httpx<0.29,>=0.27
Requires-Dist: httpcore<2,>=1.0
Requires-Dist: pydantic>=2.9
Requires-Dist: rfc8785>=0.1.2
Requires-Dist: jsonschema>=4.23
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Requires-Dist: types-jsonschema>=4.23; extra == "test"
Dynamic: license-file

# fora-protocol-sdk

FORA protocol libraries for Python, co-located with the contract (ADR-020). One
distribution, the `fora_sdk` package, layered so that the parts holding no keys and doing
no I/O can be used on their own.

| Layer | Module | What it is |
|---|---|---|
| **L0** | `wire.models`, `vocab.*` | generated wire types, from the separate `fora-protocol` distribution (consumed, never rebuilt) |
| **L1** | **`fora_sdk`** (top level) | stateless, **IO-free** protocol mechanics — RFC 9421/7638 crypto, offer and acceptance signatures, signed URLs, validation. Includes `fora_sdk.core` (transport-neutral: `Verifier`, `VerifiedOffer`, `DiscoveryResult`), `fora_sdk.window` (`Window`) and `fora_sdk.server_verify` (the server side of RFC 9421). Byte-parity-guarded against the `sdk/go` oracle |
| L2 · I/O | **`fora_sdk.resolvers`** | the only tier that dials the network: Web Bot Auth directories, well-known JWKS and `fora.json`, plus the SSRF-guarded HTTP client. Which faces take that client by default is decided by URL provenance, below — `WellKnownEndpointResolver` is the one request-derived face that still defaults to a plain client |
| L2 · transport | `fora_sdk.client` (the async Connect-unary JSON client: the agent verbs **`discover` · `execute` · `report_usage` · `dispute` · `fetch`**, the account-setup verbs **`register` · `get_account_status`**, the broker verbs **`resolve` · `execute`** and the publisher verbs **`push_resources` · `remove_resources` · `refresh_catalog`**) · `fora_sdk.sync` (the same faces, blocking) | state is injected, never owned |

```sh
pip install fora-protocol-sdk
```

Python 3.11 or later. The package ships `py.typed`, so mypy reads its annotations.

`httpx` and `httpcore` are installed with every consumer, including one that uses only
the IO-free mechanics: keeping them non-optional is what makes their version ceilings,
which guard the resolvers' SSRF seam, bind every install.

The generated wire types are a separate distribution, `fora-protocol`: Pydantic v2 models
at `wire.models` and the registered vocabulary at `vocab.*`. This package pins it to its
own version and installs it as a dependency. It owns the top-level import names `wire` and
`vocab`, so it can collide with another distribution that owns either name; install it
into an environment that does not. Its README explains the tradeoff.

---

## A complete agent

Everything below is one working client: identity from a seed, offers verified against
keys fetched from the issuing Exchange, a purchase, a bound fetch, and a usage report.
The rest of this document explains the pieces it composes.

<!-- fora:agent-example — executed verbatim by sdk/python/tests/test_readme_agent_example.py -->
```python
import asyncio
import os
import sys
import time

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from fora_sdk.core import Mode, StaticOfferKeyResolver, Verifier
from fora_sdk.resolvers import (
    CachedOfferKeyResolver,
    WellKnownEndpointResolver,
    create_wba_offer_directory_fetch,
    guarded_client,
)
from fora_sdk.signing_transport import SigningTransport
from fora_sdk.sync import Client, ClientConfig
from fora_sdk.thumbprint import thumbprint
from vocab.functiontokens import AI_INPUT

# This agent's identity, as it states it to an Exchange.
AGENT = {"id": "agent-1", "domain": "agent.example", "type": "REQUESTER_TYPE_AGENT"}

# Where THIS agent publishes its own signing key: the Web Bot Auth key directory at
# {AGENT_DIRECTORY}/.well-known/http-message-signatures-directory, served as
# application/http-message-signatures-directory+json and signed by the key it lists.
# Every request signature names it in its own covered Signature-Agent member,
# sig1="https://agent.example"; the Exchange fetches that directory and looks for the
# key whose RFC 7638 thumbprint equals the keyid below. It is the https origin alone,
# with no path. Publish before you call.
AGENT_DIRECTORY = f"https://{AGENT['domain']}"

# https in production. A local sandbox serving plaintext sets FORA_WELLKNOWN_SCHEME=http,
# and ALLOW_INSECURE=true for the guarded transports.
SCHEME = os.environ.get("FORA_WELLKNOWN_SCHEME", "https")


def buy_and_fetch(*, exchange: str, uri: str, seed: bytes) -> bytes:
    """Discover an offer for `uri`, buy it, fetch the bytes, and report the usage."""
    # 1. Identity. The RFC 9421 keyid IS the RFC 7638 thumbprint of the agent's public
    #    key, which is also the value a delivery URL gets bound to. One key, one name.
    #
    #    signature_agent names the directory that key is published in. It is required:
    #    a signer given none refuses to sign, and the call fails locally as MALFORMED
    #    before anything is sent.
    public = Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw()
    signer = SigningTransport(
        signer_seed=seed, keyid=thumbprint(public), signature_agent=AGENT_DIRECTORY
    )

    # 2. Where this Exchange serves its API, read from its own /.well-known/fora.json
    #    rather than from configuration. The same resolver later routes the usage report
    #    back to whichever Exchange issued the offer.
    #
    #    The client is passed in rather than left to default. This resolver's host comes
    #    off an offer, so a third party chose it, and its default is still the plain
    #    client — the one request-derived face that has not caught up with the rule.
    endpoints = WellKnownEndpointResolver(scheme=SCHEME, http=guarded_client())

    # 3. Offer-signing keys, from the Exchange's Web Bot Auth directory — the only place
    #    they are published. Fetched and TTL-cached with each entry's expiry clamped to
    #    the key's own not_after, then frozen into the map the (synchronous) Verifier
    #    resolves against. STRICT plus a map that resolves nothing rejects every offer:
    #    that is the fail-closed posture, not a bug.
    #
    #    `revoked` is NOT passed, and that is a choice worth making deliberately. It
    #    screens a candidate key by thumbprint and exchange against that exchange's own
    #    revocation list, so leaving it out waives emergency revocation. It is
    #    defensible here because this function fetches the directory and spends the
    #    keys inside one call. A client that holds its key map for hours must pass a
    #    predicate `revoked(thumbprint, exchange)` that answers from that exchange's own
    #    list (`WBAKeyResolver.revoked` has this shape, and knows a directory's list once
    #    it has fetched that directory), and must re-run the prefetch rather than freeze
    #    one map for its lifetime — a frozen map keeps serving a key after its TTL and
    #    its not_after have both passed.
    directory = CachedOfferKeyResolver(fetch=create_wba_offer_directory_fetch(scheme=SCHEME))
    keys = asyncio.run(directory.prefetch([exchange]))
    verifier = Verifier(
        mode=Mode.STRICT,
        resolver=StaticOfferKeyResolver(keys),
        now=lambda: int(time.time()),
    )

    config = ClientConfig(
        base_url=endpoints.resolve_endpoint(exchange),
        signer=signer,
        requester=AGENT,
        verifier=verifier,
        endpoint_resolver=endpoints,
    )

    with Client(config) as client:
        # 4. Discover. Every offer arrives already sorted into verified or rejected, and
        #    a rejected one keeps its reason instead of being dropped silently.
        found = client.discover({"exchange": exchange, "uris": [uri]})
        offers = found.verified()
        if not offers:
            refused = [r.reason for r in found.rejected()]
            raise RuntimeError(f"no verifiable offer for {uri}: {refused}")

        # 5. Buy it. execute() accepts only a verified offer, so an unverified one
        #    cannot be paid for by mistake.
        item = client.execute(offers[0]).items[0]

        # 6. Fetch. The delivery URL is bound to the agent's thumbprint and the client
        #    presents the matching proof of possession, so a copied link fetches nothing.
        #    The delivery edge checks the URL and the proof.
        content = client.fetch(item.retrieval_endpoint)

        # 7. Report what was used. It goes to the Exchange the offer named, resolved the
        #    same way as step 2 — never to whatever base_url happened to be configured.
        client.report_usage(
            {
                "exchange": exchange,
                "transaction_id": item.transaction_id,
                "billing_id": item.billing_id,
                "usage": {"consumed_quantity": len(content.body), "function": [AI_INPUT]},
            }
        )

    return content.body


if __name__ == "__main__":
    sys.stdout.buffer.write(
        buy_and_fetch(
            exchange=os.environ["FORA_EXCHANGE"],  # a bare domain, e.g. "exchange.example"
            uri=sys.argv[1],
            seed=bytes.fromhex(os.environ["FORA_AGENT_SEED"]),
        )
    )
```

`fora_sdk.client.Client` is the same surface with `await`; nothing else changes. That one
is the core, and `fora_sdk.sync` is a blocking facade over a synchronous httpx client
rather than an `asyncio.run` wrapper, which would break inside a running event loop.

That example is not decoration: a test extracts this exact block from this file, runs it
against an in-process Exchange, and fails if any name in it stops resolving.

---

## L1 — the protocol mechanics

Stateless, **no network I/O**, no secret custody, no state. The same code the Broker,
Exchange, MCP adapter and edge worker build on.

```python
from fora_sdk import thumbprint, sign_request, verify_request
```

**RFC 9421 request signing and verification, under the Web Bot Auth profile.**
`sign_request` covers `@method`, `@target-uri`, the exact body bytes (Content-Digest),
the Authorization header and the signature's own Signature-Agent member; `verify_request`
checks a received one against a key you hold, and
`fora_sdk.server_verify.verify_request_server` is the framework-agnostic server face.
`verify_multisig_request_server` verifies a request carrying several signers' signatures:

```python
now = int(time.time())
signed = sign_request(
    method="POST", url=url, body=body,
    authorization="",                    # covered, and sent even when empty
    signer_seed=seed, keyid=keyid,
    created=now, expires=now + 300,      # the window, minted by the caller; 300 s at most
    signature_agent=agent_directory,     # your key directory's https origin; required
    nonce=nonce,                         # fresh per signature; SigningTransport mints one
)
verdict = verify_request_server(
    method="POST", url=url, body=body, headers=headers,
    resolver=resolver, replay_store=replay_store, now=now,
)
```

One signature labelled `sig1` by an agent whose directory is `https://agent.example`
carries:

```
Signature-Agent: sig1="https://agent.example"
Signature-Input: sig1=("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created=C;expires=E;keyid="K";alg="ed25519";nonce="N";tag="web-bot-auth"
Signature: sig1=:<standard base64>:
```

Signature-Agent is a structured-field Dictionary, so a party that forwards a request adds
its own signature with `append_signature`: the next free label (`sig2`), its own member in
the same dictionary, and the earlier signatures left untouched. The appended signature
covers only its own request and member, unless `cover_previous=True`: then it also covers
the last earlier signature completely, which only a party forwarding the request unchanged
may do. A verifier resolves each signature's keyid in the directory that signature's own
member names, passing that origin to `KeyResolver.resolve(keyid, directory)`, and reports
it on the verdict. Labels carry no meaning; coverage of an earlier signature is optional and
must be complete. The verifier also accepts what other Web Bot Auth signers send — the
legacy String form `"https://agent.example"` on a single signature, a member key that
differs from the label, a `type=directory` member parameter, no nonce, no `alg` (the
Ed25519 key decides the algorithm; an `alg` that is present must be `ed25519`) — and
refuses the bare unquoted value. A refusal for a missing component, a wrong tag, a refused
Signature-Agent form or a member that is not an https origin carries `accept_signature`, the `Accept-Signature` value
(`accept_signature()`) to answer the 401 with. A signer given no directory, a value that
is not an https origin (`check_https_origin`), or a window over `MAX_SIGNATURE_LIFETIME`
raises a `SignatureProfileError` before anything is signed.

**Offer authenticity.** The signature covers the offer's pricing, terms and expiry, so an
offer must be verified before anything selects on it. That is what `fora_sdk.core.Verifier`
does in bulk, and `sign_offer_jcs` / `verify_offer_acceptance_jcs` are the primitives
underneath it. Canonicalization is RFC 8785 JCS through a vetted library, never
hand-rolled.

**Signed delivery URLs and proof of possession.** Byte-identical with the edge worker:

```python
url = sign_ed25519_signed_url(raw_url, seed=seed, kid="ex.v1", agent_id=tp, exp=exp)
verdict = verify_ed25519_signed_url(url, now=now, resolve_key=resolve)
proof = verify_agent_binding(method="GET", url=url, headers=headers,
                             agent_id=verdict.agent_id, now=now)
```

The proof is a Web Bot Auth signature covering `@method`, `@target-uri` and the agent's
own Signature-Agent member (`sig1="<the agent's directory>"`), with the raw public key in
`X-FORA-Agent-Key`: a GET has no body to digest, and the signed URL is itself the
credential. The verifier requires those components AT LEAST, so a Web Bot Auth library's
proof that also covers `@authority` or a header verifies; a refusal the fetcher can fix
carries `accept_signature` (`POP_ACCEPT_SIGNATURE`). `sign_agent_binding` returns the four header values as an `AgentBinding`; the
client passes a fresh 64-byte nonce, and the verifier requires the `web-bot-auth` tag and
the three-way identity agent_id == keyid == thumbprint(presented key).

**License-term pre-check.** The two tiers an Exchange applies to a pushed catalog entry,
runnable by a publisher before signing — the wire rules over the entry as given, then
canonicalization and registry membership over a copy of its terms:

```python
verdict = validate_resource_entry(entry)   # never modifies entry
normalize_resource_entry(entry)            # the form the Exchange stores
```

**Money.** Exact decimal in and out, canonical decimal string on the wire:
`parse_money`, `format_money`, `canonicalize_money`. Never floats.

**Metered offers.** A `PER_UNIT` offer may carry an estimate. The purchase charges
estimate × rate, or one unit's rate without an estimate, and the charge is final.
`is_metered_offer(offer)` says whether an offer is metered, and
`check_metered_estimate(offer)` raises on a metered offer whose stated estimate is not
positive; the `Verifier` rejects such an offer.

**One price per offer.** An offer's price is `offer["pricing"]`, and the term it sells
carries none. `check_offer_terms_unpriced(offer)` raises `ValueError` on a priced term;
`sign_offer_jcs` refuses to sign such an offer, and the `Verifier` rejects one.

**Registration schema.** An Exchange may publish a JSON Schema for the
`registration_data` it expects. Both ends validate against it, so the rules live in one
place: 2020-12 only, same-document `$ref` only, size, depth and evaluation caps, and a
`pattern` alphabet all three SDK languages express identically.

```python
schema, verdict = compile_registration_schema(raw)
```

**Do not discard that verdict.** The two callers read a non-accepted one differently, and
getting it backwards is the easy mistake. A *client* pre-checking a payload skips the
check and sends anyway, because the Exchange's enforcement decides and a local check that
could not run must not veto your own user. An *Exchange* compiling its own configured
schema treats anything but accepted or not-published as a misconfigured deployment, and
must not advertise a schema it is not enforcing.

**Routing and audience.** `is_bare_host` and `host_anchored` are the pure checks that
precede a signed call to an address a network party named. `check_audience` is the other
direction: a request arrived, does it name *this* Exchange? The signature does not answer
that — it proves the sender signed the URL it dialled, and that URL came out of a fetched
manifest, so a poisoned resolution redirects the request while every signature still
verifies.

<!-- fora:l1-faces — every backticked name between these markers is resolved against
     the package by sdk/python/tests/test_readme_agent_example.py. Extend the region
     to cover more of this document; do not add names outside it to dodge the check. -->
**Also:** `errordetail` (the typed error taxonomy and its constructors),
`generate_idempotency_key`, `apply_scopes`, `hash_url`, `monotonic_window`,
`check_https_origin`, `accept_signature`, `sign_directory_response` and
`verify_directory_response`.
<!-- /fora:l1-faces -->

---

## L2 · I/O — `fora_sdk.resolvers`

The network-fetching tier. Everything that dials a host a third party can influence lives
here, so the pre-auth-reachable network surface never enters the pure core. Almost all of it
dials through one SSRF-guarded HTTP client; `WellKnownEndpointResolver` is the exception
and still defaults to a plain one, which is a known gap rather than a decision. Pass it
`http=guarded_client()` until that default changes, as the example above does.

- **Key resolvers** — `WellKnownKeyResolver` (well-known JWKS, TTL-cached) and
  `WBAKeyResolver` (Web Bot Auth directory, revocation- and expiry-aware, with a
  background poller). A key directory is fetched with no redirect, must be served as
  `application/http-message-signatures-directory+json`, and only the keys that signed its
  response (`verify_directory_response`) are handed out. A directory that answers 404
  raises `DocumentNotFoundError`, and one that answers with any 3xx `RedirectRefusedError`,
  both subclasses of `DirectoryUnavailableError`. A Signature-Agent member is always
  an https origin and its directory is always fetched over https; a local sandbox serves
  the directory over TLS and injects an `http=` client that trusts it.
- **Endpoint resolver** — `WellKnownEndpointResolver` discovers an Exchange's own service
  endpoint from `/.well-known/fora.json`, host-keyed and cached. Its exits are worth
  knowing apart, because the difference decides whether a caller should retry:
  `ManifestVersionRefusedError` when the document carries a `ver` this reader does not
  accept or none at all, `NoEndpointError` when it advertises none, and
  `EndpointRefusedError` when it advertises one this resolver will not hand back. All
  three are verdicts and therefore final; anything else is a transport failure and worth
  retrying.
- **Offer keys** — `CachedOfferKeyResolver` caches an Exchange's offer-signing key with an
  expiry clamped to the key's own `not_after`, and `create_wba_offer_directory_fetch` is
  the fetch it runs on. Give both the same `now=` clock, so the directory's response
  signatures and the key selection are judged at one time. `active_ed25519_key_screened`
  and its siblings are the underlying window-and-revocation selection.
- **Registration requirements** — `WellKnownRequirementsReader` reads what one Exchange
  asks of a registration from the same manifest, and holds no document cache: the contract
  requires the terms digest to come from a freshly fetched document.
- **The guarded client** — `guarded_client` and `guarded_async_client` are the one
  construction path a third-party-influenceable fetch uses. `ssrf_guard` and
  `async_ssrf_guard` are the transports underneath, for a caller wiring its own.

**Which default a resolver takes follows its URL's provenance, and that is the whole
rule.** A fixed, operator-chosen address — an on-premise JWKS — may legitimately be
private, and the operator rather than an attacker chose it. A request-derived host — the
directory named by a `Signature-Agent` header, an Exchange domain read off an offer —
takes the guarded client, because the party choosing the address is not the party running
the process.

**Redirects: the guarded client follows, a signed leg refuses.** Following a bounded chain
is right for a public well-known document, where the address is re-pinned and the scheme
re-vetted at every hop — the manifest and a revocation list follow up to five. A key
directory is the exception: its address is the origin its signer committed to, so every
directory fetch refuses a redirect. It is wrong for anything carrying a credential, so the content
fetch and the RPC legs install their own refusal: following a redirect either replays a
proof bound to the old URL, or hands a fresh proof of possession of the agent's key to
whatever host the first hop named.

Two orthogonal environment flags drive the guard, both defaulting to the guarded posture:
`SKIP_SSRF` drops the dial-time address check, and `ALLOW_INSECURE` permits plaintext
http. The transport caps redirect depth, bounds well-known bodies at 1 MiB, and fails
closed if **any** resolved address of a host is reserved.

---

## L2 · transport — `fora_sdk.client` and `fora_sdk.sync`

`ClientConfig` is the whole of a client's wiring, and three of its fields are things the
client refuses to guess:

- **`signer`** — a `SigningTransport` over the agent's own key. The SDK holds one agent
  key: the one the request is signed with. Its `signature_agent` (the key directory's
  https origin) is required, and every signature carries a fresh 64-byte nonce. Used on
  its own, the transport also takes a `signer_source` (a `SignerSource`, which names the
  seed, keyid and directory per request, for a service signing as many identities),
  `append_only=True` (every request goes through `append_signature`, so a request already
  signed gets one more signature beside the earlier ones) and `cover_previous=True` (an
  appended signature covers the last earlier one, for a party forwarding a request
  unchanged).
- **`verifier`** — fail-closed by default. An unconfigured client gets a verifier over a
  resolver that resolves nothing, so it rejects **every** offer, with a reason. That is
  the correct default and on a first run it looks exactly like a broken stack.
- **`endpoint_resolver`** — turns the exchange domain inside a signed offer into the
  origin that Exchange advertises for itself. A usage report goes where the signed offer
  says, never where configuration says, so this is not an optional convenience.

The rest are bounds and seams with working defaults:

| Field | Default | What it bounds |
|---|---|---|
| `call_timeout_sec` | `30.0` | one RPC, end to end |
| `max_rpc_read_bytes` | `1048576` (1 MiB) | an RPC response body |
| `content_timeout_sec` | `30.0` | the delivery fetch as a whole; its remainder is carried across the legs of one `fetch` |
| `max_content_bytes` | `8388608` (8 MiB) | one fetched body, buffered whole |
| `proof_window` | 30 s | validity of the proof of possession a bound fetch presents |
| `sign_window` | the signer's own window (300 s, the longest the profile allows) | validity of an outbound request signature. It is not needed for uniqueness: every request signature carries a fresh RFC 9421 nonce, so two identical requests inside one second still sign to different bytes |
| `request_id` | `None`, meaning **no header is sent** | mints the `X-Request-ID` correlation value |
| `validation` | `"strict"` | whether an outbound message is checked against its generated model before it is sent. Orthogonal to offer verification, which is about what comes back |
| `registration_requirements` | a reader built on the guarded client, once per client | where `register` reads an Exchange's terms revision and registration schema. It holds no document cache on purpose: the contract requires the terms digest to come from a freshly fetched manifest |
| `strict` | `False` | refuse an answer carrying an unknown field, or breaking a field-level or cross-field rule, and an error envelope or `ErrorDetail` the contract does not define. See [Testing a FORA service](#testing-a-fora-service) |
| `before_sign` | `None` | a hook that receives each request just before it is signed. See [Testing a FORA service](#testing-a-fora-service) |

**Build a client once and reuse it.** `ClientConfig`, the resolvers and the `Verifier`
are all designed to be shared: the endpoint resolver caches each Exchange's manifest
behind its own locks, the offer-key resolver caches keys with a TTL, and the client owns
HTTP connection pools that `close()` releases. One per process, or one per Exchange, is
the shape to aim for — not one per purchase. The one thing that must not be shared across
processes is the signing key.

### The verbs

Requests are plain dicts in proto-JSON **snake_case**, or the generated request models
from `wire.models`; a camelCase key is refused as malformed rather than silently ignored.
`to_wire(model)` shows the JSON object a model is sent as. Responses are the generated
Pydantic models.

| Verb | Send | Get back |
|---|---|---|
| `discover(query)` | `exchange`, `uris`, optional filters | `DiscoveryResult`: `groups` (one per requested URI, each with `uri`, `result.verified`, `result.rejected`, `absence_reason`), plus `exchange` and `rate_limit`. `verified()` and `rejected()` flatten across groups |
| `execute(offer, *, idempotency_key=None)` | a `VerifiedOffer`, or a sequence of them issued by one Exchange — nothing else is accepted | `TransactionResponse`: `items`, each with `transaction_id`, `billing_id`, `retrieval_endpoint`, `expires_at`, `cost` |
| `fetch(signed_url)` | one `retrieval_endpoint` | `Content`: `url`, `mime_type`, `body` |
| `report_usage(report, *, idempotency_key=None)` | `exchange`, `transaction_id`, `billing_id`, `usage` | `UsageReportResponse`: `report_id`, which a later dispute must cite |
| `dispute(request, *, idempotency_key=None)` | `exchange`, `transaction_id`, `report_id`, `reason` | `DisputeResponse` |
| `register(request)` / `get_account_status(request)` | account setup with an Exchange | `RegisterResponse` / `GetAccountStatusResponse` |

A rejected offer keeps both the offer and the `reason` it was refused, so a caller can
tell a bad signature from an unresolvable Exchange. A group with no offers at all keeps
its `absence_reason` instead, and the distinction matters: "not in the catalogue" means
give up, "temporarily unavailable" means retry later, and "content blocked" means never
retry.

**Where a URI comes from** is not the SDK's job. `discover` asks one Exchange about URIs
you already have — from your own crawl frontier, a publisher's catalogue, or a Broker.
`BrokerClient.resolve` is the fan-out case: it asks a Broker, which queries the Exchanges
it knows and relays back what they offered, so a caller with no idea which Exchange sells
a resource starts there rather than with `discover`.

**The delivery edge checks the delivery URL.** `execute` hands each `retrieval_endpoint`
back exactly as the Exchange issued it, and `fetch` dials it as given with the agent's
proof of possession attached. The edge verifies the URL signature and, where it can, the
binding to this agent's key; an edge that cannot check the binding (CloudFront with its
RSA key pair) treats the URL as a bearer token. An edge refusal comes back from `fetch`
as a `CallError` carrying a `retrieval_auth_failure` detail with the edge's reason.

Failures arrive as one `CallError` carrying a `CallErrorKind` — `NOT_SENT`, `REFUSED`,
`UNREACHABLE`, `MALFORMED`, `TOO_LARGE`, `NOT_SIGNABLE`, `UNKNOWN` — plus the peer's own
reason token, the Connect `code` when the peer answered with one, and, when the peer sent
one, a typed `ErrorDetail`, decoded from the binary `details[].value` the error carries. One failure type for
every verb, so a caller branches in one place. `NOT_SENT` is worth singling out: it means
the client refused before anything left the process, so retrying without changing
something will fail the same way.

`BrokerClient` carries `resolve` for brokered discovery and `execute` for a brokered
purchase, `CatalogClient` carries the publisher verbs, and `AdminClient` carries the
operator verbs: `set_tenant_fee_rate` and `set_reporting_policy` (`fora.admin.v1`), and
`request_domain_verification` and `confirm_domain_verification`. All take the same
`ClientConfig`, because each addresses a different endpoint, often with a different key.

`BrokerClient.execute(offers, *, idempotency_key=None)` buys offers from any number of
Exchanges in one call and returns the `BrokerTransactionResponse`: `items` in request order,
`exchanges` (one `ExchangeOutcome` per Exchange contacted) and `totals` (one `Cost` per
currency, never summed across currencies). The Broker sends one sub-request per Exchange,
signed with its own key, and your acceptances travel in each body, so every Exchange still
verifies your consent. An Exchange that refused its whole sub-request is not an error: the
affected items carry `refusal` with that Exchange's code and typed reason, and the other
items come back unchanged. A refusal whose code is `unavailable` or `deadline_exceeded`
leaves the item's outcome unknown, since the Exchange may have bought it before the answer
was lost, and `totals` does not count it; retry the same request with the same idempotency
key to settle it, through this Broker, another one or directly: each Exchange scopes the key
to the agent whatever the route. The signer must set `signature_agent`, and its directory host
must be `requester.domain`: a Broker refuses any other pairing, so the client refuses it
first, as `MALFORMED`. `Client.execute` with offers from more than one Exchange is refused
the same way; buy those through the Broker.

### Testing a FORA service

The client is also a test harness. These capabilities let a conformance or e2e suite drive
a service through the SDK and check every answer through it:

- **`ClientConfig.before_sign`** receives each request as an `httpx.Request` after the SDK
  built and checked it, just before it is signed. The request it returns is signed and
  sent, and the answer decoded as usual, so a deliberately broken message still goes
  through the SDK's own signer and decoder. A hook may not change the method or URL, or
  set a header the signer writes.
- **`RawBody(body)`** passed to any verb in place of its request sends `body` exactly as
  given: no `ver`, `idempotency_key` or `requester` is filled in and nothing about the
  message is refused locally. It is still signed and its answer still decoded. A routed
  verb still reads its destination from the body's `exchange`.
- **`ClientConfig(strict=True)`** refuses an answer carrying an unknown field at any
  depth, or breaking a field-level or cross-field rule, using the published strict JSON
  Schema of the response message (`wire.schemas`) and the SDK's cross-field rules. An
  error answer is checked too: the Connect envelope may carry only `code`, `message`
  and `details`, must name a known Connect code and carry well-formed details, and every
  `ErrorDetail`, binary `value` and `debug` projection alike, must pass the strict
  `ErrorDetail` schema and the cross-field rules. A refused envelope is
  `CallError(MALFORMED)` with `code` still set to the peer's Connect code.
- **Identity helpers** in `fora_sdk.identity` mint a throwaway agent: `generate_key()`,
  `directory_document(keys)` for the directory to serve, and
  `signing_transport_for(key, directory)`. Serve the directory as
  `application/http-message-signatures-directory+json` with the three headers
  `sign_directory_response(authority, body, seeds, created, expires)` returns, signed by
  every key it lists for the authority it is served under; a verifier hands out no key
  that did not sign.
- **Document readers** in `fora_sdk.resolvers` read what a party publishes and check it:
  `read_manifest(domain)`, `read_wba_directory(url_or_domain)`,
  `read_revocation_list(url)` and `read_license_document(license)`. Each fetches through
  the guarded client, refuses the wrong media type (`MediaTypeRefusedError`) and a body
  the strict contract refuses (`StrictViolationError`), and returns a `Document` with the
  parsed model, the URL, the bytes and the media type. The license reader verifies the
  bytes against `uri_digest` (`DigestMismatchError`) and returns a `LicenseDocument`. A
  failed fetch is `DirectoryUnavailableError`, and two say which: `DocumentNotFoundError`
  for a 404 and `RedirectRefusedError` for a directory that answers with any 3xx, both
  subclasses of it; no reader returns `None`. The directory reader fetches with no
  redirect and refuses a directory unless every listed key signed its response
  (`DirectoryResponseUnsignedError`), judged at `now=` (unix seconds) or the wall clock.
- **`check_strict(message_name, payload)`** applies the same strict check to any decoded
  message, raising `StrictViolationError`. A delivery-fetch proof is signed with the
  public `sign_agent_binding` (or `SigningTransport.sign_agent_binding`).
- **`parse_discovery_hint(status, headers)`** reads the `X-Content-Rules` and
  `X-FORA-Exchange` headers of an edge's 403 into a `DiscoveryHint`: each value with a
  state of `"absent"`, `"valid"` or `"malformed"`. Any other status reads as absent.
  `reconcile_discovery_hint(hint, listed)` checks the hinted Exchange against the
  domains the publisher manifest's `exchanges` lists and answers `"listed"`,
  `"unlisted"` or `"no_exchange"`; the manifest wins. The header names are
  `ContentRulesHeader` and `ExchangeHeader`.

### Running against a local Exchange

Three environment variables. WHEN each is read matters, because setting one after the
object that reads it was built has no effect:

| Variable | Effect |
|---|---|
| `FORA_WELLKNOWN_SCHEME=http` | the resolvers that take `scheme=` read manifests and offer-key directories over plaintext. Consumer-side: the SDK never reads it for you, which is why the example above passes `scheme=` explicitly. `WBAKeyResolver` takes no scheme: a Signature-Agent directory is always fetched over https |
| `ALLOW_INSECURE=true` | the scheme gate permits a plaintext `http` origin. Needed for the RPC and delivery legs, which check the scheme ABOVE the transport, so injecting a client is not enough |
| `SKIP_SSRF=true` | the dial-time address guard is dropped, so loopback and private addresses are reachable. Read when `guarded_client` BUILDS a client, so set it before constructing a resolver |

All three default to the guarded, https-only posture. Set them for a sandbox, never in
production — together they remove the whole pre-auth SSRF defence.

---

## Guarantees

- **Byte-parity with Go.** Thumbprints, RFC 9421 signature bases, acceptance payload
  bytes, delivery-proof values and money formatting are byte-for-byte identical to the
  `sdk/go` oracle. If those bytes differ, signatures do not verify and nothing works. Go
  emits the vectors; this package's parity suites replay them.
- **The public surface is gated.** Every public Go symbol is either mapped to its Python
  and TypeScript counterparts or recorded as a deliberate divergence with a reason, and
  the allowlist may only shrink.
- **Fail-closed by default.** An unresolvable Exchange is absent rather than trusted, an
  unverified offer is rejected rather than dropped, and a directory fetch that fails
  returns nothing rather than an empty document.
- **Library-first.** JCS canonicalization, JSON Schema evaluation, the HTTP client and the
  cryptography are vetted libraries. The SDK owns the protocol, not the primitives.

Source, tests and the release process are in
[FORA-Protocol/protocol](https://github.com/FORA-Protocol/protocol) under `sdk/python` and
`gen/python`. Licensed under Apache-2.0.
