Metadata-Version: 2.4
Name: fora-protocol-sdk
Version: 1.2.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.2.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 |
| test support | `fora_sdk.testing` | acceptance signers that admit an empty requester, for a test proving a verifier refuses one. Not re-exported from `fora_sdk`, and imported by no module of it |

```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) |
| `strict_requests` | `False` | refuse a plain dict, and a request carrying an unknown field or breaking a cross-field rule, before it is signed. Independent of `validation`. 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.
Any other key the model does not declare passes the check and is sent, and the server's
JSON codec discards it; `ClientConfig(strict_requests=True)` refuses it instead.
`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, the `status` and
`headers` of a refusal, 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)` keeping `code`, `status` and `headers`: the peer's Connect code,
  HTTP status and header fields.
- **`ClientConfig(strict_requests=True)`** refuses a request the contract does not
  describe, before it is signed. A verb then takes only its generated request model or a
  `RawBody`: a plain dict, or a model of another message, is `CallError(MALFORMED)`
  naming the model to pass, raised before anything is resolved, read or sent. The message
  as stamped, with `ver`, the configured `requester` and a purchase's reflected offers in
  it, must pass the published strict JSON Schema of the request message and the
  cross-field rules, and an unknown member is named by its path. It applies whatever
  `validation` says, and a `RawBody` is still sent exactly as given. A keyword argument a
  model does not declare is dropped by the model itself, so run a type checker over the
  harness: it reports one.
- **`CallError.headers`** holds the header fields of a refusal, an answer of status 400 or
  above on an RPC leg or `fetch`: a verifier's `Accept-Signature`, a gateway's
  `Retry-After`, an edge's discovery headers, which
  `parse_discovery_hint(err.status or 0, err.headers)` reads (`status` is `None` when no
  answer arrived). It is the error's own copy, looked up case-insensitively, a field
  sent on several lines joined with ", ". A refused redirect, a 2xx the client could not
  read and a local or transport failure carry none, and the fields are never rendered into
  the message.
- **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.
- **`fora_sdk.testing`** signs an acceptance that names an empty requester, which the
  `fora_sdk.core` signers refuse to produce, as fora.proto requires of a signer:
  `jcs_acceptance_payload_any_requester`, `sign_offer_acceptance_any_requester`,
  `jcs_request_acceptance_payload_any_requester` and
  `sign_request_acceptance_any_requester`. Each skips only that refusal, so the acceptance
  is valid in every respect but the requester, and a verifier's refusal of it is
  attributable to that alone. Test support only: import it from a test or a harness.
- **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.
