Skip to content

RFC-055: client() — contract-driven callers as first-class trace actors - #150

Merged
avatar29A merged 8 commits into
mainfrom
epic/rfc-055-clients
Jul 29, 2026
Merged

RFC-055: client() — contract-driven callers as first-class trace actors#150
avatar29A merged 8 commits into
mainfrom
epic/rfc-055-clients

Conversation

@avatar29A

@avatar29A avatar29A commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Implements RFC-055client(), 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:

resp = courier.main.call(method = "/courier.v1.CourierService/GetOrder", body = '{"order_id": 42}')

Nothing checks the path, the verb, or that the field is order_id and not orderId. And every driver-initiated call is emitted as step_send with service = "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

orders  = service("orders", interface("public", "http", 8080), image = "orders:2.1")
courier = service("courier", interface("main", "grpc", 9090, spec = "./courier.pb"), image = "courier:1.4")

mobile   = client("mobile-app",   target = orders.public, openapi = "./orders.yaml", validate = "response")
gcourier = client("gRPC-Courier", target = courier.main)   # contract from interface(spec=)

def test_order_flow():
    o = mobile.create_order(body = {"item_id": "sku-1", "qty": 2})
    with fault(courier.main, grpc_faults.unavailable()):
        r = gcourier.get_order(order_id = o.data["id"])
        assert_true(not r.ok)

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. The test → client clock 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:

#3   mobile-app   client_call.orders         → mobile-app.get_order GET /orders/1001
#5   mobile-app   client_return.orders       ← mobile-app.get_order 200 (0ms)
#6   mobile-app   contract_violation.orders  Error at "/courier_eta": Value is not nullable

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

Phase What
1 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)
2 internal/star/: client.go (ClientVal, OperationVal, call path, before= hook, event emission), client_builtins.go (the builtin, contract resolution, name validation)
3 Downstream consumers that had step_send/step_recv hard-coded: report keep-set, app.js (ten inline checks → isCallEvent/isSendEvent/isRecvEvent), normalized trace, assertion-failure context, dotted event_type
4 faultbox inspect --clients, docs/spec-language.md, docs/cli-reference.md, poc/client-rfc055/

Reuses kin-openapi and google.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 when faultbox ran from the directory containing the spec. Now spec-relative, matching load_file(), build=, and client(). 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_recv deliberately stay out — admitting them would silently change what events() returns for existing specs.

Testing

Full suite green, go vet clean. 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 clientAddr redirects to the proxy listener (the entire "faults apply to client calls unchanged" story rests on that one lookup), and that match.event(...) selects client events with no new syntax (RFC OQ-3 deferred the match.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:

  • Tutorial chapter 28 — the RFC's Phase 4 listed one and it hadn't been written. Placed in Part 3 next to the mock chapters it mirrors: ch18/19 generate the dependency you can't run, ch28 generates the caller that drives you.
  • gRPC end-to-end through Starlark — every prior client test in internal/star was 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 the Response, 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 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 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 a feature-manifest.md row (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 the where= path entirely, since the lambda is the filter and pre-filtering had been silently hiding proxy, unmediated_io, and packet events 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

avatar29A and others added 7 commits July 29, 2026 13:22
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
avatar29A force-pushed the epic/rfc-055-clients branch from 13f047c to e6fcc69 Compare July 29, 2026 08:24
…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>
@avatar29A
avatar29A merged commit 689d10f into main Jul 29, 2026
1 check passed
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.

RFC-055: client() — Contract-Driven Callers as First-Class Trace Actors

1 participant