Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Added

- **The local API chapter.** `docs/spec/local-api.md` specifies how one
server process fronts one engine for several local applications: JSON-RPC
2.0 over a WebSocket on a Unix domain socket by default (TCP on loopback
with a per-launch token as the opt-in), a `hello` that declares the
client's application id once and stamps it on every send, and the events
relayed unchanged as notifications. It is the first complete catalogue of
the engine's events: all seventy-five tags with their fields and the
vocabularies of the enum-valued ones, and how each is routed (by
application id, by an identifier the server issued, or to everyone). The
method table partitions the interface definition into the 126 methods a
client may call and the 92 platform operations it never can, including the
run loop and the drain, which the server owns because the engine delivers
a message only when something drains. Errors keep the engine's
twenty-five-variant taxonomy: the JSON-RPC code is the variant's position
in the append-only enum. There is no HTTP request path: the pinned
WebSocket library drops any non-`GET` handshake without a response, which
the chapter records so it is not re-added. `docs/bridges/local-api.md`
states which shared bridge rules the server inherits and the new rule that
a Rust guard pins the three tables to the definition, the engine and the
reference server. Nothing ships in this entry but the contract; the
reference server follows it.
- **The leaf node builds for ESP32 RISC-V parts and for Cortex-M0.**
`offline-protocol-leaf` now compiles, and CI lints it, for
`riscv32imac-unknown-none-elf` (ESP32-C6, ESP32-H2),
Expand Down
7 changes: 5 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ implementation written against these documents should interoperate.
| [Capability negotiation](spec/capability-negotiation.md) | What peers advertise, what it gates, what absence means |
| [Leaf node provisioning](spec/leaf-provisioning.md) | What a constrained device owes at pairing, the never-committing profile |
| [Bluetooth LE framing](spec/ble-framing.md) | The GATT contract, the fragment header, and what a receiver owes on reassembly |
| [Peer-stream framing](spec/stream-framing.md) | The preamble that proves a stream's peer, the length-prefixed message frame, and the LAN discovery hint |
| [Username discovery and invites](spec/username-discovery.md) | The self-certifying invite payload, the username directory, and the signing-domain registry |
| [The gateway contract](spec/gateway-contract.md) | What a gateway is, the five verbs, the daemon wire protocol, and the backbone |
| [The local API](spec/local-api.md) | One server, several local applications: JSON-RPC over a WebSocket, the method and event tables, routing, replay, errors |
| [Conformance](spec/conformance.md) | The two profiles, what every implementation owes, and the vectors that decide it |

## Security
Expand All @@ -87,7 +89,7 @@ implementation written against these documents should interoperate.

| Document | Scope |
|----------|-------|
| [ADR index](adr/README.md) | Twenty-four decisions that are expensive to reverse or easy to undo by accident |
| [ADR index](adr/README.md) | Twenty-five decisions that are expensive to reverse or easy to undo by accident |

If something in the codebase looks redundant or over-engineered, check here
before simplifying it.
Expand All @@ -99,11 +101,12 @@ in here fails **silently** when violated.

| Document | Scope |
|----------|-------|
| [Shared contract](bridges/README.md) | The ten rules every binding shares |
| [Shared contract](bridges/README.md) | The thirteen rules every binding shares |
| [Swift](bridges/swift.md) | iOS native and the React Native iOS bridge |
| [Kotlin](bridges/kotlin.md) | Android native and the React Native Android bridge |
| [Python](bridges/python.md) | Desktop and tooling |
| [TypeScript](bridges/typescript.md) | The React Native JavaScript surface |
| [Local API](bridges/local-api.md) | A server fronting one engine for several local clients over a socket |

## Release history

Expand Down
1 change: 1 addition & 0 deletions docs/bridges/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ binding.
| [Kotlin](kotlin.md) | Android native, the Android library and the React Native Android bridge |
| [Python](python.md) | Desktop and tooling |
| [TypeScript](typescript.md) | The React Native JavaScript surface |
| [Local API](local-api.md) | A server fronting one engine for several local clients over a socket, and the reference server |

Integration **guides** live elsewhere: [iOS](../ios-integration.md),
[Android](../android-integration.md),
Expand Down
121 changes: 121 additions & 0 deletions docs/bridges/local-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Local API contract

Covers a server that fronts one engine for several local clients over the
[local API](../spec/local-api.md), and the reference server in the Python
package.

Read [the shared contract](README.md) first. A server is a binding one level
up: it is written against a generated binding (the reference one against
Python), so everything that binding owes, the server owes through it, and
this document covers what is specific to sitting between the binding and a
socket.

## Which shared rules apply verbatim

