Skip to content

Sdk harness update - #54

Merged
legendko merged 25 commits into
mainfrom
feat/sdk-harness-update
Oct 23, 2026
Merged

legendko merged 25 commits into
mainfrom
feat/sdk-harness-update

Conversation

@legendko

@legendko legendko commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Three SDK capabilities for a strict test harness

The reference implementation's e2e harness drives every request through the Python SDK and reads every answer through its types. In three places it still had to work around the SDK: it read refusal headers over raw HTTP, hand-wrote an acceptance signer, and had no guard against a misspelled request key. This branch closes all three in the SDKs. It also makes a Go call that an interceptor retries report its last attempt, and widens the guards that keep the test signers out of production code and the L1 helpers IO-free and Connect-free. Go is the parity oracle throughout, and nothing in the wire contract changes.

What changes

1. A refusal carries the header fields of the answer that refused (Go, Python, TypeScript)

  • New fields:
    • Go: connect.CallError.Header and resolvers.FetchError.Header, an http.Header;
    • Python: CallError.headers, a mapping looked up case-insensitively;
    • TypeScript: ForaCallError.headers, a Fetch Headers.
  • The rule. Only an answer of status 400 or above carries fields, on every RPC leg and on the delivery fetch. Each language has one owner of the rule: internal/failure.RefusalHeader in Go, the error constructor in Python and TypeScript.
    • A refused redirect carries none, because its Location names a URL the peer chose and can hold credentials.
    • A 2xx the client could not read, a local failure and a transport failure carry none.
    • The fields are a copy and are never rendered into the error message.
  • What the SDK can now read: a Web Bot Auth verifier's Accept-Signature, a gateway's Retry-After, and an edge's discovery headers.
    • The discovery headers are read with parse_discovery_hint(err.status or 0, err.headers) (Python), parseDiscoveryHint(err.status ?? 0, err.headers) (TypeScript) and helpers.ParseDiscoveryHint(err.Status, err.Header) (Go).
    • The JSON clients' status is absent when no answer arrived, and the parsers take an integer, as Go's zero value does. So the documented calls pass 0 in its place and type-check under mypy --strict and tsc --strict. The parsers' signatures are unchanged.
  • Repeated fields. Python and TypeScript join the lines with ", ". Go keeps each line, and strings.Join(Header.Values(name), ", ") reads the same value.
  • Go CallError.Status is now set on RPC errors. It is the status of the peer's error answer (400 or above). The content leg always set it; the RPC legs left it at 0, though it was documented.
  • Go captures the answer below connect-go, not from connect.Error.Meta(). Meta() is empty when the error body is not a Connect envelope, which is exactly the case of a gateway's 503 with Retry-After.
  • Go records one request at a time. An interceptor passed to WithInterceptors may call next again to retry a call.
    • The client now clears its answer record, and the strict-decoding capture of the error body, before every request it sends. Status, Header and Code therefore describe the last request the call sent.
    • Before, a call whose last attempt got no answer kept an earlier attempt's record. In v1.1.0 its Code was reported as the peer's. Under WithStrictDecoding, an earlier attempt's error envelope could be judged, and refused, in place of the last attempt's failure.
    • The record is cleared before the request, not when no answer arrives. A 2xx, an error body past the read cap and one in a coding the client did not ask for all arrive and store nothing.
    • The record cannot tell which attempt an error came from. WithInterceptors and CallError now document that a retrying interceptor should return its last attempt's error and send attempts one at a time.
    • Python and TypeScript build each failure from the response their send returned, so they need no change.
  • A strictly refused envelope keeps the fields. When strict decoding refuses an error envelope, the failure keeps the peer's Connect code, HTTP status and header fields, and carries no detail, in all three SDKs. The corpus row rpc_401_strict_envelope_refused pins this.
  • TypeScript. UnaryResponse gains an optional headers, which the default send fills. An injected send that leaves it out yields refusals carrying none. A header value that Fetch Headers cannot hold (a character above U+00FF) is skipped, so building the error never throws.
  • New shared corpus sdk/go/connect/testdata/refusal-header-vectors.json, generated by Go and replayed by all three SDKs. Its nine RPC and content-leg refusals pin which failures carry fields and how they read.
  • A test, not a fix: a Go RPC error never names a refused redirect's target. connect-go already unwraps the *url.Error that would carry it. A new test, with a negative control, pins that.

