- 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.
10 KiB
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 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.sockwhenXDG_RUNTIME_DIRis unset. - macOS:
$HOME/Library/Application Support/com.mrgeckosmedia.MidiHarbor/daemon.sock.
- Linux:
- Permissions: socket file mode
0600, parent directory0700. - Encoding: protocol buffers,
proto3. - Schema location:
proto/midiharbor/v1/harbor.proto, compiled bytonic-prost-buildinto themidi-harbor-ipccrate. The.protofile 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 againstv1calling av2-only daemon gets gRPCUNIMPLEMENTEDfor 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
GetServerInfofirst, becauseUNIMPLEMENTEDalone does not tell a user which side is old:
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:
WatchTrafficandMonitorEndpointare 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 carriesdropped: uint64so the client can show that it fell behind. The daemon never blocks and never grows a buffer.WatchState,WatchEventsandWatchInvitationsare lossless. These are low-rate and correctness-relevant. A client that cannot keep up with them is disconnected withRESOURCE_EXHAUSTEDrather than having its view silently diverge.WatchTrafficcoalesces 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
- The daemon never blocks the MIDI data path on IPC. Serialisation reads atomics and ring buffers from ordinary tasks (Principle III).
- State is authoritative in the daemon. Clients hold a cache fed by
WatchStateand must tolerate re-syncing at any time viaListEndpoints. - Connecting or disconnecting a client never disturbs a connection (FR-037). No resource in this protocol is scoped to a client's lifetime.
- Requests are idempotent where they can be.
SetEndpointEnabledto the current value succeeds as a no-op;DeleteRouteon an absent route returnsNOT_FOUND, not a broken state. - No RPC blocks indefinitely. Scanning and connecting return as soon as the state machine
accepts the request, with the endpoint in
CONNECTING; progress arrives onWatchState. - The
.protofile 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-buildhandles with a vendoredprotoc, 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.
grpcurlagainst the socket, plus the--jsonCLI 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.