midi-harbor/specs/001-service-and-clients/contracts/ipc-protocol.md
James Coleman f059eaef65 docs(specs): drop the spec names used before the renumbering
- The specs index no longer carries a table mapping the three original spec names to the renumbered ones, and spec headers no longer record a feature branch or "first written as" name.
- A task that pointed at research under the old 001 directory now points at its current path, so every reference resolves to a spec that exists.
- The constitution's 1.4.1 amendment still records that the specs were split and renumbered, without listing the retired names.
2026-09-29 14:35:33 -05:00

206 lines
10 KiB
Markdown

# Contract: Daemon gRPC Protocol
**Feature**: 001-service-and-clients | **Protocol**: `midiharbor.v1` | **Date**: 2026-09-20
The contract between `midi-harbor daemon` and its clients (the GUI and the CLI). Governed by
FR-036, FR-037, FR-039c, FR-040, FR-041 and Constitution Principle II.
**Revised 2026-09-20**: gRPC replaces the original length-delimited JSON framing. See
[research.md](../research.md) R-009 for the decision and what it changes.
---
## 1. Transport
- **Protocol**: gRPC over HTTP/2, using `tonic`.
- **Channel**: a **Unix domain socket**, not a TCP port. Nothing about Midi Harbor's control plane
should be reachable from the network, and a local socket gets per-user isolation from file
permissions with no authentication layer to design.
- **Path**:
- Linux: `$XDG_RUNTIME_DIR/midi-harbor/daemon.sock`, falling back to
`$HOME/.cache/midi-harbor/daemon.sock` when `XDG_RUNTIME_DIR` is unset.
- macOS: `$HOME/Library/Application Support/com.mrgeckosmedia.MidiHarbor/daemon.sock`.
- **Permissions**: socket file mode `0600`, parent directory `0700`.
- **Encoding**: protocol buffers, `proto3`.
- **Schema location**: `proto/midiharbor/v1/harbor.proto`, compiled by `tonic-prost-build` into
the `midi-harbor-ipc` crate. The `.proto` file is the contract; the Rust types are generated
from it and are never hand-edited.
- **Concurrency**: HTTP/2 multiplexes many concurrent calls over one connection, and many clients
may connect at once (FR-040). No call blocks another.
---
## 2. Versioning (FR-041)
Version lives in the **proto package name**: `midiharbor.v1`.
- **Major version = new package.** A breaking change means `midiharbor.v2`, a new service path,
and a daemon that may serve both during a migration. A client built against `v1` calling a
`v2`-only daemon gets gRPC `UNIMPLEMENTED` for the whole service, which is unambiguous.
- **Minor version = additive only.** New RPCs, new message fields, new enum values. Protobuf
ignores unknown fields on the wire, so an older client talking to a newer daemon keeps working
without any negotiation step.
- **Clients still call `GetServerInfo` first**, because `UNIMPLEMENTED` alone does not tell a user
*which* side is old:
```protobuf
message ServerInfo {
string daemon_version = 1; // "0.1.0"
uint32 protocol_major = 2; // 1
uint32 protocol_minor = 3; // 0
google.protobuf.Timestamp started_at = 4;
string config_path = 5;
}
```
A client whose major version differs from the daemon's MUST refuse to proceed and say which
component to update, naming both versions. This is the one place the product must not present a
generic connection error.
**Never reuse a field number.** Removing a field means reserving its number, so a future field
cannot silently inherit an old meaning.
---
## 3. Service surface
One service, `Harbor`. Every RPC operates on live daemon state, so the GUI and CLI are
interchangeable (FR-039c).
### Queries
| RPC | Request → Response |
|---|---|
| `GetServerInfo` | `GetServerInfoRequest` → `ServerInfo` |
| `ListEndpoints` | `ListEndpointsRequest` (optional kind filter) → `ListEndpointsResponse` |
| `GetEndpoint` | `GetEndpointRequest` → `Endpoint` |
| `ListRoutes` | `ListRoutesRequest` → `ListRoutesResponse` |
| `ListPeers` | `ListPeersRequest` → `ListPeersResponse` |
| `ListEvents` | `ListEventsRequest` (since, limit, endpoint) → `ListEventsResponse` |
| `GetCapabilities` | `GetCapabilitiesRequest` → `GetCapabilitiesResponse` |
| `GetStatus` | `GetStatusRequest` → `Status` |
### Mutations
| Group | RPCs |
|---|---|
| Virtual ports | `CreateVirtualPort`, `RenameEndpoint`, `DeleteVirtualPort`, `SetEndpointEnabled` |
| Network sessions | `CreateNetworkSession`, `ConnectPeer`, `AddManualPeer`, `DisconnectPeer`, `RemovePeer`, `RespondToInvitation`, `SetInvitationPolicy` |
| Physical devices | `ForgetPhysicalDevice`, `ResolveAmbiguousDevice` |
| Bluetooth | `StartBluetoothScan`, `StopBluetoothScan`, `ConnectBluetoothDevice`, `DisconnectBluetoothDevice`, `ForgetBluetoothDevice`, `SetPeripheralAdvertising` |
| Routes | `CreateRoute`, `DeleteRoute`, `SetRouteEnabled` |
| Configuration | `ExportConfiguration`, `ImportConfiguration`, `ReloadConfiguration` |
| Diagnostics | `ExportDiagnostics` |
Physical devices are discovered, never created, so no `CreatePhysicalDevice` exists.
`RenameEndpoint` without `confirm = true` fails `FAILED_PRECONDITION` carrying the applications
that may need to reselect the port (FR-001 scenario 4).
`CreateRoute` succeeds even when it forms a cycle, returning the affected routes marked
`LOOP_DETECTED` so the client can warn (FR-033).
### Server-streaming RPCs
Streams replace the original topic-subscription mechanism. A client opens the streams it needs.
| RPC | Yields | Delivery |
|---|---|---|
| `WatchState` | `StateEvent` — endpoint added/removed/changed, route validity, capabilities | **Lossless** |
| `WatchEvents` | `Event` — mirrors the bounded history | **Lossless** |
| `WatchInvitations` | `Invitation` — requires a `RespondToInvitation` (FR-014) | **Lossless** |
| `WatchTraffic` | `TrafficUpdate` — coalesced counters | **Lossy**, see below |
| `MonitorEndpoint` | `MidiMessage` — decoded messages on one endpoint (FR-047) | **Lossy**, see below |
---
## 4. Backpressure (Principle III)
gRPC has HTTP/2 flow control, which means a slow client can, by default, apply backpressure all
the way back to the producer. **On the two high-rate streams that is exactly the wrong behaviour**:
a stalled GUI must never slow down MIDI delivery.
So the daemon applies an explicit policy per stream:
- **`WatchTraffic` and `MonitorEndpoint` are lossy by contract.** Each subscriber gets a bounded
channel. When it is full the daemon **drops the update and increments a counter** rather than
awaiting capacity. The next message delivered carries `dropped: uint64` so the client can show
that it fell behind. The daemon never blocks and never grows a buffer.
- **`WatchState`, `WatchEvents` and `WatchInvitations` are lossless.** These are low-rate and
correctness-relevant. A client that cannot keep up with them is disconnected with
`RESOURCE_EXHAUSTED` rather than having its view silently diverge.
- **`WatchTraffic` coalesces** to at most one update per endpoint per 250 ms. Counter updates are
never emitted per-message.
Serialisation for every stream happens on ordinary async tasks reading atomics and ring buffers.
No gRPC code runs on the MIDI data path.
---
## 5. Errors
gRPC status codes carry the class of failure; the domain reason rides along in the details.
| Condition | Status code |
|---|---|
| Endpoint, route, peer or device not found | `NOT_FOUND` |
| Name already in use, duplicate route | `ALREADY_EXISTS` |
| Confirmation required and not supplied | `FAILED_PRECONDITION` |
| Capability unavailable on this system | `UNAVAILABLE` |
| Malformed request, invalid name or port | `INVALID_ARGUMENT` |
| Client speaks a major version the daemon does not serve | `UNIMPLEMENTED` |
| Lossless-stream client fell too far behind | `RESOURCE_EXHAUSTED` |
Every error additionally carries a trailing metadata entry `harbor-reason` holding the stable slug
from `FailureReason::code()` — `name_conflict`, `permission_denied`, `adapter_unavailable`, and so
on. **Clients switch on that slug, never on the message text**, and the CLI derives its exit codes
from it. The status message itself is human-readable and may change between releases.
Capabilities work the same way: `Capability.id` (such as `bluetooth_central`) is stable and is
what a client decides from, and `Capability.name` is for people.
When the user can do something about the failure, the error also carries `harbor-guidance-bin`:
UTF-8 text saying what, from `FailureReason::guidance()`. It is binary metadata because guidance
names things the user chose, which need not be ASCII. Like the message, it is for people and may
change; clients show it and never parse it.
This keeps one mapping — `FailureReason` → slug → gRPC status → CLI exit code — with the closed
enum in `midi-harbor-core` as its single source of truth.
---
## 6. Behavioural guarantees
1. **The daemon never blocks the MIDI data path on IPC.** Serialisation reads atomics and ring
buffers from ordinary tasks (Principle III).
2. **State is authoritative in the daemon.** Clients hold a cache fed by `WatchState` and must
tolerate re-syncing at any time via `ListEndpoints`.
3. **Connecting or disconnecting a client never disturbs a connection** (FR-037). No resource in
this protocol is scoped to a client's lifetime.
4. **Requests are idempotent where they can be.** `SetEndpointEnabled` to the current value
succeeds as a no-op; `DeleteRoute` on an absent route returns `NOT_FOUND`, not a broken state.
5. **No RPC blocks indefinitely.** Scanning and connecting return as soon as the state machine
accepts the request, with the endpoint in `CONNECTING`; progress arrives on `WatchState`.
6. **The `.proto` file is the contract.** Changing it is a contract change and requires a version
decision, whether or not any Rust signature changes.
---
## 7. Why gRPC, and what it costs
The upsides that decided it: a schema-first contract that cannot drift from the implementation,
generated clients in any language, server streaming with real flow control instead of hand-rolled
subscriptions, and no bespoke framing code to get wrong.
The costs, accepted deliberately:
- **A build-time protobuf compilation step**, which `tonic-prost-build` handles with a vendored
`protoc`, so contributors need no system package.
- **A heavier dependency tree** — `tonic`, `prost`, `hyper`, `tower` — in the headless build,
which was a point in favour of the original hand-rolled JSON. Measured against the alternative
of maintaining our own framing, versioning and streaming semantics, this is the better trade.
- **The wire is no longer human-readable.** `grpcurl` against the socket, plus the `--json` CLI
output, cover the debugging need that plain JSON would have given for free.
- **Flow control has to be actively overridden** on the two lossy streams, as §4 sets out. This is
the one place gRPC's defaults are wrong for this product, and it is a deliberate override rather
than an oversight.