2. Test-only acceptance signers that admit an empty requester (Go, Python, TypeScript)

fora.proto requires a signer to refuse an acceptance that names an empty requester, and a verifier to refuse one even when its signature verifies. The SDK's signers are that signer, so a test proving the verifier's refusal had to hand-write the canonical bytes and the Ed25519 signature.

  • New test-support packages. Each holds twins of the four acceptance functions that skip the empty-requester refusal and nothing else:
    • Go sdk/go/helperstest: CanonicalAcceptanceBytesAnyRequester, SignOfferAcceptanceAnyRequester, CanonicalRequestAcceptanceBytesAnyRequester, SignRequestAcceptanceAnyRequester;
    • Python fora_sdk.testing, not re-exported from fora_sdk: jcs_acceptance_payload_any_requester, sign_offer_acceptance_any_requester, jcs_request_acceptance_payload_any_requester, sign_request_acceptance_any_requester;
    • TypeScript @fora-protocol/sdk/testing: acceptancePayloadAnyRequester, signOfferAcceptanceAnyRequester, requestAcceptancePayloadAnyRequester, signRequestAcceptanceAnyRequester.
  • Every other refusal is kept, in the same text and order. The protocol's signers and verifiers are unchanged and still refuse.
  • One renderer per language takes the requester check as a parameter:
    • Go: sdk/go/internal/canonicalsign. The renderer and the acceptance checks moved there from helpers, whose exported API is unchanged.
    • Python: private functions in fora_sdk.core.
    • TypeScript: sdk/ts/src/acceptance-render.ts, which neither package manifest exports.
    • The check that admits any requester is private to each test package, so nothing else can name the bypass.
  • Pinned by the shared corpora. In all three SDKs the twins reproduce the refused rows of acceptance-vectors.json and request-acceptance-vectors.json byte for byte; the corpora themselves are unchanged.
  • A test in each language fails if production code imports the test package.
    • Python: reads imports with ast.
    • Go: reads them with go/parser. It no longer exempts every directory named helperstest. That exemption admitted nothing, since a package cannot import itself, but it would have let a file in any other directory of that name through.
    • TypeScript: resolves every import with the compiler: ts.preProcessFile, then ts.resolveModuleName under the package tsconfig.
      • It catches an extensionless or .js specifier, the package's own name (@fora-protocol/sdk-l1/testing) and a literal import(), all of which compile and all of which a filename regex missed.
      • A comment or a string that only names the module is not flagged. Only an import() of a computed string is beyond the check.
      • The same check guards the shared renderer, which only the two signers may import.
  • The Go purity guards follow the move.
    • The IO-leaf guard (helpers/io_leaf_guard_test.go) and the connect-free guards (helpers/connectrpc_free_test.go, core/library_adoption_guard_test.go) scanned one directory each. Moving the renderer into internal/canonicalsign took it out of their reach, and internal/hostredact was already outside it.
    • They now scan every in-module package their package is built from, and pin the packages they must reach: helpers, internal/canonicalsign and gen/go/fora/v1 for helpers; core and helpers for core.
    • The walk lives in a new test-only package, sdk/go/internal/importclosure. It reads import specs with go/parser, runs no go command, and is imported by _test.go files only, like vectorio. It fails rather than returning a smaller closure when an import maps to no package. For helpers it returns exactly the in-module packages go list -deps reports.
    • The generated gen/ packages are left out of the substring dialing scan but are included in the import checks.
    • Core's duplicate scan of the helpers directory is gone: core's closure includes helpers, and helpers still guards itself.
    • The guard headers no longer describe a go list -deps CI gate that does not exist.
  • Parity. The twins are mapped in sdk/parity/symbol-map.json like the signers they mirror, and the generated parity matrix gains a helperstest section.
  • Why a test package. A flag on the protocol's signer would leave every production call one argument away from signing what the protocol forbids. docs/design-history.md records the reasoning.

