# ADR-020: Internal Wire Architecture — singularity-grpc and MCP Scope

**Status:** Proposed
**Date:** 2026-05-02
**Deciders:** Mikael Hugo
**Context repos:** `singularity-grpc` (new), `singularity-forge` (SF), `ace-coder` (ACE), `singularity-memory`

---

## Context

The first-party services that make up the singularity stack — SF
(`singularity-forge`), ACE (`ace-coder`), `singularity-memory`, and the
future `singularity-*` services — need a wire format for talking to each
other. Until now this has been an open question, with MCP (Model Context
Protocol) sometimes treated as the default because it was already in use
for external coding tools.

**MCP causes misunderstandings.** Concretely:

- **String-typed tools.** Every cross-service call is funnelled through
  a string tool name and a JSON blob. Refactoring a callsite means
  grepping for strings; type checkers cannot follow the call graph.
- **JSON-RPC framing tax.** Every request pays the cost of JSON
  marshalling, schema-less envelope handling, and per-message protocol
  overhead, even for hot internal paths.
- **Namespace pollution in agent context.** Internal service tools
  exposed via MCP show up alongside user-facing tools in the agent's
  tool list, crowding the LLM's context with infrastructure plumbing
  that should never have been a "tool" in the first place.
- **Lost typing across language boundaries.** SF (TypeScript), ACE
  (Python), and `singularity-memory` (Go) all share a build system and
  can generate native clients from a single schema source. MCP throws
  that away on every call.

Internal services share our build system. External LLM-driven coding
tools (Claude Code, Cursor, third-party agents) do not. Conflating those
two audiences into one wire format is the root cause of the confusion.

---

## Decision

1. **Create `singularity-grpc`** as the shared schema-and-runtime repo
   for first-party services. It owns common proto types, the codegen
   toolchain, and per-language runtime helpers (auth, tracing, retry).
2. **First-party services talk to each other via gRPC** with typed
   clients generated from each service's protos. SF, ACE, and
   `singularity-memory` all consume `singularity-grpc` for shared types
   and runtime.
3. **MCP is reserved for the external-tool boundary only.** It exists
   as a façade for LLM-driven coding tools that don't share our build
   system. It is a temporary scaffold for the period when external
   coders help build the platform; the surface shrinks as the system
   becomes self-hosting.

### `singularity-grpc` repo structure

```
singularity-grpc/
├── proto/
│   └── common/             # shared types: notification, error, identity,
│                           # workspace base, pagination, timestamps
├── tools/
│   └── codegen/            # proto → Python / TypeScript / Go / Rust
├── runtime/
│   ├── python/             # auth interceptors, tracing, retry helpers
│   ├── typescript/         # same, in TS
│   └── go/                 # same, in Go
└── docs/
    ├── conventions.md      # naming, versioning, error model
    ├── auth.md             # how services authenticate to each other
    └── why-not-mcp.md      # the rationale captured here, expanded
```

### Per-service ownership

Each service owns its own protos and server implementation. SF owns
nothing service-specific because SF *consumes* clients published by
other services — it does not expose a gRPC API of its own.

| Repo | Owns | Imports from `singularity-grpc` |
|------|------|---------------------------------|
| `ace-coder` | `api/proto/workspace.proto`, `api/proto/htdag.proto`, server impl, generated clients, the SF extension that registers the ACE engine | common types, runtime helpers, codegen |
| `singularity-memory` | `api/proto/memory.proto`, server impl, generated clients in 3 languages (Python, TS, Go) | common types, runtime helpers, codegen |
| `singularity-forge` | nothing service-specific — SF is a *consumer* of generated clients published by `ace-coder` and `singularity-memory` | nothing direct |

### The wire-format table (canonical)

This is the canonical reference for which wire goes where.

| Caller | Callee | Wire | Why |
|--------|--------|------|-----|
| ACE host → ACE tools | in-process Python imports | function call | type-safe, zero overhead |
| ACE host → singularity-memory | typed Python client (gen from Go API) | HTTP/gRPC | typed, fast, refactorable |
| SF → singularity-memory | typed TS client (gen from Go API) | HTTP/gRPC | same, in TS |
| SF → ACE worker | existing JSON-RPC stdio (`rpc-client`) | stdio JSON-RPC | already in production, language-agnostic |
| ACE worker VM → host | direct gRPC over tailnet | gRPC | typed, low-latency |
| Claude Code / Cursor → singularity-memory | MCP façade | MCP | external tool, no shared types |
| Claude Code → ACE | MCP façade (temporary) | MCP | external coder helping build, until self-hosting |

---

## Consequences

- **Removes string-typed tool naming from cross-service calls.**
  Refactoring a service API now changes generated client code, which
  the type checker enforces at every callsite.
- **Removes JSON-RPC framing tax for typed services.** Hot paths
  between SF/ACE/memory pay only the gRPC binary protocol cost.
- **Existing `packages/rpc-client/` stdio JSON-RPC stays.** It is the
  Codex-compatible RPC mode for driving SF as a child process. That is
  a different concern from service-to-service typed wires and is not
  affected by this ADR.
- **MCP is scoped narrowly.** Only external LLM-driven tools that don't
  share our build system get an MCP façade. As the platform self-hosts,
  this surface shrinks.
- **`singularity-grpc` becomes a versioning chokepoint** for shared
  types. This is intentional — it forces a single source of truth for
  the cross-service vocabulary (notifications, errors, identity).

---

## Naming

The repo is named **`singularity-grpc`** as a forcing function. The
name commits the project to gRPC as the implementation, which prevents
the kind of "let's just use HTTP/JSON for now" drift that produced the
MCP-everywhere situation in the first place.

Alternative names considered:

- **`singularity-wire`** — transport-agnostic. Rejected because the
  ambiguity is exactly what we want to avoid; the name has to commit.
- **`singularity-rpc`** — more general. Rejected for the same reason;
  too easy to backslide into JSON-RPC.

If gRPC ever needs to be replaced (e.g. with Cap'n Proto or a future
standard), the rename will be a deliberate, visible decision rather
than a silent transport swap inside a generic-named repo.

---

## Alternatives considered

- **Stay on MCP for everything.** Rejected. The string-typing,
  framing tax, and namespace pollution problems compound as more
  services are added. Refactor pain grows non-linearly.
- **HTTP/JSON without typed clients.** Rejected. Same string-typing
  problem at smaller scale; no schema enforcement across services;
  every service reinvents auth, retry, and error handling.
- **Cap'n Proto / FlatBuffers.** Rejected for now. gRPC has better
  tooling, broader language support, and a mature ecosystem. The
  schema layer (proto3) is portable enough that we can swap the
  runtime later if a specific workload demands it.

---

## References

- [ADR-019](./ADR-019-workspace-vm-convergence.md) — Workspace VM
  Convergence (this ADR replaces ADR-019's "MCP scope" section)
- [ADR-014](./ADR-014-singularity-knowledge-and-agent-platform.md) —
  singularity-memory Go migration
- [ADR-013](./ADR-013-network-and-remote-execution.md) — tailnet +
  remote execution substrate
