Repository navigation
feat(api): serve the storage schema plan and apply at the write tier - #1394
Conversation
b638831 to
4da6e99
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The route contract and server-version wiring issues remain unresolved, with documentation and test updates also requested.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds admin-only HTTP endpoints for inspecting and applying SchemaBot’s storage schema through local or remote adapters, with write-tier authorization, tests, and documentation updates.
Changes:
- Adds storage-schema request/response types and handlers.
- Wires storage adapters and API routes.
- Adds authorization, routing, and handler coverage.
- Updates AZ-2 and authentication documentation.
File summaries
| File | Summary |
|---|---|
pkg/serve/serve.go |
Registers the local adapter. Moderate (1 vote): pass the derived version fallback. Nit (1 vote): add Build-to-Service integration coverage. |
pkg/auth/tiers.go |
Documents write-tier classification. |
pkg/auth/tiers_test.go |
Tests storage routes as write-tier requests. |
pkg/apitypes/storage_schema_requests.go |
Defines HTTP payloads. |
pkg/api/storage_schema_handlers.go |
Implements validation, authorization, routing, and responses. |
pkg/api/storage_schema_handlers_test.go |
Tests handler behavior. Nit (1 vote): correct the POST/write-tier test comment. |
pkg/api/service.go |
Registers schema routes. Moderate (3 votes): reconcile the advertised /diff route with the registered /plan route. |
pkg/api/route_authorization_sweep_test.go |
Adds authorization fixtures. |
docs/invariants.md |
Updates AZ-2 enforcement references. |
docs/auth.md |
Documents schema access. Nit (1 vote): update admin-only guidance. |
Review details
Suppressed comments (4)
docs/auth.md:657
- These handlers call
authorizeDirectAdminWrite, so storage-schema inspection and convergence are admin-only when scoped authorization is enabled; a database operator group still receives 403. The existing admin-only list indocs/auth.md:759-761was not updated, so this new table/paragraph can lead operators to grant a scoped group that cannot use the endpoints. Add these operations to that admin-only guidance.
`POST /api/storage/schema/plan` reads without changing anything, and still
requires write access under that default. It reports the internal shape of
SchemaBot's own bookkeeping database, and its sibling route converges that
database, so both belong to the people who operate the server rather than to
everyone who can see the schema changes it runs.
pkg/api/storage_schema_handlers_test.go:339
- This test comment says the diff is a GET, but the route under test is POST; that reverses the reason the tier gate applies and can mislead future authorization changes. Describe it as being denied because the POST defaults to the write tier.
// database operator is denied on it even though it is a GET, because the tier
// rule admits it as a write — the storage database is SchemaBot's own
pkg/serve/serve.go:519
BuildderivesmoduleVersion()for embedded hosts whenWithBuildInfois absent, but the adapter wired here still receives the emptyo.version. Normal embedders will therefore return storage reports with an emptyversionand generic schema attribution, even though the report contract says it identifies the answering binary; pass the same derived fallback intoServer.version.
version: o.version,
pkg/serve/serve.go:526
- The production wiring added here is not exercised by the new handler tests: those tests all call
SetStorageSchemaServicemanually. If this Build-to-Service registration is removed or regresses, a real server's local storage-schema routes will refuse with 400 while the handler suite remains green; add an integration assertion that a built server's HTTP handler reaches the server-bound storage adapter.
svc.SetStorageSchemaService(srv.storageSchemaService())
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
4da6e99 to
7b82a94
Compare
7b82a94 to
aa44ec9
Compare
aa44ec9 to
4c9f197
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1394, 4c9f197. Both finder lenses completed. Adversarial verification was capped at 8 candidates, so 1 lower-ranked candidate was never verified either way and is not reported here. Non-blockingThe two new operation names are missing from the metrics allowlist, so every storage-schema authorization decision is labelled
A gRPC-client resolution failure is returned verbatim with HTTP 400, leaking the configured endpoint address. With docs/auth.md's hand-maintained admin-only enumeration was not updated, so the docs imply a database-operator grant covers the new routes. auth.md#L759 still reads "changing settings, maintaining checks, redriving webhooks, and forcing a lock release" while the Write row at auth.md#L641 gained storage schema. A scoped operator reading this concludes General suggestionsThe doc comment on The one thing that could have broken, verifiedWhether the admin gate can be bypassed on either route. Both are registered POST-only in service.go#L850 and Verified correct
This review was generated by Claude Code (claude-opus-5). |
f1ad009 to
b722280
Compare
|
🤖 All five addressed in The two new operation names are missing from the metrics allowlist, so every decision is labelled
A gRPC-client resolution failure is returned verbatim with HTTP 400, leaking the configured endpoint address. Fixed. It answers with the fact that a client could not be built for the configured endpoint, and the cause — which names this deployment's own infrastructure — stays in the server's logs. You were right that this contradicted the redaction policy the failure writer enforces 150 lines below; the table test now covers this path too. docs/auth.md's admin-only enumeration was not updated. Updated, so the prose and the Write row agree. The doc comment says the route "is a GET" when both routes are POSTs. Reworded. A comment that invites someone to "restore" the route to a GET and drop it to the read tier is worse than no comment. Replied by Claude Code (claude-opus-5) on Armand's behalf. |
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1394, b722280. Non-blockingServer-side The new registry guard names a gate function that does not exist, so three live call sites go unscanned. direct_write_auth_registry_test.go:39 keys on The 501 body asserts one cause for Unimplemented and drops the other. General suggestionsBoth routes ship registered but inert in any binary built from this head. No non-test code calls The one thing that could have broken, verifiedMaking storage schema reachable over HTTP could have handed a scoped operator a write path into the storage database. Both handlers run Verified correct
This review was generated by Claude Code (claude-opus-5). |
b722280 to
6f2ed42
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1394, 6f2ed42. Verdict: 5 findings — 2 blocking (routes inert, 30 s write timeout), 2 non-blocking, 1 suggestion. BlockingNothing ever supplies a The new routes never lift the server's 30 s Non-blockingThe registry-coverage test names a gate function that does not exist and omits one that does, silently skipping three operation call sites. Every General suggestionsBoth handlers decode the body before authorizing, unlike their three sibling admin handlers. The one thing that could have broken, verifiedExposing two new mutating routes without the scoped-operator denial the repo's auth doctrine requires. Verified sound: Verified correct
This review was generated by Claude Code (claude-opus-5). |
6f2ed42 to
3839be0
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1394, 3839be0. Non-blockingThe new registry guard names a gate function that does not exist, so three operations are never checked. Server-fault failures are answered 400, so a control-plane misconfiguration reads as a malformed request. The apply handler runs a synchronous convergence on General suggestionsNothing in the shipped binary calls The guard resolves constant-named operations from a map it is still filling during the same walk. The one thing that could have broken, verifiedRouting a storage-schema request to the wrong plane. Verified correct
This review was generated by Claude Code (claude-opus-5). |
morgo
left a comment
There was a problem hiding this comment.
🤖 Automated review on Morgan's behalf, at 29026a0.
Reviewing the delta since the approval at 3839be0c: two files moved, both in storage_schema_handlers.go and its test. Everything else in this PR is byte-identical to the approved state; the rest of the tree drift is the base branch moving underneath.
The change splits target-resolution failures by whose fault they are instead of answering every one with 400. I checked the partition itself, since that is the whole content of the change and a miscategorised path would be the bug:
Caller faults, left unwrapped and answered 400 — environment without a deployment, deployment without an environment, and a deployment/environment naming no configured data plane. All three are things the caller can fix from the response alone. ✓
Server faults, wrapped with an explicit status — no local storage schema service (501), a configured endpoint whose client will not build (503), a remote deployment resolving to an in-process client (500). None of these are fixable by the caller. ✓
The errors.As default is the safe direction: an unwrapped error stays 400, so adding a new caller-fault path needs no change here, and only a deliberate wrap can escalate a status.
The reasoning for why this matters is right and worth keeping: answering a server fault with 400 tells a pre-deploy gate its request was malformed, so it stops instead of retrying — which is the opposite of what a 503 should produce. Matching the local spellings to the 501/503 that writeStorageSchemaFailure already returns on the remote paths means one caller cannot see two different statuses for the same condition depending on which side resolved it.
Two nits, neither worth a round trip:
- The doc comment says a server fault's "response deliberately does not carry the cause". That is exactly true of the 503 path, where the underlying error is logged and the response says only where to look. It is not true of the 500 path, which formats the in-process client's Go type through
%Tinto the response body. Low consequence — this is a write-tier authenticated route, so the reader is already privileged — but the comment claims a property the code only has on one of the two paths. - 501 is
>= http.StatusInternalServerError, so a server intentionally built without a storage schema service logs at Error on every such request. That is a build-time choice rather than a fault, and anything polling the route would produce a steady Error-level drip.
CI 41/41 SUCCESS.
…ailure has Both storage schema operations were missing from the direct-write authorization metric's allowlist, so every decision either route recorded landed under operation="unknown" — the one label that makes a route's denials invisible to the dashboard watching for them. A registry test now pins the names that are written down at their gate, in the shape the recovered-panic registry already uses. The failure writer collapsed every data plane fault into a 500 pointing at someone else's logs. An Unimplemented says the deployment needs upgrading and an Unavailable says it needs looking at; both are remedies an operator can act on without reading anything. A client that cannot be built for a configured endpoint now answers with the fact and keeps the cause — which names this deployment's own infrastructure — in the server's logs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…equest Three of the ways a storage schema target fails to resolve are this server's fault, and all three were answered 400. A build with no storage schema service, a configured data plane whose client will not build, and a routing config that sends a remote deployment to an in-process client are none of them things a caller can fix by editing their request — and a pre-deploy gate that treats 4xx as "malformed, stop" gives up on them instead of retrying or escalating. They now answer 501, 503, and 500. The first two are the spellings the remote paths already use for the same two conditions, so the same fault reads the same whether it was local or came back over gRPC. The log level follows the same split. A caller's mistake stays a warning: the response says what to fix and nobody has to read this server's logs. A server fault is an error, because the response deliberately withholds the cause and these logs are where the operator goes next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t claims The guard watched a gate named authorizeDirectWriteForPlan, which is not a function in this repository — the real one is authorizeDirectWriteForStoredPlan — and it did not list authorizeDirectDatabaseWrite at all. A gate named wrong matches no call sites, the scan stays green on the ones it does find, and every operation behind the misspelled or missing gate goes unchecked. Adding a lock_steal call site with no registry entry left the guard passing, which is exactly the regression it exists to catch; it now fails. Two further holes, both of which let a missing entry pass: Constants were collected and call sites scanned in the same directory walk, so only constants from already-walked files resolved. A gate named through a constant declared in a later-sorting file read as "named by a parameter" and was skipped. Collecting every constant first, then scanning, removes the dependence on filename order. Nothing asserted the gates exist. Every name in the list is now checked against the functions actually declared in the tree, so the next rename fails here rather than silently narrowing what is watched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29026a0 to
feef130
Compare
|
🤖 Five findings on The registry guard named a gate that does not exist. Correct, and worse than under-covering: The guard resolved constants from a map it was still filling. Also right, and the reason the name fix alone was not enough. Split into two passes — parse and collect every constant, then scan call sites over Server faults answered 400. Fixed: no local storage service → One gap worth naming, which your finding did not: the The apply handler's write deadline. Real, and fixed in #1405 as Nothing calls Replied by Claude Code (claude-opus-5) on Armand's behalf. |
Why this matters
The storage schema surface exists one layer down as Tern RPCs, and nothing yet reaches it over HTTP. Exposing it is one decision with a real consequence attached: the tier an operator has to hold to read a plan of SchemaBot's own bookkeeping schema.
What it does
Two admin-only routes expose the storage schema surface over HTTP:
A server answers them from the adapter bound to the storage it booted with, and refuses rather than guessing when a build never resolved one.
Both are POSTs, and that is what carries the authorization gate. The plan reads and writes nothing, so the first instinct is a GET. But the gate that actually bites is the tier: scoped write checks are a pass-through on deployments that leave scoped writes disabled, so tier classification is the whole admin decision there. A GET would have been admitted at the read tier — which is to say SchemaBot's bookkeeping schema would have been readable by everyone holding read access.
The POST also carries the schema files the plan compares against in its body, so the verb the gate wanted is the verb the payload wanted.
Invariants
pkg/auth/tiers.go, where request classification actually lives — the previous pointer resolved cleanly to a file that no longer decides it.Opened by Claude Code (Opus 5).