Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c92a05d
fix(sdk-types): keep a comment's first paragraph, and the field named…
legendko Aug 20, 2026
b71ac20
fix(sdk): make every declared TypeScript module installable
legendko Aug 20, 2026
f12ed50
fix(sdk): read the typed reason Connect actually puts on the wire
legendko Aug 20, 2026
9840b32
feat(sdk): give Python and TypeScript the per-URI discovery answer
legendko Aug 20, 2026
3ca67fe
feat(sdk): give TypeScript the RAMP client
legendko Aug 20, 2026
bb659ce
feat(sdk): give Python the RAMP client, async with a blocking facade
legendko Aug 20, 2026
8e28d3d
docs(sdk): record the client at parity, and name the one camelCase place
legendko Aug 20, 2026
077d00f
test(sdk): hold the three clients to one answer per verb and per refusal
legendko Aug 20, 2026
596c9de
fix(sdk): read the wire a real server serves, at every depth
legendko Aug 20, 2026
8be2661
fix(sdk): gate the scheme before TypeScript dials a host another part…
legendko Aug 20, 2026
90e93a6
fix(sdk): re-check the endpoint an injected resolver hands back
legendko Aug 20, 2026
554fa8f
fix(sdk): carry one agent private key, the one the request is signed …
legendko Aug 20, 2026
3e26036
fix(sdk): bound the read in Python, and split the guarded leg from th…
legendko Aug 20, 2026
24a7389
fix(sdk): keep a hostile answer inside the typed failure taxonomy
legendko Aug 20, 2026
b424731
fix(sdk): tell a gateway draining apart from a service saying no
legendko Aug 20, 2026
a2562dd
test(sdk): make the guards see the spellings they were written to catch
legendko Aug 20, 2026
7e01dda
docs(sdk): record what the client changed, and correct what it falsified
legendko Aug 20, 2026
e5dca1e
fix(sdk): refuse a content coding the client never negotiated
legendko Aug 21, 2026
58cd597
fix(gen): let a consumer of the wire types read what a real server sends
legendko Aug 21, 2026
2d79c05
fix(sdk): bound the error-detail reader, on the path a hostile peer t…
legendko Aug 21, 2026
0dd019b
fix(sdk): treat a member named __proto__ as data, not as an instruction
legendko Aug 21, 2026
a3d2f02
fix(sdk): make the hex rule one rule, and make its replay able to fail
legendko Aug 21, 2026
c3fd431
fix(sdk): put the scheme gate above the send, where it cannot be repl…
legendko Aug 21, 2026
6d31ebe
fix(sdk): answer the same class, and the same value, in all three lan…
legendko Aug 21, 2026
90c078b
docs(sdk): say what the codec does, what the guard is, and what a cor…
legendko Aug 21, 2026
2d7c767
fix(sdk): give the scheme refusal back its type
legendko Aug 21, 2026
3b61a3e
fix(sdk): bound the parse, not only the reader above it
legendko Aug 21, 2026
dbf7536
fix(sdk): keep a Python signature off plaintext, whoever supplies the…
legendko Aug 21, 2026
9840ee9
test(sdk): gate the halves that were fixed but never checked
legendko Aug 21, 2026
6da5066
fix(sdk): give the delivery leg the same answers as the RPC leg
legendko Aug 21, 2026
73589b1
test(sdk): make the parity gates measure what they claim
legendko Aug 21, 2026
bc39865
fix(sdk): release the socket behind a body a refusal never read
legendko Aug 21, 2026
a69ed51
fix(sdk): give the delivery leg's own failures the type it promises
legendko Aug 21, 2026
48c68a7
test(sdk): drive the scheme pre-flight through both client faces
legendko Aug 21, 2026
1da78ca
fix(sdk): decode the rate limit, except the member decoding cannot pr…
legendko Aug 21, 2026
1ed3804
fix(gen): read a null as the absence of a value, whatever the field h…
legendko Aug 21, 2026
e7bdb17
fix(sdk): bound a stranger's JSON, and state the delivery URL rule
legendko Aug 25, 2026
83773df
Merge main into feat/ts-and-py-json-rpc-client-layer
legendko Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/sdk-types-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,16 @@ jobs:
# fails if the committed testdata differs (deterministic, no time.Now()/random).
# It also guards the sign-request + acceptance vectors the Python parity suite reads.
- name: assert Go golden vectors are current
# Every emitter in BOTH corpus packages. The selector used to name
# Every emitter in EVERY corpus package. The selector used to name
# TestGenerateVectors, which is one function: `-run` matches unanchored, so a
# sibling called TestGenerateAudienceVectors does not contain that substring
# and never ran — and the package selector left sdk/go/resolvers out entirely,
# so six more were missed twice over. proto-ci is unfiltered and runs
# `go test ./...`, so the corpora were still gated; what was lost is the
# redundancy this job exists to provide.
run: go test ./sdk/go/helpers/ ./sdk/go/resolvers/ -run 'TestGenerate'
# redundancy this job exists to provide. sdk/go/connect is the third home: the
# Connect error envelope can only be captured from a real connect-go handler,
# which the Connect-free tiers may not import.
run: go test ./sdk/go/helpers/ ./sdk/go/resolvers/ ./sdk/go/connect/ -run 'TestGenerate'