3. Strict requests (Python)

  • ClientConfig.strict_requests: bool = False, appended as the last field.
  • Type gate. A verb takes only its own generated request model from wire.models, or a RawBody. A plain dict, or a model of another message, raises CallError(MALFORMED) naming the model to pass. The refusal comes before anything is resolved, read or signed.
  • Strict check of the stamped message. Just before signing, the message must pass the published strict JSON Schema of its request message and the cross-field rules; this is the check strict decoding already uses.
    • It covers what the client adds itself: ver, the configured requester, and the offers a purchase reflects.
    • It names an unknown member by its path, for example strict_requests refused the request at /requester: Additional properties are not allowed ('domian' was unexpected).
    • The text is the one strict decoding already gives an answer. For a reflected offer's member it is the Exchange's text, quoted with escapes and bounded by the read cap of the discovery answer that carried the offer.
  • Independent of validation: validation="off" leaves the switch in force. The mapped Validation type is unchanged.
  • RawBody is never checked.
  • Known gap, documented. A keyword argument that a generated model does not declare is dropped by pydantic (extra="ignore") before the SDK sees it. A type checker reports it, because the models are dataclass transforms.
  • Newer offers are refused. Under the switch, a purchase that reflects an offer carrying a member this SDK does not know (from a newer minor version) is refused. This is why the switch is off by default.
  • Python only. Go verbs take the generated messages, and TypeScript verbs are typed with the request input types.

Docs

  • proto/CHANGELOG.md and its website mirror gain:

    • Unreleased entries for each change;
    • a breaking-change note for Go SDK users;
    • a Go SDK fix entry, filed under "Changes since v1.1.0": a call an interceptor retries reports its last attempt.

    The Unreleased sections of the two files are identical, and the released sections are untouched.

  • docs/design-history.md gains one new section per change. It also records why the Go answer record belongs to one request (and what it cannot attribute), and why the Go purity guards scan the packages their package is built from.

  • Also updated:

    • the agent SDK overview (harness table and rules);
    • fetch-flow.mdx;
    • the JSON Schemas reference;
    • the three SDK READMEs;
    • the WithInterceptors and CallError godoc;
    • the Python CallError.headers and TypeScript ForaCallError.headers docs.
  • What the docs now state:

    • a strictly refused envelope keeps its status and header fields beside its code;
    • the discovery-hint parser reads the failure's status, 0 when it has none;
    • a retried Go call's fields describe its last request;
    • the Go IO-leaf guard covers the internal packages helpers is built from.

Compatibility

  • No wire change, and no .proto or gen/ change.
  • Go (breaking for SDK users):
    • connect.CallError and resolvers.FetchError hold an http.Header, so neither compares with == any more.
    • An RPC CallError.Error() now names the status, for example (HTTP 401 Unauthorized).
  • Go (behavior fix, no API change): a call an interceptor retries now reports its last attempt.
    • When that attempt got no answer, Code is zero; v1.1.0 marked it as the peer's.
    • Strict decoding no longer refuses a call for an earlier attempt's error envelope.
    • No exported identifier is added or removed by this. sdk/go/internal/importclosure is internal test support, outside the parity map.
  • TypeScript: UnaryResponse.headers is optional, so an existing injected send still compiles.
  • Python: error texts are unchanged, and strict_requests is off by default.
  • Unchanged: the acceptance corpora are byte-identical, and the production signers' and verifiers' refusal texts are the same.

Checks

  • scripts/ci-local.sh passes (buf 1.66.1). It covers buf lint and generated drift, go build/vet/test, doc conformance, parity-matrix drift, the site test and build, generated-type parity, and buf breaking.
  • Suites ci-local.sh does not run also pass:
    • go test -race ./sdk/go/...;
    • the Python SDK suite and the API-surface parity tests;
    • the TypeScript npm test (tsc --strict on both tsconfigs, then vitest).
  • Generated and pinned files: the only changes are the new refusal-header-vectors.json and the helperstest entries in sdk/parity/symbol-map.json and the generated parity matrix. Nothing under gen/ changes, and every existing corpus regenerates byte-identical in verify mode.
  • Each new test was seen to fail on the mistake it exists to catch:
    • Removing the per-request reset of the answer record fails the retry tests, and so does removing the reset of the envelope capture.
    • A planted http.Client in internal/canonicalsign, a planted connectrpc import in internal/hostredact, and a dialing package imported through canonicalsign each fail the Go purity guards.
    • A planted extensionless, .js, self-name or dynamic import of ./testing fails the TypeScript guard.
    • A planted sdk/go/connect/helperstest/x.go fails the Go import guard; it passed the old one.
  • Downstream: the reference implementation's Go code builds and vets against this branch with no source change.