| Rule | Why it reaches the server |
|---|---|
| [C1](README.md#c1-regenerate-every-binding-together) | The server calls a generated binding. A partial regeneration fails at the server's first call, at runtime, exactly as it does in an application |
| [C2](README.md#c2-the-error-enum-is-append-only) | The JSON-RPC error code is the variant's position in the enum. Append-only is what makes that number stable; an inserted variant would renumber every client's error handling as well as breaking the bindings |
| [C3](README.md#c3-events-cross-as-opaque-json) | An event notification's `params` is the engine's JSON, unchanged. The server never parses an event for anything but routing (`type`, `app_id`, the correlation identifiers), and never re-encodes one |
| [C6](README.md#c6-config-parsers-must-not-default-to-literals) | A dictionary parameter arrives partial. The server passes the fields the client sent and lets the definition's defaults fill the rest; a literal fallback in the server would reset every field a client did not mention |
| [C10](README.md#c10-lifecycle-rules-for-event-emission) | The per-application-id hold of stamped inbound events is C10's held inbound buffer, one level up: the same 256-entry capacity and the same reason (the engine has already acknowledged the message), over two of C10's three tags (`message_received`, `file_received`) plus `media_resend_required` because it is stamped; `message_decryption_failed` is excluded until it carries an `app_id` |
| [C12](README.md#c12-telemetry-is-callback-free-and-the-binding-fills-the-platform) | The server is the binding that fills the platform fields and owns the session boundary. Telemetry is therefore a server-owned operation, and no client can enable, disable or flush it |

## L1. The method and event tables are pinned by a Rust guard

The chapter carries three tables that name things the compiler never sees
together: the methods a client may call, the platform operations it may not,
and the events it may receive. Each rots silently. A method added to the
interface definition and to neither table is unreachable from every client
and unmentioned in the contract; an event added to the engine and not to the
catalogue is one no client is written for; a method the reference server
exposes that the chapter lists as platform-only is a hole in the boundary
the chapter's fifth invariant draws.

A Rust guard in the FFI crate reads the chapter, the interface definition,
the engine's event enum and the reference server's dispatch table, and
asserts that the exposed set plus the platform set is exactly the set of
declarations, that the dispatch table exposes exactly the exposed set, and
that the catalogue's tags are exactly the enum's variants. It follows the
skip-if-tree-absent idiom the other document-reading guards use, so a
published crate's tests still build without the repository around them.

The chapter states the shape the guard parses (a row's first cell is the
backticked name; the three tables sit under three named headings). Changing
one of those headings, or moving a row out from under them, is a change to
the guard and fails it, which is the point: the document and the code can
only move together.

What this prevents: a new engine method quietly reachable by every local
application because the server's dispatcher was generated from the
definition and nobody classified it. The guard makes "unclassified" a
failing test rather than a default.

## L2. The server owns the run loop, the drain and the lifecycle

No client calls `process()`, `receive_message()`, `start()`, `stop()`,
`pause()` or `resume()`, and none of them is a method on the wire. The
server runs the loop the mobile bridges run (100 ms), drains on every tick,
and starts and stops the engine on the operator's instruction.

This is the first invariant of the chapter and it is load-bearing in a way
that is easy to miss: the engine emits `message_received` from inside the
drain, and acknowledges and dedup-marks the message there. A client that
could drain would take messages out from under the server's routing; two
drainers would split one stream; a server that did not drain would leave
every inbound message unacknowledged while the sender retries into silence.
The Python manager's own documentation already says that a second caller of
`receive_message()` always sees `None`; a server makes that impossibility
structural instead of documented.

**The drain's return value is never relayed.** `receive_message()` returns
the core `Message` as JSON, a different shape from the `message_received`
event the engine emits from inside the same call (`id` rather than
`message_id`, a capitalised `priority`, `forwarded_from` rather than
`forward_info`, no `transport`). The FFI crate discards that return value on
purpose, and the Python manager synthesises a second `message_received` from
it and hands it to the same handler as the engine's event. A server built on
that manager drops the synthesised event and relays only the engine's. The
failure otherwise is two `message_received` notifications per message, one
of them in a shape the catalogue does not describe, and a client that
deduplicates by `message_id` cannot even pair them, because the synthesised
one has no such field.

## L3. Routing and the hold are the server's, and never reach the wire

The three routing classes (stamped, correlated, broadcast), the per-application
hold, service ownership, space scoping, method denial and the refusal of an
unlisted id at `hello` once any allow-list or deny is configured are all
rules the server applies between the engine and the socket. Nothing about
them is sent to a peer, a relay or a gateway, and nothing about them changes
an event or a frame.

The reason to say so here is the temptation to make one of them real: an
ownership claim in the service descriptor, a per-application member in a
space. Both would be a second membership system beside MLS, which
[document replication](../spec/data-sync.md) forbids for the reason it
states there, and both would mean a peer could see which applications sit
behind an identity. The server knows; the mesh never does.

## L4. Session refusals reuse the engine's taxonomy

A method before `hello` is `InvalidState`; a bad application id is
`InvalidArgument`; a wrong token, a service another application owns, a
space outside the client's allow-list, a denied method group, or an unlisted
id at `hello` once any allow-list or deny is configured, is
`PermissionDenied`. No error variant exists only on the wire.

A server that invented its own codes for these would hand every client a
second taxonomy to handle, and would break the property that a client
written against the JSON-RPC surface handles the same variants an embedded
application does.

## What the reference server owes

| | |
|---|---|
| Carrier | A Unix domain socket by default, created `0600` in a `0700` directory; TCP on loopback only when enabled, with the per-launch token in a `0600` file |
| Frame limit | Raised from the WebSocket library's 1 MiB default to cover the engine's file size limit plus base64, so a media send is refused by the engine and not by a closed connection |
| Dispatch table | Checked in, classified against the two method tables, and read by the guard in L1 |
| Hold | Per application id, 256 entries, oldest dropped, each drop logged |
| Health | `GET /health` through the library's request hook, with the body the chapter shows; nothing else on HTTP |
1 change: 1 addition & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ document says which reading is normative for the wire.
| [Peer-stream framing](stream-framing.md) | The preamble that proves a stream's peer, the length-prefixed message frame, and the LAN discovery hint |
| [Username discovery and invites](username-discovery.md) | The self-certifying invite payload, and the non-authoritative username directory |
| [The gateway contract](gateway-contract.md) | What a gateway is, the five verbs it implements, the gateway-daemon wire protocol, and the backbone |
| [The local API](local-api.md) | One server fronting one engine for several local applications: JSON-RPC over a WebSocket, the `hello` handshake, the method and event tables, routing, replay, and the errors |
| [Conformance](conformance.md) | The two profiles, what every implementation owes, and how the vectors decide it |

## Conformance vectors
Expand Down
Loading
Loading