# sdk/ts L1 helpers PLUS the L2 core + Hono server-verify binding
# (offer-verify over JCS, the branded VerifiedOffer guard, the server-verify
Expand Down
208 changes: 208 additions & 0 deletions conformance/generated_client_fields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package conformance

// Completeness guard: every contract field reaches BOTH generated clients.
//
// The proto -> JSON Schema -> Pydantic/Zod pipeline has several rewriting steps, and a
// step that removes a key removes it silently — the output is still a valid document,
// still generates, still parses. The corpus cannot see it: corpusgen emits cases only
// for fields carrying a protovalidate rule, so a field with no rule has no case, and a
// field that vanishes from the clients has nothing to round-trip. Every gate stayed
// green while Offer.title, ResourceEntry.title and UsageAsset.title were absent from
// both clients outright — the merge step stripped every key named `title`, meaning to
// remove the JSON-Schema keyword and taking three real fields with it.
//
// So the invariant is stated directly and independently of any rule: for each message
// in the contract, the generated Pydantic model and the generated Zod schema each carry
// a property per proto field, under the proto field's own snake_case name.
//
// It reads the two committed artifacts as TEXT rather than importing them, for the
// reason ver_field_contract_test.go gives: this package is the guard tier BELOW the
// SDKs and the generated clients are Python and TypeScript, which Go cannot import. A
// committed generated file is a data read exactly like the corpus.
//
// The proto is authoritative. On failure the pipeline has dropped something the wire
// declares; fix scripts/sdk-types/ and regenerate — never delete the field here.

import (
"os"
"regexp"
"strings"
"testing"

"google.golang.org/protobuf/reflect/protoreflect"
)

const (
pydanticModels = "../gen/python/wire/models.py"
zodSchemas = "../gen/ts/wire/schemas.ts"
)

// pydanticClassRe reads the header of one generated model. The body is taken by
// splitting on the next top-level `class`, not by a lookahead — RE2 has none.
var pydanticClassRe = regexp.MustCompile(`^(\w+)\(WireModel\):\n`)

// pydanticFieldRe finds the field names declared in a model body. datamodel-code-generator
// emits ` <name>: <annotation>` at exactly one level of indentation.
var pydanticFieldRe = regexp.MustCompile(`(?m)^ ([a-z][a-z0-9_]*): `)

// TestGeneratedClientsCarryEveryContractField asserts no proto field is missing from the
// generated Pydantic models or Zod schemas.
func TestGeneratedClientsCarryEveryContractField(t *testing.T) {
pydantic := pydanticFields(t)
zod := readFile(t, zodSchemas)

EachMessage(func(md protoreflect.MessageDescriptor) {
name := string(md.Name())
// The merged $defs are keyed by BARE message name, which AssertUniqueBareNames
// already proves unambiguous, so a bare lookup is the right one here too.
got, ok := pydantic[name]
if !ok {
t.Errorf("message %s has no generated Pydantic model", name)
return
}
schema, ok := zodSchemaOf(zod, name)
if !ok {
t.Errorf("message %s has no generated Zod schema", name)
return
}
// The message's OWN keys, not every key on the line. json-schema-to-zod inlines
// each nested message into the same expression, so a substring search would let a
// nested field of the same name stand in for a missing outer one — and `ext`,
// `ver` and `exchange` recur throughout the contract, so that is the common case
// rather than a corner of it.
zodKeys := zodTopLevelKeys(schema)
if len(zodKeys) == 0 {
t.Errorf("message %s: no properties read out of its generated Zod schema — "+
"the scan, not the output, is wrong", name)
return
}
fields := md.Fields()
for i := 0; i < fields.Len(); i++ {
field := string(fields.Get(i).Name())
if _, ok := got[field]; !ok {
t.Errorf("%s.%s is declared in the proto and missing from %s",
name, field, pydanticModels)
}
if _, ok := zodKeys[field]; !ok {
t.Errorf("%s.%s is declared in the proto and missing from %s",
name, field, zodSchemas)
}
}
})
}