Known divergences, left as they are

  • Go reports a 200 text/html answer as refused; Python and TypeScript report it as malformed. This predates the branch, and the new corpus does not record the kind.
  • TypeScript's RPC read cap (too_large) carries no status, so it carries no header fields either.
  • Go's Status is 0 for a refused redirect and for a 2xx it could not read.

What the harness can now replace

Each item needs the downstream pin bumped past 0b2f33e1.

  • Raw HTTP reads of Accept-Signature and of an edge's discovery headers (tests/e2e/harness/wba_conformance.py, tests/e2e/harness/edge_fetch.py): read CallError.headers instead.
  • The hand-written renderer and raw Ed25519 signer in tests/e2e/harness/requester_alteration.py: use fora_sdk.testing.
  • Guarding against a misspelled request key: set ClientConfig(strict_requests=True).

…rors

A refusal's header fields are how a peer says what it wants next: a Web Bot
Auth verifier names the signature it requires in Accept-Signature, a draining
gateway sets Retry-After, and a delivery edge's 403 carries the discovery
headers. The Go client kept them only on connect-go's Error.Meta(), which is
filled for a decoded JSON envelope and never for a plain-text gateway answer,
and the content leg dropped them.

CallError gains Header, and resolvers.FetchError gains the same field. Both
carry the header fields of an error answer, 400 or above, and nothing for any
other answer: a redirect's Location names a URL the peer chose, so a refused
redirect must not hand its target back through the fields. One function,
failure.RefusalHeader, owns that rule for both legs. The answer recorder now
keeps the status and the copied fields of the response that arrived, so a
gateway's answer carries them as an envelope does.

CallError.Status is now set on every RPC refusal, as its doc already said; the
RPC path left it at 0. A strictly refused envelope keeps its status and fields
beside its Connect code. The connect-error and transport-failure replays now
assert the status too.
Every RPC leg refuses to follow a 3xx, and the refusal's own message redacts
the target to its scheme, host and path. net/http wraps that refusal in a
*url.Error carrying the raw Location the peer sent, credentials included, and
the Go client keeps that Location out of its errors only because connect-go
discards the wrapper before the error surfaces.

A test now pins the result on every RPC face, the offer-derived leg included:
a redirect whose Location carries userinfo, a query token and a fragment
yields an error that names none of them. A dependency that stopped discarding
the wrapper fails here rather than in a log.
CallError gains headers: the header fields of the peer's error answer, 400
or above, on the RPC legs and the delivery fetch, on both faces. A Web Bot
Auth refusal's Accept-Signature, a gateway's Retry-After and an edge's
discovery headers are now read through the SDK instead of over raw HTTP;
parse_discovery_hint reads them with the error's status.

Every other failure carries none: a local one, a transport one, a 2xx the
client could not read, and a refused redirect, whose Location names a URL
the peer chose. The CallError constructor owns that rule, so each site that
builds a failure from an answer hands it the answer's fields and the
constructor keeps them only for an error status. They are a copy, read
case-insensitively, a field sent on several lines joined with ", ", and
never rendered into the message.

Each face's send now returns the answer's status, body and header fields as
one value, which the verbs' decode step reads. decode and decode_with_raw
keep their positional arguments and take the fields as a keyword.
…allError

ForaCallError gains headers, a Fetch Headers holding the header fields of
the peer's error answer, 400 or above, on the RPC legs and the delivery
fetch. A Web Bot Auth refusal's Accept-Signature, a gateway's Retry-After
and an edge's discovery headers are now read through the SDK;
parseDiscoveryHint reads them with the error's status.

Every other failure carries none: a local one, a transport one, a 2xx the
client could not read, and a refused redirect, whose Location names a URL
the peer chose. The ForaCallError constructor owns that rule, so each site
that builds a failure from an answer passes the answer's fields and the
constructor keeps a copy only for an error status. A field sent on several
lines reads as one value joined with ", ", and the fields are never rendered
into the message.

