sql grants: named statements with bound parameters, so a database is reachable without a reachable-by-construction surface #308
Labels
No labels
burndown-2026-06
sunday-sprint
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/umbra#308
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Filed by Angie (engineer,
claudeseat) on Kai's call, as the umbra half ofmcp-beaver#71. That issue is an empty-bodiedautonomy/async-consulton mcp-beaver; Kai's call is to teach umbra SQL, so the design questions are settled here.The gap
Neither engine can reach a database. Measured on
main:grep -rn "database/sql\|pgx\|sqlite\|mysql"over umbra returns nothing, and the only transport in mcp-beaver's runtime isnet/http. Every database Kai runs is reachable only if something already fronts it with HTTP.The deny-by-absence problem, and the answer
The product claim is "everything you declared works, nothing else is reachable". A SQL surface can be built two ways and only one of them survives that claim:
database/sqlplaceholders and are never interpolated into the text. A statement nobody wrote does not exist.The second is the same shape
graphql(#306) just took for documents, and the same shape grants have always had: the authored thing is fixed, the caller fills declared holes.Shape
Zero new dependencies, which is the load-bearing design call
umbra names the driver and opens it through stdlib
database/sql. It imports no driver. The consumer binary registers one for its side effect (_ "github.com/jackc/pgx/v5/stdlib"), exactly as anydatabase/sqlprogram does.That keeps umbra policy-free and driver-agnostic, matching the repo boundary that no consumer-specific type or default leaks in, and it means adding MySQL or SQLite later is a consumer import rather than a change here. Building the runtime checks the named driver is actually registered and fails closed with a message naming it, so a missing import is a startup error rather than a first-call surprise.
Where it lives
In
opcore, as asqlgrant child and aDescriptor.SQL, not a new top-level surface. opcore is already the inline grammar plus descriptor core rather than a purely HTTP package:Descriptor.Proxyis a second execution kind with a completely different path. A consumer that already drivesParseInlineandOperation.Executethen serves SQL tools with no change.Safety rules, all fail-closed at build
;separating statements is refused, so a sloppy authored statement cannot become a stacked query.$Nand?) cannot be mixed.can listwithDELETEis refused, so the served verb cannot lie about what it does.max-rowsbounds the result, defaulting rather than ceilinged into, and the response states truncation honestly rather than implying it read everything.Acceptance
database/sqldriver, not only a stub.Refs
mcp-beaver#71,#306.Landed on
mainas8b098f7. Built by Angie (engineer,claudeseat).Acceptance
database <driver> { value ... }at wrap level,sql { statement ...; param ...; max-rows ... }per grant.Verified against real SQLite
modernc.org/sqlitein a throwaway module, so umbra's own dependency set is untouched. This exercised the?placeholder style, real binding, and real type mapping:The in-tree tests use a recording
database/sqldriver that captures what the engine handed it, so "nothing is interpolated" is asserted against the actual statement and args rather than asserted about.The dependency call held
umbra imports no driver. It opens the named driver through stdlib
database/sql; the consumer registers one. Zero new dependencies ingo.mod, and adding Postgres is_ "github.com/jackc/pgx/v5/stdlib"in the consumer. An unregistered driver errors with its name and the list of registered ones.I deliberately did not check driver registration at parse time. It would have made
lintfail on a valid SQL guardfile in any binary that does not link a driver, which is exactly whatmcp-beaver lintis.Beyond the filed scope
WITH gone AS (DELETE ... RETURNING id) SELECT id FROM goneopens withWITH, so a naive leading-keyword check reads it as a read and would letcan listserve a delete. The scanner walks for a write keyword at code position.authis no longer required for a wrap serving only sql grants. The DSN carries the credential and there is no HTTP upstream to authenticate to. Previouslyvalidate()refused such a wrap outright.[]bytefor text, which would serialize as base64 and be unreadable to a model.Not done, and why
No verification against Postgres. The docker daemon is down on this host, and the running Postgres instances are in Kai's sirens namespaces. Connecting an agent to a live database is a DevOps action rather than an engineering one, and nothing about this change needed it: SQLite exercised the same
database/sqlpath, and the$Nstyle is covered by the in-tree driver. Worth one real Postgres run before a guardfile ships against one.make test,make lint(0 issues), andpre-commit run --all-filesall clean.