feat(api): let a control plane ask a data plane about its own storage - #1393
Conversation
0dd2b03 to
26df539
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain around storage binding, cancellation, version reporting, and error status handling.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds Tern RPCs for inspecting and applying a data plane’s own storage schema through its existing gRPC connection, using embedded schemas to uphold AV-9.
Changes:
- Adds protobuf, gRPC, and HTTP gateway storage-schema APIs.
- Registers storage adapters and client forwarding.
- Adds schema report conversions and tests.
File summaries
| File | Summary / final review notes |
|---|---|
pkg/tern/storage_schema.go |
Defines the storage-schema service interface. |
pkg/tern/server.go |
Adds RPC handlers. Moderate (2): preserve validation status; moderate (1): preserve cancellation/deadline status. Nit (1): add focused RPC tests. |
pkg/tern/grpc_client.go |
Forwards storage-schema RPCs. |
pkg/serve/storage_schema.go |
Implements local storage operations. Critical (1): pin the boot-resolved database identity. Moderate (3): make convergence context-aware or explicitly document detached execution. |
pkg/serve/storage_schema_test.go |
Tests adapter policies and schema resolution. |
pkg/serve/serve.go |
Registers the adapter. Moderate (3): persist the fallback module version. Nit (1): add successful end-to-end RPC coverage. |
pkg/serve/serve_build_test.go |
Updates server registration coverage. |
pkg/proto/ternv1/tern.pb.gw.go |
Generated HTTP gateway bindings. |
pkg/proto/ternv1/tern.pb.go |
Generated protobuf types. |
pkg/proto/ternv1/tern_grpc.pb.go |
Generated gRPC bindings. |
pkg/proto/tern.proto |
Defines storage-schema RPCs and messages. |
pkg/api/storage_schema_proto.go |
Converts storage-schema reports to and from protobuf. |
pkg/api/storage_schema_proto_test.go |
Tests report round trips. |
Review details
Files not reviewed (3)
- pkg/proto/ternv1/tern.pb.go: Generated file
- pkg/proto/ternv1/tern.pb.gw.go: Generated file
- pkg/proto/ternv1/tern_grpc.pb.go: Generated file
Suppressed comments (3)
pkg/serve/serve.go:675
- The new adapter is only tested for option/DSN helpers; no test exercises a successful
StorageSchemaPlanorStorageSchemaApplythrough the gRPC server andGRPCClient. A regression in registration, dialect/DSN wiring, context or policy propagation, or proto conversion would pass the current suite. Add integration coverage with a storage test database and a loopback Tern RPC.
// The storage-schema service answers for this instance's own storage
// database, which is the only way a control plane can read it: a data
// plane's storage is reachable from the data plane, and the gRPC endpoint
// is the connection that already exists between the two.
tern.NewServer(client, s.logger, tern.WithStorageSchemaService(s.storageSchemaService())).Register(gs)
pkg/tern/server.go:71
- All adapter errors are converted to
codes.Internalhere. A canceled request or the adapter's 30-second plan deadline therefore reaches callers as a server failure rather thanCanceled/DeadlineExceeded, so the control plane cannot distinguish an abandoned or timed-out read from a storage failure. Preserve cancellation/deadline status codes while keeping non-context errors sanitized.
if err != nil {
// The caller sees a sanitized message, so this log is the only place
// the cause survives — and a diff that cannot be computed is exactly
// what an operator is trying to see during a failed deploy.
s.logger.ErrorContext(ctx, "storage schema diff failed", "error", err)
return nil, status.Error(codes.Internal, "storage schema diff failed; see data plane logs")
pkg/tern/server.go:71
- The new RPC boundary has no tests in
pkg/tern/server_test.go, which otherwise covers this server's status mapping. In particular, no test proves that an endpoint without an adapter returnsUnimplementedor that adapter failures are sanitized toInternal; a regression in either compatibility/safety behavior would pass the current suite. Add focused tests for both storage-schema methods, ideally including one actual gRPC round trip.
func (s *Server) StorageSchemaPlan(ctx context.Context, req *ternv1.StorageSchemaPlanRequest) (*ternv1.StorageSchemaPlanResponse, error) {
if s.storageSchema == nil {
return nil, errStorageSchemaUnsupported
}
resp, err := s.storageSchema.StorageSchemaPlan(ctx, req)
if err != nil {
// The caller sees a sanitized message, so this log is the only place
// the cause survives — and a diff that cannot be computed is exactly
// what an operator is trying to see during a failed deploy.
s.logger.ErrorContext(ctx, "storage schema diff failed", "error", err)
return nil, status.Error(codes.Internal, "storage schema diff failed; see data plane logs")
- Files reviewed: 10/13 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
26df539 to
30d5775
Compare
30d5775 to
e74b5fd
Compare
e74b5fd to
bf2290a
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1393, bf2290a. Both finder lenses completed. Adversarial verification was capped at 8 candidates, so 4 lower-ranked candidates were never verified either way and are not reported here. Non-blockingThe Unimplemented branch — the thing the whole client-side contract keys on — is untested. Both new tests build the server with The proto round-trip fixture omits General suggestionsThe The unsupported-endpoint early return logs nothing, against AGENTS.md's "Every The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
bf2290a to
31e9c1c
Compare
31e9c1c to
8f0ae24
Compare
|
🤖 All four addressed in The Unimplemented branch is untested. Covered, following The proto round-trip fixture omits The The unsupported-endpoint early return logs nothing. It logs now. Your read was right that the client already proves the RPC arrived, but the log is what separates "this embedder registered no adapter" from "this deployment predates the RPCs" without leaving the data plane. Replied by Claude Code (claude-opus-5) on Armand's behalf. |
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1393, 8f0ae24. Verdict: 3 findings — 2 non-blocking (dead contract, unwired server), 1 suggestion. Non-blockingThe whole of
General suggestionsAll four new apitypes structs are dead and their doc comments point at the wrong paths. The one thing that could have broken, verifiedCapability negotiation: an old data plane must tell a new client "I don't do storage schema" in a way the client can act on. Verified correct
This review was generated by Claude Code (claude-opus-5). |
8f0ae24 to
e69866f
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1393, e69866f. Verdict: 2 findings — 1 non-blocking (round-trip-only conversion test), 1 suggestion (stale RPC list in architecture.md). Non-blockingThe only conversion test is a Go→proto→Go round trip, so a symmetric field swap passes undetected. General suggestionsarchitecture.md's exhaustive Tern RPC list is not updated for the two new RPCs. The one thing that could have broken, verifiedAdding a variadic Verified correct
This review was generated by Claude Code (claude-opus-5). |
…nswers The Unimplemented branch is the whole answer an operator gets from a data plane that cannot serve these RPCs, and nothing exercised it: the server's nil-adapter guard, both client wrappers that turn the code into an upgrade instruction, and the two report fields that only a full round-trip fixture pins. The guard also returned without a trace, which is what separates a deployment whose embedder registered nothing from one running a release that predates the RPCs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e69866f to
3d81eff
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1393, 3d81eff. Verdict: 2 findings — 2 non-blocking (unwired server adapter, untested success path). Non-blockingThe new RPCs are dead on every shipped binary, and the error message misdirects the operator. The only non-test construction of the tern gRPC server is The success path of both handlers is untested. The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
A round trip is blind to any swap the two conversions make symmetrically: with `Outstanding` and `Destructive` exchanged in both directions, `assert.Equal` still holds, while a control plane reading the wire form lists a refused `DROP TABLE` as a statement that runs on its own. The report is now asserted one direction at a time against named wire fields, the round trip kept for the reverse. The serving handlers had no success path under test either. Every adapter a test installed was nil or returned an error, so both could have answered `nil, nil` and stayed green. A static adapter over a real in-process connection now asserts that what it reported is what the caller reads — including the statement the data plane refused, which arrives with the reason it was refused rather than under the list that converges. architecture.md's Tern RPC list named 13 of the 15 RPCs the proto defines, leaving a reader to conclude that a storage schema convergence is unreachable over gRPC.
morgo
left a comment
There was a problem hiding this comment.
🤖 Automated review on Morgan's behalf, at 3184444.
Reviewing the delta since the approval at 3d81eff4: purely additive — one docs/architecture.md paragraph and test coverage. No production Go changed; the rest of the tree drift is main moving underneath.
The new TestServerStorageSchemaAnswersWhatTheAdapterReported is the right test to have added, and it is not tautological. It drives both RPCs over a real connection and then asserts the two statement lists arrive distinct: Outstanding carrying the apply_operations alter, Destructive carrying the DROP with its reason. Then on the apply it checks the two halves separately — Remaining.Outstanding empty because the column converged, Remaining.Destructive still length 1 because the refusal stands.
That is the shape of assertion that actually catches the failure the comment names. A proto field mis-mapping that swapped the two lists, or dropped one, would leave a report that still looks well-formed — and would tell an operator either that a DROP runs automatically or that nothing is outstanding. Asserting the lists are non-empty would not catch it; asserting which statement is in which list does.
The docs paragraph is accurate to the code: an embedder that registered no storage-schema adapter does answer both RPCs Unimplemented (pkg/tern/server.go, refuseStorageSchema → errStorageSchemaUnsupported), and the stated reason — the alternative is silently reading some other instance's storage — is the actual justification for refusing rather than falling back.
CI 41/41 SUCCESS.
|
🤖 Both rounds, The round trip is blind to a symmetric swap. Right, and I reproduced it: exchanging The success path of both handlers was untested. Confirmed by mutation: architecture.md named 13 of 15 RPCs. Fixed, with a sentence on why these two are different in kind: they address the serving instance's own bookkeeping database rather than one it manages, and a data plane with no adapter registered answers The RPCs being dead on a shipped binary is the stack, not a defect. The serving adapter and its Replied by Claude Code (claude-opus-5) on Armand's behalf. |
Why this matters
A data plane's storage database is reachable from the data plane, not from the control plane an operator talks to. So "which storage DDL is outstanding on deployment
west" has to be asked ofwestitself, over the connection that already exists between the two: the Tern gRPC endpoint.What it does
PlanStorageSchemaandApplyStorageSchemabecome Tern RPCs.StorageSchemaServiceis deliberately not part oftern.Client.Clientis the schema change surface a data plane exposes about the databases it manages; this is about the data plane's own bookkeeping, and keeping them apart is what makes "an endpoint that cannot answer refuses" expressible.Invariants
Opened by Claude Code (Opus 5).