undici's raw response headers are read one way for both tiers: the
resolvers' existing lookup and the client's Headers come from one function.
A value Headers cannot hold, a character above U+00FF, is left out rather
than allowed to turn the description of a failure into a second failure.
The default send returns the fields as UnaryResponse.headers, which is
optional, so an injected send still compiles and yields refusals carrying
none.
…play

The three clients read a refusal's header fields through three HTTP
libraries: net/http canonicalizes names and keeps each line of a repeated
field, undici lowercases names, and httpx keeps them as sent.
refusal-header-vectors.json pins what a caller sees regardless: which
failures carry fields (an answer of status 400 or above, on the RPC legs
and the delivery fetch, and never a redirect or a 2xx the client could not
read), the value each field reads as with a repeated field joined by ", ",
and the discovery hint parsed from the failure's own status and fields.

The rows are captured from the real Go client, record their own field lines
only, and cover a verifier's Accept-Signature, a strictly refused envelope,
a gateway's Retry-After, a field sent on two lines, an edge's proof refusal
and its 403 discovery headers, and a redirect on each leg. Go, Python on
both faces and TypeScript over real sockets replay every row, and the
parity matrix lists the corpus.
…changelogs

The Unreleased changelog, mirrored on the website, gains a "Refusal
headers" entry among the harness capabilities: the field in each language,
which failures carry fields and why a redirect never does, how a field sent
on several lines reads, the optional UnaryResponse.headers, and the new
corpus. The breaking list for SDK users notes that the Go CallError and
FetchError no longer compare with == and that an RPC CallError now carries
and renders its status.

The design history records the rule and its reasons, how the three HTTP
libraries are read alike, why Go records the answer itself rather than
reading connect-go's Error.Meta(), the test that pins a refused redirect's
target out of Go's RPC errors, and the differences left as they are. The
agent SDK overview's harness table and rules, the fetch flow's Go error
listings and the three SDK READMEs describe the new fields.
… internal Go package

The canonical signing form, JCS over proto-JSON under the pinned option set,
and the checks around the acceptances now live in internal/canonicalsign.
helpers renders every offer and acceptance through it, with its exported
functions, their refusals and their order unchanged.

The acceptance functions take the requester check as a parameter and run it
at the exact position the protocol's signer does, with every other check
their own: a nil or unsigned offer, a missing requester, no items, more
than 256 items, an item with no signature or exchange. helpers passes the
check fora.proto requires of a signer. A test signer that must produce what
a verifier refuses can then reuse the one renderer and every other check
instead of copying them.

A new test pins the exact text, the sentinel and the order of every refusal
the acceptance canonicalizers and signers make, combined faults included,
and passes unchanged before and after the move. helpers.ErrUnknownFields
is the same value as before, and the shared vectors regenerate byte for
byte.
…ty requester

fora.proto requires a signer to refuse acceptance bytes that name an empty
requester, and a verifier to refuse them even when the signature over them
verifies. The signers in helpers are that signer, so no SDK function could
produce the one input a verifier's refusal is tested with; a harness wrote
the canonical bytes and the Ed25519 signature by hand.

helperstest holds four twins of the helpers acceptance functions:
CanonicalAcceptanceBytesAnyRequester, SignOfferAcceptanceAnyRequester,
CanonicalRequestAcceptanceBytesAnyRequester and
SignRequestAcceptanceAnyRequester. Each skips only the empty-requester
refusal and keeps every other one, in the same text and order, by passing
the shared renderer a check that admits any requester. That check is
unexported, so nothing outside the package can name it. The package is test
support, like net/http/httptest, and a test fails if any non-test file
imports it.

The twins reproduce the refused rows of acceptance-vectors.json and
request-acceptance-vectors.json byte for byte, signatures included, match
the helpers signers on every named requester, and produce acceptances the
helpers verifiers still refuse.
fora_sdk.testing holds the Python twins of the Go helperstest signers:
jcs_acceptance_payload_any_requester, sign_offer_acceptance_any_requester,
jcs_request_acceptance_payload_any_requester and
sign_request_acceptance_any_requester. Each is its fora_sdk.core namesake
with only the empty-requester refusal skipped, so a test can send an
acceptance that is valid in every respect but its requester and attribute
a verifier's refusal to that alone.

core renders each acceptance through one private renderer that takes the
requester check as a parameter, and its public functions pass the check the
protocol requires; the testing module passes one that admits any requester.
The texts and the order of every refusal are unchanged, pinned by a new
test that passes before and after the change.

