Skip to content

fix(hop): one service's gRPC and Connect endpoints are one destination - #31

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
hop/one-destination-per-service
Oct 26, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
hop/one-destination-per-service

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Collaborator

What we are trying to do

Let the hop start on a composition whose modules still publish one service on both a grpc and a connect endpoint. Today it refuses its own routes at startup (platform-core#114 at 792d82c, scope cmp114):

hop routes: ambiguous destination for annotations /annotations.v1.AnnotationsService/Annotate

Since #26 (3acb2c3), a Connect endpoint also claims its procedures for gRPC. annotations, model, robin and tracing each publish the same contract on both endpoints (same package, version and digest in their catalogs), so each procedure looked like two destinations.

The fix, in routing (the owner of the guarantee)

libraries/hop/go/routing:

  • Validate: when a procedure is claimed twice, sameDestination accepts exactly one case: the same audience and service, one grpc endpoint and one connect endpoint, under the same package, version and digest. That pair is one destination. Every other second claim is still refused, and the error now names both listeners (published by module/first/connect (connect) and module/second/grpc (grpc)). That covers two services, two endpoints of one transport, an HTTP route over a Connect procedure, and one service's endpoints under different contracts (… publish it under different contracts …, so their policies may differ).
  • Resolve: a gRPC call for a procedure on both endpoints goes to the Connect endpoint, which serves gRPC, gRPC-Web and Connect, whatever order the endpoints are listed in. A procedure only the gRPC endpoint publishes still goes there, and a gRPC-only module routes as before. Resolve also stops returning the first exact match of an unvalidated snapshot: a duplicate it cannot reconcile is refused.
  • The README states the new rule. It replaces "remove the separate gRPC endpoint".

The policy name passed to authority Check does not change: it is the procedure in both cases.

Classification: fix, at the place that owns the behaviour. No module is edited and no route is authored. The CLI's route producer (cli/pkg/orchestration/hop_configurations.go) calls this same Validate, so a CLI that bumps its libraries/hop/go pin past #26 has to take this commit, or it refuses these compositions at plan time.

Reuse: the existing Validate/Resolve walk and the existing proxy upstream table. No new mechanism. The hop still dials the gRPC endpoint (unused for shared procedures), and I deliberately left that alone.

Parity: routing exists only in Go. libraries/authority-client/{python,typescript} select no routes, so they have nothing to mirror.

Tests

libraries/hop/go/routing/routes_test.go:

  • TestOneServiceOnGRPCAndConnectIsOneDestination: both endpoints on one service give one destination (Connect for gRPC and Connect requests, the gRPC endpoint for a gRPC-only procedure), in both orders.
  • TestGRPCOnlyModuleStillRoutes
  • TestTwoServicesClaimingOneProcedureRefuseByName: connect+grpc, connect+connect and grpc+grpc across two services are refused, the error names both, and Resolve on the unvalidated snapshot refuses too.
  • TestSameServiceWithDifferentContractsRefuses: a different digest, version or package is refused.

On the parent commit, three of the four fail. The existing refusals (TestAmbiguousPublicationAndTemplatesRefuse, TestConnectGRPCOverlapRefusesInEitherOrder) are unchanged and pass.

modules/authority/services/hop/code/pkg/infra/proxy/connect_listener_test.go, TestDualPublishedServiceSendsGRPCToItsConnectListener: a hop with both listeners live sends the gRPC call to the Connect backend (connect=1, grpc=0).

Local: go vet ./... and go test -race -count=1 ./... pass on all 11 Go modules. pytest: 300 passed. npm test (authority-client/typescript): 158 passed.

Run evidence

Local run of platform-core#114 at 792d82c (merged pins), in its own naming scope hoprq3, with the CLI at cli main e4de4a8c and authority resolved to this branch's checkout. Roots: saas/auth-gateway authority/hop annotations/annotations, with the gate's own converter exclusions. Two local-only edits to configurations/local/hop.env, neither committed anywhere:

  • MODULES=model,robin,tracing,annotations: documents is left unrouted because it panics on its own Connect carrier (no carrier for … documents/documents/connect; owner service-go-grpc or module-document-store, as #114 records). The derived routes still carry all four dual-published modules.
  • ENFORCEMENT=warn, only for the call below. The reason is in the next section.

The hop starts. In enforce mode, and again in warn mode:

>> authority/hop: Running on :5593
authority/hop > (INFO) (hop) standing in front of modules modules=model,robin,tracing,annotations

Negative control. The same run with libraries/hop/go/routing put back to main panics as #114 reported:

authority/hop > Caught panic panic=… hop routes: ambiguous destination for annotations /annotations.v1.AnnotationsSer…

An Annotate call routes to annotations' Connect listener. A probe signs dev-admin in at saas accounts (Connect face), is issued a Work Context at audience annotations (the token is not printed), and calls through the hop's gRPC face:

/annotations.v1.AnnotationsService/ListTargetKinds via hop -> OK: {"kinds":[{"authority":"annotations","name":"annotation",…}]}
/annotations.v1.AnnotationsService/Annotate via hop -> PermissionDenied: platform: the call is refused: the authority refuses /annotations.v1.AnnotationsService/Annotate (the hop's warn mode let it through), and this module makes no change on an advisory: annotations's delivery declares no method policy, …

annotations answered the Annotate call itself:

annotations/annotations > INFO annotations: a call was refused before any effect method=/annotations.v1.AnnotationsService/Annotate …

Before the calls the hop holds no connection to annotations. Afterwards (lsof on the hop process) it holds exactly one, to the Connect port 47350 and none to the gRPC port 46583:

[::1]:61352->[::1]:47350

Why warn mode, and a gap that is not this PR's. In enforce mode the route resolves and the hop asks the authority, but saas accounts refuses every annotations method with annotations's delivery declares no method policy. This run delivers manifests through accounts' legacy REST delivery route (host.delivery stays saas/accounts/rest; the CLI refuses accounts' Connect face as a delivery endpoint, per #114). In this run, that route applied the manifests without an authority document. The owners are codefly-dev/cli and module-saas-starter, not routing. For the same reason Annotate cannot succeed end to end here: annotations makes no change on an advisory. Also seen in the run, and also not routing's: annotations' owner probe to robin is refused because robin publishes no /annotations.v1.TargetAuthorityService/ListKinds destination.

Not verified: an enforce-mode Annotate that succeeds, which needs the delivery gap above closed; the composed CI gate (#114's CI stops earlier, on a token's access to module-annotations); a CLI release carrying this routing. The CLI's route producer calls this same Validate, and it still pins libraries/hop/go before #26.

🤖 Generated with Claude Code

Since #26 a Connect endpoint also claims its procedures for gRPC, so a
module that still publishes the same service on a grpc and a connect
endpoint (annotations, model, robin, tracing) made the hop refuse its own
routes at startup: "ambiguous destination for annotations
/annotations.v1.AnnotationsService/Annotate".

Routing now treats that pair as one destination when both endpoints carry
the same contract (package, version, digest): gRPC calls go to the Connect
endpoint, which serves gRPC, gRPC-Web and Connect; the gRPC endpoint keeps
only procedures Connect does not publish, and a gRPC-only module routes as
before. Every other second claim is still refused, now naming both
listeners: two services, two endpoints of one transport, an HTTP route over
a Connect procedure, or one service's endpoints under different contracts.
Resolve no longer returns the first exact match of an unvalidated snapshot;
it applies the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 3afad72 into main Oct 26, 2026
15 checks passed
antoinetoussaint-byte added a commit that referenced this pull request Oct 26, 2026
…p-binds-method

Kept both: #31's rule that a service's gRPC and Connect endpoints are one
destination, and this branch's refusal of a REST face transcoded into a
procedural listener; both routing test sets stay. #31's dual-publication
proxy test signs with the hop's Signer, as every proxy test here does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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