Skip to content

refactor: collapse the Solution two-API split onto solution.Client (#295) - #296

Merged
antoinetoussaint-byte merged 3 commits into
mainfrom
issue-295-solution-wire-first-host-caller-and-settle-the
Aug 17, 2026
Merged

refactor: collapse the Solution two-API split onto solution.Client (#295)#296
antoinetoussaint-byte merged 3 commits into
mainfrom
issue-295-solution-wire-first-host-caller-and-settle-the

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byteantoinetoussaint-byte commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes#295.

The four remaining tasks from #295 do not depend on this change and cannot be done in core (they need the cross-repo CLI caller); they are carried forward in #297 so closing #295 on merge drops nothing.

Summary

  • Settles the one method-policy open question that does not depend on a real caller existing: the two-API split (task impr: current with * instead of another field #2). solution.Client makes the operation ceiling a required, type-level argument; the exported WithCeiling let any caller stamp a ceiling onto a raw solutionv0.SolutionClient and bypass that guarantee. This unexports WithCeiling so Client (solution.NewClient) is the single path that can dispatch an effectful Solution RPC — a caller holding the raw generated client can only ever reach the least-privilege default ceiling (enough for the read-only advertisement, by design), so every mutating RPC fails closed.
  • Makes Client's ceiling guarantee intrinsic: each method checks the declared policy against the ceiling before dispatch, so it holds even on a connection whose dial did not install EnforcingClientInterceptor (which NewClient cannot introspect). The interceptor stays as defense in depth and to gate callers that reach for the raw client.
  • The interceptor's default-ceiling denial message and the surrounding docs now point callers to solution.Client instead of the removed solution.WithCeiling.

Not addressed here (tracked in #297)

The issue itself states every remaining open question "only becomes answerable once a real caller exists," and the caller — the CLI solution command group (task #1) — lives in the separate codefly binary repo, not in core:

Making Client canonical now — before task #1's caller exists — is the right sequencing: there is no consumer to break, and the future caller is forced onto the guaranteed path.

Test plan

  • go build ./...
  • go vet ./solution/... ./agents/manager/...
  • go test ./solution/... ./agents/manager/...
  • TestClientRequiresCeilingPerCall covers the canonical Client path (Create admitted under a scaffold ceiling, Package denied before the wire).
  • TestClientEnforcesWithoutDialInterceptor covers a Client over a connection with no interceptor: Create admitted, Package refused before the wire by Client itself. Verified failing against the pre-fix code.
  • TestEnforcingClientInterceptorDefaultsToLeastPrivilege covers the raw-client path that can no longer stamp a ceiling: read-only advertisement admitted, mutating RPC denied with a message naming solution.Client.

🤖 Generated with Claude Code

)
The typed solution.Client makes the operation ceiling a required, type-level
argument, but the exported WithCeiling let a caller stamp a ceiling onto a raw
solutionv0.SolutionClient and bypass that guarantee. Unexport WithCeiling so
Client is the single canonical dispatch path: a caller holding the raw client
can no longer reach any ceiling above the least-privilege default, so every
mutating RPC fails closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#295)
The Client doc asserted it was "the only supported way to dispatch a Solution
RPC," which overclaimed: the raw generated client can still dispatch the
read-only GetSolutionInformation under the interceptor's least-privilege
default ceiling, and the very next paragraph already qualified the claim to
"anything beyond the read-only advertisement." Reconcile the first sentence
with that reality — Client is the only path for effectful RPCs; the read stays
reachable via the raw client by design — so the invariant isn't read as
stronger than it is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#295)
Client's fail-closed guarantee hung on an invariant it could not check: that
the connection was dialed with EnforcingClientInterceptor. A Client built over
a plain connection stamped the ceiling onto a context nobody read and dispatched
every RPC — including Package — unchecked. NewClient takes any
grpc.ClientConnInterface and cannot introspect a connection's interceptor chain,
so the precondition was silent.
Enforce the ceiling inside each Client method, before dispatch, reusing the same
policyFor/admits check and PermissionDenied shape as the interceptor. The
guarantee is now intrinsic to Client regardless of how the connection was dialed;
the interceptor stays as defense in depth and to gate callers that reach for the
raw generated client. Client still stamps the ceiling so the interceptor, when
present, admits the same call instead of defaulting it to least privilege.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit a136f65 into mainAug 17, 2026
1 check passed
Sign up for freeto 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.

solution: wire first host caller and settle the method-policy open questions

1 participant

@antoinetoussaint-byte