Connect-RPC is the only door, with identity resolved by transport
Context and Problem Statement
Agents, humans, orchestrators, a CLI, an MCP server, and eventually a web UI all need to read and mutate the graph. Agents run on the same host in a different unix account, on other machines over a tailnet, and eventually in other organizations over the internet. What is the API boundary, and how does a caller’s identity reach it?
Decision Drivers
- The API contract must be the domain model so that storage and transport can change underneath it
- Wide SDK availability across languages for a product
- Credential isolation: agents must not hold database credentials
- One host today with unix sockets and SELinux, a tailnet next, the internet later
- Streaming for the ready set so orchestrators need not poll
Considered Options
- Connect-RPC over unix socket, tsnet, and HTTPS, with a principal-resolving interceptor
- gRPC only
- REST with OpenAPI
- Direct SQL access with per-principal database roles
Decision Outcome
Chosen option: Connect-RPC with transport-resolved identity, because Connect serves the same protobuf contract over HTTP/1.1 and HTTP/2 to browsers, curl, and every gRPC-capable language, and because putting identity resolution in an interceptor keeps handlers unaware of whether a request arrived over a socket, the tailnet, or the internet.
One Principal type. Three resolvers, added in this order:
SO_PEERCREDon a unix socket, mapping a unix uid to a principal- Tailscale
WhoIsviatsnet - Bearer token or OIDC over HTTPS
Server streaming, backed by PostgreSQL LISTEN/NOTIFY, serves change events. Orchestrators do not stream the ready set: they call ClaimNext, which waits and returns work already claimed under a fencing token. Readiness is derived and emits no event of its own, so a ready stream would mean storing derived state.
Consequences
- Good: Agents never hold database credentials. The credential isolation principle of the orchestrator is preserved.
- Good: The MCP server and CLI are one Go client with a transport configuration. The same binary serves as
skift,skift mcp, andskift serve. - Good: Proto types are versioned as
skift.v1from the start. - Bad: Two models, wire and storage, with a mapping layer between them. This is the price of swappability and is accepted deliberately.
- Bad: Proto contract design is front-loaded work.
- Neutral:
tsnetis deferred until agents run on a second machine. Unix sockets suffice on one host.
Rejected Options
gRPC only
- Good: Mature.
- Bad: HTTP/2 only, no browser without a proxy, harder to curl.
REST with OpenAPI
- Good: Universal.
- Bad: Streaming is awkward, contract drift is common, and batch atomic mutation does not map naturally.
Direct SQL with database roles
- Good: No API layer at all.
- Bad: Every client holds a database credential, the schema becomes the contract, and swapping storage becomes impossible.
More Information
Depends on ADR-004 for what happens after a principal is resolved.