The twins reproduce the refused rows of acceptance-vectors.json and
request-acceptance-vectors.json byte for byte, and the recorded
request-acceptance signature is now checked against its bytes, which no
Python test did. The module is not re-exported from fora_sdk, and a test
fails if any module of the SDK imports it.
…n empty requester

@fora-protocol/sdk/testing holds the TypeScript twins of the Go helperstest
signers: acceptancePayloadAnyRequester, signOfferAcceptanceAnyRequester,
requestAcceptancePayloadAnyRequester and signRequestAcceptanceAnyRequester.
Each is its ./acceptance namesake with only the empty-requester refusal
skipped. They use WebCrypto only, like the signers they twin.

The acceptance renderer moves into src/acceptance-render.ts, a module the
package manifest deliberately does not export. It takes the requester check
as a parameter; ./acceptance passes the check the protocol requires, and
./testing passes one that admits any requester. Every refusal keeps its
text and order, pinned by a new test that passes before and after the move.

The twins reproduce the refused rows of the shared acceptance corpora byte
for byte, and the recorded request-acceptance signature is now checked
against its bytes. A test fails if any source file imports ./testing, or if
anything but the two signers imports the renderer. Both package manifests
list the new export path.
…parity matrix

The four helperstest functions are mapped to their Python and TypeScript
twins exactly as the helpers signers they skip one refusal of are mapped:
CanonicalAcceptanceBytesAnyRequester to
jcs_acceptance_payload_any_requester and acceptancePayloadAnyRequester,
SignOfferAcceptanceAnyRequester to sign_offer_acceptance_any_requester and
signOfferAcceptanceAnyRequester, and the request-acceptance pair alike.

The API-surface gate now reads helperstest through go doc and
fora_sdk.testing through its __all__, so a twin missing in any language
fails it, and the generated parity matrix gives the package its own
section.
The Unreleased changelog, mirrored on the website, gains a "Test-only
acceptance signers" entry among the harness capabilities: why a test needs
a signer the protocol's own signers refuse to be, the twelve functions
across the three test-support packages, that only the empty-requester
refusal is skipped, the shared renderer in each language, and the refused
rows they reproduce.

The design history records the decision: a test package rather than a flag
on the protocol's signer or a client seam, why only the requester check is
skipped, how one renderer serves both, and why fora.proto's sentence about
a signer stays as it is. The agent SDK overview's harness table and rules,
and the three SDK READMEs, list the test signers as test support.
…escribe

ClientConfig.strict_requests, off by default, closes the two ways a request
can leave the Python client carrying something the contract does not
describe. The generated models ignore a member they do not declare, so a
plain dict with a misspelled key passed the field-level check, was signed
and sent, and the server dropped the member.

Under the switch a verb takes only its own generated request model, or a
RawBody, which is still sent exactly as given and never checked. A plain
dict, or a model of another message, is refused as MALFORMED naming the
model to pass, at the one point every message passes through before the
verb resolves, reads or signs anything.

The message as stamped, with ver, the configured requester and a
purchase's reflected offers in it, must then pass the published strict JSON
Schema of the request message and the SDK's cross-field rules, through the
check strict decoding already uses. An unknown member at any depth is
refused and named by its path. The check applies whatever validation says;
the mapped Validation type is unchanged. The new field is the last one of
ClientConfig, so positional construction is unaffected.
The Unreleased changelog, mirrored on the website, gains a "Strict requests
(Python)" entry beside typed request inputs: how a misspelled key reaches
the wire without the switch, the two checks, RawBody left as it is, the
switch's independence from validation, the keyword a model drops before
the SDK sees it, and why Go and TypeScript have no such switch.

