RFC-055: client() — contract-driven callers as first-class trace actors - #150
Merged
Conversation
Adds a new topology entity that turns an OpenAPI 3.x document or a protobuf FileDescriptorSet into a named, typed caller bound to a service interface, and makes that caller a first-class trace actor: own swim lane, own vector clock, own event types (client_call / client_return / contract_violation), and matchable anchors for RFC-041 temporal properties. Reuses the loaders RFC-021 and RFC-023 already shipped for the mock (callee) side, and fills the interface(spec=) kwarg that has been parsed but read by nothing since early versions. All eight open questions resolved per strawman. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caller-side inversion of the loaders RFC-021 and RFC-023 shipped for mocks. Both contract formats now produce a common OperationTable that the Starlark layer, the trace emitter, and `faultbox inspect` will work against without knowing which format they came from. client_contract.go — OperationTable, Operation, Param, ContractInfo, the canonical snake_case naming rules (camelCase/PascalCase/acronym/digit boundaries), collision detection with a rename= fix in the message, and Levenshtein "did you mean" resolution. openapi_client.go — walks an OpenAPISpec into operations; merges path-item and operation parameters; synthesizes names for operations with no operationId; binds kwargs onto path/query/header/cookie/body; validates requests and responses against the declared schemas. Undeclared status codes count as violations — that's the undocumented degraded path the feature exists to surface. grpc_client.go — walks a descriptor registry into the same shape; encodes kwargs as the real request message via dynamicpb; invokes unary methods and decodes typed responses. Streaming methods are skipped rather than failing the whole set. Unknown-field errors carry a nearest-field suggestion. http.go — HEAD/OPTIONS accepted (an OpenAPI document may declare them), and the response content type rides along in StepResult.Fields so response conformance can be checked against the declared media type. Tests cover naming, collisions, rename (including unused-key rejection), parameter partitioning, request binding and its error messages, response conformance, and two end-to-end loops that drive the real RFC-021/023 mocks from the same contract the client was built from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
client() is a topology entity in the same tier as service() and mock_service(): declared at spec load, bound to an interface, contract resolved and operation table built eagerly. A client that loads is a client whose every operation is callable. client.go — ClientVal (HasAttrs, operations as generated attributes), OperationVal (keyword-only Callable exposing contract metadata), the call path for both HTTP and gRPC, the before= hook, and the client event emission. client_builtins.go — the builtin: contract selection (explicit openapi=/descriptors= or inherited from interface(spec=), picked by extension), name validation, validate= and timeout= parsing, and rejection of an operation that would shadow a built-in client attribute. Calls emit client_call / client_return / contract_violation with service = <client name>, which is what the event log keys lanes and vector clocks on — so N logical callers appear as N participants rather than one anonymous `test` driver. The test → client merge on the way out is load-bearing: without it the client's lane reads as an independent process spontaneously emitting requests. Response gains .client / .operation / .contract_ok / .contract_error. Reusing Response rather than a new type keeps every existing assertion and expect_* predicate working against client calls. registerService now returns an error so a service can't take a name a client already holds; the mirror check lives in client(). Sharing a name would fold two actors into one lane and clock. interface(spec=) is now read for the first time since it was added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OQ-1 chose "replace, not coexist": a client call emits client_call / client_return instead of step_send / step_recv. That only works if every consumer that had the step pair hard-coded learns the client pair too — otherwise client calls go missing exactly where a reader looks for them. - report.go anchorTypes: client events survive downsampling. They are anchors by construction (spec authors name them in eventually()/ always() windows), so shedding them at the default level would defeat the feature. - app.js: isCallEvent/isSendEvent/isRecvEvent replace the ten inline step-pair checks — severity, anchors, marker palette, tooltips, log rows, filter chips. laneFor is the deliberate exception and now says why: a step event's emitter is the anonymous "test" driver so routing it to the callee is more informative, whereas a client event's emitter IS the actor to show. Client calls fold by operation rather than path, since the path carries a per-call id. contract_violation ranks with the faults and renders in the violation palette. - results.go normalized trace: client lines key on the operation, not the path, so /orders/42 and /orders/500 normalize identically. - recentAssertionContext: client calls appear in failure context. - events.go: dotted event_type (client_call.courier), which also gives ShiViz the callee on the host line while the event's service names the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`faultbox inspect --clients <spec.star>` prints each client's generated operation table — the call you would write next to the wire target it maps to. A generated client's value is that you don't have to read the contract to call the API, which only holds if there's a way to see the surface; this is that way, and it's where the unknown-operation error already points users. docs/spec-language.md gains a "Contract-Driven Clients" section (the builtin, naming rules, calling, validation modes, the trace shape, and what a client explicitly is not), the three new event types in the Trace Output table, and a primitive-index entry. interface(spec=) is finally documented as something that's read. poc/client-rfc055 drives the same OpenAPI document from both ends: the mock generates the callee's routes, two named clients generate the caller's methods, and an override returns a 200 whose courier_eta is null. No assertion describes the expected shape — validate="response" catches it against the published contract. Verified end to end: 3 passed, with contract_violation.orders landing on each client's own lane. Two fixes this surfaced along the way: - mock_service(openapi=/descriptors=) resolved paths against the process CWD, so "./api.yaml" only loaded when faultbox ran from the spec's directory. Now spec-relative, matching load_file(), build=, and client(). - events() filtered to four hard-coded types, so client events were invisible to the builtin users reach for first. Client types are now queryable; step_send/step_recv deliberately stay out, since admitting them would silently change what events() returns for existing specs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…deviated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…149 Both claims held when probed by hand, but neither had a regression test — they were true by construction, which is exactly the kind of property that breaks silently under a later refactor with every other test still green. - Proxy composition: clientAddr must redirect to the proxy listener when one is up for the target interface. That single lookup is the whole "faults apply to client calls unchanged" story; nothing else enforced it. - Temporal anchors: match.event(type="client_return", client=…, …) selects client events with no new matcher syntax, and FirstMatching resolves one as a window-opening anchor. OQ-3 deferred the match.call() sugar on the strength of that property, so it needs a test rather than an assurance. CHANGELOG [Unreleased] gains the feature entry, the interface(spec=) and events() changes, and the two fixes found on the way (spec-relative mock contract paths, service/client name collisions). RFC-055 Discussion now points at #149. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
avatar29A
force-pushed
the
epic/rfc-055-clients
branch
from
July 29, 2026 08:24
13f047c to
e6fcc69
Compare
…t diagnostics Four items called out on PR #150 as unfinished or overstated. **Tutorial chapter 28.** The RFC's Phase 4 listed it and it was never written — the piece closest to the DX motivation the feature exists for. Placed in Part 3 next to the mock chapters it mirrors, since the framing is "the same contract, pointed the other way": ch18/19 generate the dependency you can't run, ch28 generates the caller that drives you. Covers naming rules, gRPC, the multi-client trace, contract conformance under fault, anchors, and why a client is not a fault target. **gRPC end-to-end through Starlark.** Every prior client test in the star package was HTTP; the protocol layer covered encode/invoke/decode but nothing joined it to the Starlark surface, so client(descriptors=) → attribute call → client event was untested as a whole — the exact shape of the RFC's own example. Both tests drive the typed mock and the typed client from the same descriptor set, so a pass means encoder and decoder agree on the wire. Adds the failure shape too: a gRPC status is an outcome on the Response, not a Go error. **fault(<client>) diagnostics.** A client looks like a service in a spec, so reaching for fault(client, ...) is a natural first guess. It returned a bare type-mismatch. Now it explains why a client has nothing to fault and shows both forms against the target it already knows about. Same for fault_start(). **CHANGELOG correction.** The events() entry claimed more than shipped: main's v0.14.1 had already removed the type gate from the where= path. What RFC-055 adds is the dict-filter form. Corrected, and the helper renamed dictFilterQueryable to say which path it gates. Site docs: README feature bullet + docs table, docs/index.md, tutorial README and part index, and a feature-manifest row — the manifest is authoritative, and a feature without a row is not claimed as supported. Also fixes a pre-existing broken README link to a tutorial directory that hasn't existed under that name (04-advanced) for a while. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements RFC-055 —
client(), a topology entity that turns an API contract into a named caller bound to a service interface, and makes that caller a first-class actor in the trace.Closes #149.
Why
Faultbox already ingests contracts, but only to serve them: RFC-021 generates HTTP mock routes from OpenAPI, RFC-023 generates typed gRPC responses from a
FileDescriptorSet. Both are callee-side. On the caller side every request is hand-assembled:Nothing checks the path, the verb, or that the field is
order_idand notorderId. And every driver-initiated call is emitted asstep_sendwithservice = "test", so a scenario with a mobile client, a partner integration, and an admin tool renders as one undifferentiated lane. You can't ask "did the partner client see the error?"This is the same drift problem RFC-021 identified for mocks, pointed the other way — plus a trace-clarity problem that only shows up once more than one logical caller exists.
What it looks like
Three things beyond autogeneration
Clients are their own trace actors. Events carry
service = <client name>— what the event log keys lanes and vector clocks on — so N logical callers show up as N participants. Thetest → clientclock merge on the way out is load-bearing: without it the client's lane reads as an independent process spontaneously emitting requests.Contract conformance becomes assertable.
validate="response"checks each response against the schema declared for its status code. It deliberately does not raise — a contract violation under fault is usually the finding, not a harness error. An undeclared status counts as a violation, which is how the undocumented degraded path surfaces. From the poc run in this PR:No assertion described the expected shape.
validate="response"caught it against the contract the service publishes.interface(spec=)is finally read. The kwarg has been parsed and stored since early versions with nothing consuming it. A client that declares no contract inherits it, choosing the loader by extension.Shape of the change
internal/protocol/:client_contract.go(OperationTable, snake_case naming, collision detection, "did you mean" resolution),openapi_client.go(operation walk, request binding, request/response conformance),grpc_client.go(descriptor walk, dynamicpb encode, unary invoke, typed decode)internal/star/:client.go(ClientVal, OperationVal, call path,before=hook, event emission),client_builtins.go(the builtin, contract resolution, name validation)step_send/step_recvhard-coded: report keep-set,app.js(ten inline checks →isCallEvent/isSendEvent/isRecvEvent), normalized trace, assertion-failure context, dottedevent_typefaultbox inspect --clients,docs/spec-language.md,docs/cli-reference.md,poc/client-rfc055/Reuses
kin-openapiandgoogle.golang.org/protobuf— both already required. No new dependencies.Two fixes this surfaced
mock_service(openapi=)resolved contract paths against the process CWD, not the spec directory, so"./api.yaml"only loaded whenfaultboxran from the directory containing the spec. Now spec-relative, matchingload_file(),build=, andclient(). The poc example is what exposed it.events()filtered to four hard-coded types, so client events were invisible to the builtin users reach for first. Client types are now queryable;step_send/step_recvdeliberately stay out — admitting them would silently change whatevents()returns for existing specs.Testing
Full suite green,
go vetclean. New coverage: contract naming and collisions, parameter partitioning, request binding and its error messages, response conformance, client spec-load validation, event emission and vector-clock merges, and the downstream-consumer migration.Two tests specifically guard claims that are true by construction and would otherwise break silently under refactor: that
clientAddrredirects to the proxy listener (the entire "faults apply to client calls unchanged" story rests on that one lookup), and thatmatch.event(...)selects client events with no new syntax (RFC OQ-3 deferred thematch.call()sugar on the strength of that property).Two end-to-end loops drive the real RFC-021/023 mocks from the same contract the client was built from.
Not in this PR
Per the RFC's resolved questions: streaming RPCs (skipped when the table builds, rest of the descriptor set still loads), load generation (RFC-050's
load()— the identity model is in place for it), request synthesis, contract formats beyond OpenAPI/proto, retry emulation, cookie jars.before=is HTTP-only.Review follow-ups (addressed in this PR)
Four gaps called out after the first push, all now closed:
internal/starwas HTTP.client(descriptors=)→ attribute call → client event is now covered, driving the typed mock and typed client from the same descriptor set, plus the failure shape (a gRPC status is an outcome on theResponse, not a Go error).fault(<client>)diagnostics — returned a bare type mismatch. Now explains why a client has nothing to fault and shows both forms against the target it already knows. Same forfault_start().events()entry claimed more than shipped. main's v0.14.1 had already removed the type gate from thewhere=path; what this PR adds is the dict-filter form.Site docs also wired: README feature bullet + docs table,
docs/index.md, tutorial README and part index, and afeature-manifest.mdrow (the manifest is authoritative — a feature without a row isn't claimed as supported). Plus a drive-by fix for a broken README link to a tutorial directory that hasn't existed under that name in a while.Rebase note
main moved during development (v0.14.0 gVisor and v0.14.1 both shipped). Rebased clean. One conflict worth flagging: v0.14.1 independently fixed the
events()gap, and better — it removed the type gate from thewhere=path entirely, since the lambda is the filter and pre-filtering had been silently hidingproxy,unmediated_io, andpacketevents from predicates that named them. I kept main's fix and narrowed mine to what it still adds: client types in the dict-filter path.🤖 Generated with Claude Code