Skip to main content

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
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.

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.

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:

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:

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:

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.

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.

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.


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
before_sign None a hook that receives each request just before it is signed. See 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 under sdk/python and gen/python. Licensed under Apache-2.0.

Metadata

Release files for fora-protocol-sdk 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fora-protocol-sdk 1.1.0
File Size Uploaded Provenance Actions
fora_protocol_sdk-1.1.0.tar.gz 489.3 kB Without provenance Details for fora_protocol_sdk-1.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for fora-protocol-sdk 1.1.0
File Interpreter ABI Platform Actions
fora_protocol_sdk-1.1.0-py3-none-any.whl Python 3 none any Details for fora_protocol_sdk-1.1.0-py3-none-any.whl

Total release size: 760.4 kB

Release files / fora_protocol_sdk-1.1.0.tar.gz

Download URL fora_protocol_sdk-1.1.0.tar.gz
Size 489.3 kB
Tags Source
SHA-256 checksum
How to use checksums
f7b4fc5e65a4949088a318837ea6cc0d7b4bdae31a0251aae9cf27e965fcb386
BLAKE2b-256 checksum
How to use checksums
1459cb41a2b6665e935feb0874e363410529fc9ffa2974d3beaa85e01da0303f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / fora_protocol_sdk-1.1.0-py3-none-any.whl

Download URL fora_protocol_sdk-1.1.0-py3-none-any.whl
Size 271.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9798e724fda2c11d1a6e8debc1f40557836d572cf45dec4cc6635c460cc06ff2
BLAKE2b-256 checksum
How to use checksums
ce14132caf159a6b6ff643ae33e854bee55dd1b9c1b4f4dbe84e5170b436b182
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for fora-protocol-sdk 1.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page