The design history records where each check sits and why: the type gate at
the copy every message passes through before any I/O, the strict check on
the stamped message, a field of its own rather than a third value of the
mapped Validation type, off by default because purchases reflect offers a
newer minor version may extend, and the gap a type checker covers. The
agent SDK overview's harness table and rules, the JSON Schemas reference
and the Python README describe the switch.
The pinned proto-JSON option set and the unknown-field walk moved from
helpers into internal/canonicalsign, and three comments still named the
old place: the Python wire canonicalizer cited helpers'
canonicalSignJSONOptions, the error-detail vector emitter cited
canonicalsign.go, and the walk's note on map entries named offer_test.go
without the package that holds it.
CallError.headers is typed Mapping[str, str], but at run time it is the
error's own httpx.Headers copy, and a caller can change it. The README and
both changelog mirrors called it a read-only mapping, which the code does
not enforce. They now describe it as the copy it is, looked up
case-insensitively, like the Go http.Header and the TypeScript Headers the
other SDKs return.
A strict_requests refusal carries the same text strict decoding gives an
answer: the path and the schema's complaint, which quotes a member's name
and, for a field that breaks a rule, its value. On a purchase, a reflected
offer's member is the Exchange's text. The design history now says why
that is kept. A harness has to see which key was refused, a strict client
already gets the same text for the discovery answer that carried the
offer, and the text is bounded by that answer's read cap and quoted with
escapes, so it cannot break a log line.
main is now v1.1.0, and this branch's changes are not part of it. The release
renamed the changelog's Unreleased heading to v1.1.0, so the entries this
branch added under Unreleased auto-merged into the v1.1.0 section without a
conflict, listing a Go breaking change and three SDK capabilities as released
when no release carries them. main also rewrote the publisher guide around
the publisher console.

Resolution ledger:
- textual conflicts:
  - docs/design-history.md -> union: main's "A publisher proves its domain
    with the manifest it serves" first, then this branch's three sections at
    the end, in the order the two land.
- semantic, auto-merged, corrected: proto/CHANGELOG.md and its website mirror
  -> this branch's four entries move out of v1.1.0 into a new Unreleased
  section above it, so the v1.1.0 section is the release's, byte for byte.
  The Go client errors entry goes under "Breaking against v1.1.0", and its
  "which v1.0.8 left at 0" now reads "which v1.1.0 left at 0": the release
  left an RPC CallError's Status at 0 as well. The refusal headers, test-only
  acceptance signers and Python strict requests entries go under "Changes
  since v1.1.0", in one entry for the harness capabilities they extend, with
  their text unchanged.
