Source Binding Consolidation Notes
Date: 2026-05-17 Status: exploring
Context
The branch codex/fix-openapi-add-flow shipped a focused cleanup of the
OpenAPI add-source API (see openapi-add-source-api-cleanup.md). Looking
across the OpenAPI, GraphQL, and MCP plugins while reviewing that work
turned up a much larger duplication problem. This note captures the
direction we're considering before drafting a real plan.
What's duplicated today
Each of OpenAPI, GraphQL, and MCP independently reimplements the same
source-binding machinery on top of the core credential_binding table:
- A plugin-specific
*SourceBindingInput/*SourceBindingReftype that is a thin re-skin of coreCredentialBindingInput/CredentialBindingRef. - A
resolve*SourceBindinghelper that filters bindings byslotKey === slotand picks the innermost-visible binding whose scope rank ≤ the source's owner-scope rank. Identical across plugins. - A
list*SourceBindingshelper applying the same scope-ceiling filter. - A
validate*BindingTargethelper that re-checks the same outer-scope rule core'sassertCredentialBindingTargetNotOuteralready enforces. - A
coreBindingToXxxBindingadapter that exists only because the per-plugin Ref type is a re-skin. - Per-plugin HTTP endpoints:
setSourceBinding,listSourceBindings,removeSourceBinding. Same payloads, same semantics, mounted under different paths. - A
canonicalizeCredentialMap/canonicalizeAuththat splits an input "shape with secret refs" into a stored shape (with binding sentinels) plus a list of bindings to write. Same algorithm per plugin. - React glue: each plugin re-wires
secret-header-auth,credential-target-scope, andoauth-sign-ininto its own add flow.
Storage-side, each plugin owns nearly-identical child tables:
- OpenAPI:
openapi_source_header,openapi_source_query_param,openapi_source_spec_fetch_header,openapi_source_spec_fetch_query_param. - GraphQL:
graphql_source_header,graphql_source_query_param. - MCP:
mcp_source_header,mcp_source_query_param.
All eight tables share the exact same columns:
(source_id, scope_id, name, kind, text_value, slot_key, prefix).
They're discriminated only by which logical compartment the plugin is
modeling. They're written and read wholesale per source (bulk delete +
bulk insert; bulk findMany). No query filters by name, slot_key,
kind, or prefix. No join uses them. The Drizzle mirrors in
apps/cloud and apps/local are the only non-store consumers.
The arbitrary-source-types constraint
We expect to support source types beyond HTTP-ish protocols: CLI
sources (argv + env), database connection strings (templated URIs like
mysql://user:{{password_slot}}@host/db), and others we haven't named.
That changes the design center of any shared abstraction:
- The HTTP-flavored vocabulary baked into the current shared bits
(
header:,query_param:,prefix) doesn't generalize. - A DB source doesn't have headers; a CLI source doesn't have query params. The notion of "compartment" is plugin-specific.
- A connection-string source needs interpolation, not just whole-field
substitution.
prefixis a degenerate one-hole template; argv and URIs want full templating.
The only protocol-agnostic primitive is: "named slot at
(plugin, source, source_scope, scope) resolving to a value of kind
text | secret | connection." That's already what core
credential_binding provides.
Revised consolidation thesis
Core should own the binding primitive, not just the storage.
Core owns:
- The
credential_bindingtable (today). - The resolver: given
(pluginId, sourceId, sourceScope, slot, scope), return the innermost-visible binding subject to the source-scope ceiling. Today this is duplicated per plugin. - Validation: scope-stack membership, "binding target not outer than
source," secret/connection reachability. The facade already has the
inner checks; the plugin-side
validate*BindingTargethelpers can go. - HTTP endpoints for
setBinding/listBindings/removeBinding, parameterised bypluginIdin the path (or payload). - A small slot manifest concept: each plugin declares, for a given source, which slot keys exist, their human-readable labels, expected value kind, required/optional, and any rendering hints. The UI uses this to render a credential editor for any plugin without bespoke React code.
Plugins own:
- Their source-shape storage. Whatever's plugin-specific (OpenAPI: spec/baseUrl/operations; GraphQL: endpoint/operations; MCP: transport/config; DB: connection-string template; CLI: argv/env). The nearly-identical child tables go away; shape moves to JSON on the source row or whatever structural columns the plugin actually needs.
- The wire-format glue: how a resolved slot value becomes an HTTP header, an MCP transport handshake parameter, a DB connection string, a CLI argv entry.
- Their slot manifest content. OpenAPI generates it from the parsed spec; MCP from connect-time metadata; DB from the URI template's holes.
Plugins do not own:
- Re-skinned binding types.
- Their own resolver / lister / validator.
- Their own
setSourceBindingHTTP endpoint. - Hand-written credential-editor React glue, beyond plugin-specific manifest hints.
What this changes vs the current plan
The existing openapi-add-source-api-cleanup.md proposes deferred
domain wrappers (setHeaderValue, setQueryParamValue,
setOAuthClientCredentials, setOAuthConnection). Under the
consolidated model those wrappers stop making sense — they pre-suppose
HTTP-ish compartments. The consumer-facing API becomes "set this slot
on this source," parameterised by a slot key the plugin's manifest
defines. UX affordances like header preset pickers move into the UI
layer, driven by the manifest.
The plan note's "source shape vs values" split is still right; it just applies more aggressively. Shape lives in plugin source config (JSON or columns the plugin actually queries on); values live in core bindings.
On prefix
prefix is a UI/UX affordance for the Authorization: Bearer <token>
pattern, generalised to "any prepended literal string." It is stored
in three places (core ConfiguredCredentialBinding,
ScopedSecretCredentialInput, plugin child-table columns) and
consumed at exactly one site per plugin:
resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
No escaping, no suffix, no conditional logic. It exists so the secret
itself doesn't have to bake Bearer into the value and so the UI can
render a separate prefix input next to the secret picker.
It does not generalise to connection strings or argv. The right move
is to drop prefix from core entirely and either:
- Have it live in the plugin's slot manifest as a pure UI hint, with the resolver still returning the raw secret value and the plugin doing the concatenation in its wire-format layer; or
- Subsume it into a plugin-owned template ("this slot's value is interpolated into this string"), which is the same idea generalised to multi-hole shapes.
Either way the resolver stops doing string concatenation.
On the OpenAPI normalized child tables
In the local-cleanup framing they look wrong because the same four columns are duplicated four times. In the arbitrary-source-types framing they look wrong for a deeper reason: they bake HTTP compartment vocabulary into the schema. They should go away rather than be consolidated.
Two reasonable destinations:
- JSON on the source row. Same place OAuth2 config already lives. Access pattern (read all, replace all) already matches what a JSON blob gives you.
- Plugin-owned structural columns where they matter. If a plugin truly benefits from a normalized shape table for its own queries, it can have one; OpenAPI/GraphQL/MCP currently don't.
Open questions
- Third-party plugins. Do we expect plugin authors outside this repo? That sets the stability bar for the slot manifest contract and the resolver API.
- Templating layer. Is there one core mini-template language
(
{{slot}}) that plugins opt into, or is interpolation strictly plugin-owned? Shared templating gives consistent UX (preview the resolved value in the UI, redact in logs uniformly); per-plugin templating gives more freedom but reinvents. - Migration order. Sketch the core API first and prove it on OpenAPI as the proving ground, then port GraphQL and MCP in follow-ups. Each step independently mergeable.
- Existing PR. Land or shelve
codex/fix-openapi-add-flowas-is before the bigger consolidation opens; do not swallow the focused cleanup into the larger refactor.
Suggested next steps
- Close out the existing PR.
- Draft a real plan note that specifies the core API surface (resolver signature, manifest shape, HTTP endpoints) before touching code.
- Once the shape is agreed, port OpenAPI first: delete the four child tables, move shape to source-row storage, replace the plugin-level binding helpers with core calls, replace the plugin-level HTTP group with a core one.
- Port GraphQL and MCP to match.
- Pick the first arbitrary source type (likely DB connection string or CLI) as a forcing function to validate the abstraction works beyond HTTP-ish shapes.