// pydanticFields maps each generated model name to the set of field names it declares.
func pydanticFields(t *testing.T) map[string]map[string]struct{} {
t.Helper()
out := map[string]map[string]struct{}{}
// Each chunk after the split starts at a class header; the rest of it is that
// class's body, since the next header is where the next chunk begins.
for _, chunk := range strings.Split(readFile(t, pydanticModels), "\nclass ") {
header := pydanticClassRe.FindStringSubmatch(chunk)
if header == nil {
continue
}
fields := map[string]struct{}{}
for _, f := range pydanticFieldRe.FindAllStringSubmatch(chunk, -1) {
fields[f[1]] = struct{}{}
}
out[header[1]] = fields
}
if len(out) == 0 {
t.Fatalf("no generated models found in %s — the scan, not the output, is wrong", pydanticModels)
}
return out
}

// zodSchemaOf returns one generated Zod schema. json-schema-to-zod emits each on a
// single line, so the line IS the schema.
func zodSchemaOf(source, message string) (string, bool) {
marker := "export const " + message + "Schema = "
start := strings.Index(source, marker)
if start < 0 {
return "", false
}
rest := source[start:]
if end := strings.IndexByte(rest, '\n'); end >= 0 {
return rest[:end], true
}
return rest, true
}

func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}

// zodTopLevelKeys returns the property names of a generated Zod schema's OWN object.
//
// json-schema-to-zod emits a whole message — nested messages inlined — as one expression
// on one line, so telling the message's own fields from its children's means tracking
// brace depth rather than searching text. Strings are skipped as units: a field
// description is arbitrary prose, and an enum's values sit at the same depth as the keys.
func zodTopLevelKeys(schema string) map[string]struct{} {
out := map[string]struct{}{}
open := strings.Index(schema, "z.object({")
if open < 0 {
return out
}
depth := 1
for i := open + len("z.object({"); i < len(schema); i++ {
switch schema[i] {
case '"':
end := i + 1
for end < len(schema) && schema[end] != '"' {
if schema[end] == '\\' {
end++ // an escaped byte, including an escaped quote
}
end++
}
// A property key is a string at this object's own depth followed by a colon.
// An enum value or a describe() argument sits at the same depth and is not.
if depth == 1 && end+1 < len(schema) && schema[end+1] == ':' {
out[schema[i+1:end]] = struct{}{}
}
i = end
case '{':
depth++
case '}':
depth--
if depth == 0 {
return out
}
}
}
return out
}

// TestZodTopLevelKeysReadsOneMessage proves the scan above distinguishes a message's own
// fields from the nested ones json-schema-to-zod inlines beside them.
//
// Without it the guard was satisfiable by accident: TransactionResponse does not declare
// transaction_id — it lives one level down in TransactionResultItem — yet a substring
// search over the line finds it, so the outer message could lose a field of that name and
// the guard would still pass. `ext`, `ver` and `exchange` recur across the contract the
// same way.
func TestZodTopLevelKeysReadsOneMessage(t *testing.T) {
schema, ok := zodSchemaOf(readFile(t, zodSchemas), "TransactionResponse")
if !ok {
t.Fatal("no generated Zod schema for TransactionResponse")
}
keys := zodTopLevelKeys(schema)
for _, own := range []string{"ver", "items", "agent_identity_hash", "ext"} {
if _, found := keys[own]; !found {
t.Errorf("%s is TransactionResponse's own field and was not read", own)
}
}
// Declared by TransactionResultItem, inlined into the same expression.
if _, found := keys["transaction_id"]; found {
t.Error("transaction_id is a nested field and must not read as TransactionResponse's own")
}
if !strings.Contains(schema, `"transaction_id":`) {
t.Error("the nested field is absent from the line entirely — this test proves nothing")
}
}
Loading
Loading