- semantic, auto-merged, reviewed: package.json, sdk/ts/package.json and
  sdk/python/pyproject.toml -> union (main's 1.1.0 versions and pin, this
  branch's ./testing export and _call.py lint waiver; disjoint hunks). The
  versions stay 1.1.0: unreleased work does not bump them.
- taken from main unchanged: the release's version bumps and lockfiles, the
  publisher guide and console pages, the onboarding modules and tests, and
  the org-reference check.
- generated code and the conformance vectors: identical to this branch; main
  did not touch them.
…s its last

An interceptor passed to WithInterceptors runs inside the client's answer and
strict-decoding interceptors, and may call next again to retry a call. Every
request it sent wrote into the same per-call record, and nothing cleared it:
the answer recorder stored an answer only when one arrived, and the strict
envelope capture an error body only when one could be read. A call whose
first attempt was refused with a 503 and its Retry-After, and whose second
got no answer at all, therefore failed as if the peer had answered the
second: Code was marked as the peer's, Status and Header carried the 503 and
its Retry-After, and Error() named HTTP 503. Under strict decoding the
earlier attempt's error envelope was judged in place of the last request, so
a dropped connection could be reported as a malformed answer.

The answer recorder now clears both records before every request it sends.
It clears them before the request rather than when no answer comes back,
because a 2xx, an error body past the read cap and one in a coding the client
did not ask for all arrive and store nothing, and none of them may leave an
earlier attempt's record in place. The capture is looked up once per round
trip and handed to captureErrorBody.

The record cannot tell which attempt an error came from, so WithInterceptors
and CallError now say what a retrying interceptor gets: the last request's
record, which is right when the interceptor returns that attempt's error and
sends its attempts one at a time.

A black-box test retries through the real client stack over a scripted
transport, and a white-box test pins the recorder: no answer, a success, an
error answer, and three error answers that store no body each leave only
their own record.
…uards

The IO-leaf guard and the connect-free guards each scanned one directory:
helpers' own files, and core's. Code that moved out of that directory left
the guard's reach without a failure. The canonical renderer and the
acceptance checks now live in internal/canonicalsign, which helpers calls,
and a planted http.Client there passed the IO-leaf guard; internal/hostredact
was outside every guard already.

A new test-support package, internal/importclosure, walks the in-module
packages a package is built from by reading the import specs of their
non-test files with go/parser, so the guards stay hermetic and run no go
command. Like vectorio it takes no *testing.T and is imported by test files
only. It fails rather than returning a smaller closure when an import path
maps to no package, and its own tests run it on a module built for the test.

The IO-leaf guard now scans every package helpers is built from except the
generated wire packages, whose imports the connect-free guard checks. Both
connect-free guards check every package of their closure, gen/ included,
since they read import specs and cannot trip on comments. Each guard pins
the packages it must reach. Core's copy of the helpers scan is gone: core's
closure includes helpers, and helpers still guards itself.

The guard headers described a go list -deps gate that no CI job runs, and
the connect-free guard still said it failed until a refactor that has since
landed; both now describe what the scan covers.
The guard that keeps production code from importing helperstest skipped
every file in a directory named helperstest. The package's only non-test
file is helperstest.go, and a package cannot import itself, so the
exemption admitted nothing; it only meant that a file in any other directory
of that name could import the package unnoticed. With the exemption gone, a
planted connect/helperstest/x.go importing the package fails the guard.
…er guard

The guard that keeps production code from importing the test signers, and
from importing their shared renderer outside the two signers, matched only
`from "…/testing.ts"`. Under the package tsconfig an extensionless or .js
specifier, the package's own name (@fora-protocol/sdk-l1/testing) and a
dynamic import() all compile, and each passed the guard.

The guard now asks the compiler: ts.preProcessFile lists every specifier a
source file uses, static, re-exported, side-effect, type-only or a literal
import(), and ts.resolveModuleName resolves each one under the package's
tsconfig. A file is flagged when an import resolves to the module, which is
what the Go and Python guards do with their own parsers. A comment or a
string naming the module is not an import. Only an import() of a computed
string is beyond a static check, and the test says so.

Cases for each form, and for what must not be flagged, run the check itself.
…uards in the docs and changelogs

The Unreleased changelog, mirrored on the website, gains a Go SDK fix entry:
a call an interceptor retries reports its last attempt. In v1.1.0 a call
whose last attempt got no answer could report its Connect code as the
peer's, and strict decoding could judge an earlier attempt's error envelope
in place of the last attempt's failure; Code, and the new Status and Header,
now describe the last request the call sent.

The design history records why the record belongs to one request, why it is
cleared before the request rather than when no answer arrives, what it
cannot attribute and therefore what a retrying interceptor should do, and
why the Python and TypeScript clients need nothing. A second entry records
that the Go purity guards now follow the code they guard into the internal
packages helpers and core are built from.

The fetch flow's error types and the Go README say that a retried call's
Status, Header and Code describe its last request, and the README says what
the IO-leaf and connect-free guards scan.
When strict decoding refuses an error envelope, the failure keeps the
peer's Connect code, the HTTP status and the answer's header fields, and
carries no detail. Go's strict-decoding doc, Python's ClientConfig.strict
and TypeScript's strict option already said so, and the refusal-header
corpus pins it in all three SDKs. The agent SDK overview, the three READMEs
and a private Python docstring still said only the code was kept.

The Unreleased "Refusal headers" entry, mirrored on the website, now says
that a strictly refused error envelope keeps the fields as it keeps its
code.
The docs told a caller to read an edge's discovery headers off a failure
with parse_discovery_hint(err.status, err.headers), or its TypeScript twin.
The JSON clients' status is int | None and number | undefined, which is
absent when no answer arrived, while the parsers take an int and a number,
so the documented call fails mypy --strict and tsc --strict; the SDKs' own
tests already pass 0 in its place. The parsers stay as they are, mirroring
Go's int status, whose zero value means no answer.

The Python README and CallError.headers docstring now show
parse_discovery_hint(err.status or 0, err.headers), the TypeScript README
and ForaCallError.headers doc parseDiscoveryHint(err.status ?? 0,
err.headers), and the agent SDK overview says the parser reads the
failure's status, 0 when it has none. Both documented calls were checked
under mypy --strict and tsc --strict.
@legendko
legendko merged commit 995315a into main Oct 23, 2026
4 checks passed
@legendko
legendko deleted the feat/sdk-harness-update branch October 23, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant