Skip to content

docs(spec): the local API chapter - #486

Merged
bahdotsh merged 1 commit into
mainfrom
docs/headless-local-api-spec
Sep 30, 2026
Merged

bahdotsh merged 1 commit into
mainfrom
docs/headless-local-api-spec

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

The local API chapter: how one server process fronts one engine for several local applications, so a host that is not a phone can run the SDK as a service. docs/spec/local-api.md is the contract; docs/bridges/local-api.md is what a server owes as a binding one level up. Nothing ships in this PR but the contract. The reference server follows it in the next PR, and this chapter is written so that server's tests can hold it to the text.

  • JSON-RPC 2.0 over one WebSocket. Method names, parameter names, results, enum spellings, event tags and error variants are the interface definition's own; the server maps and never reimplements. A one-shot call is a connection that sends hello, one request, and closes.
  • A connection sends as the application it declared in hello, and the server stamps that id on every send through the per-send id from feat(protocol,uniffi,bindings): a per-send app id, and the app id on received events #461. The id is self-declared; several clients may declare the same one.
  • Three routing classes for events: stamped (the event carries app_id), correlated (it names an id the server issued to a client, or a service a client owns, or it fired while the server was inside that client's call), broadcast (everything else). The per-application hold of stamped inbound events is C10's held buffer one level up: two of C10's three tags plus media_resend_required, same 256 cap.
  • The socket is the authorization boundary. Unix domain socket by default (0600 in a 0700 directory); TCP on loopback only, opt-in, with a per-launch token in a file created 0600 atomically. Root and every process of the owner's uid are inside the boundary and peer credentials are not checked. Service ownership, space scoping, per-application method denial and the unlisted-id rule are server-side rules that separate applications from each other's mistakes, never reach the mesh, and refuse with existing ProtocolError variants.
  • Errors keep the engine's taxonomy. error.data.variant is the variant name; error.code is -32000 - position in the append-only enum (zero-based, unlike UniFFI's one-based discriminant, and stated so), so C2 is what keeps the numbers stable and a new variant numbers itself.

What the chapter is the first to write down

Count Note
Declarations in the interface definition 218 177 on OfflineProtocol (the plan counted 174 at its base commit; #455, #470 and #457 added three), 31 on DataStore, 6 on MeshServices, 4 namespace functions
Methods a client may call 126 Grouped by concern; services. and data. prefixes keep the three objects distinct on the wire
Platform operations never on the wire 92 The plan said 56 and counted only the transport drivers; this chapter also classes the lifecycle, the run loop and drain, the storage attach, telemetry (C12: the server is the binding), the host feeds, the carrier signatures, the inbound chunk driver and data.wipe_all as the server's
Event tags, each with its fields and routing 75 Matches the plan. The API reference documents about twenty with prose and one shape there (message_sent) is stale
Error variants 25 Matches the plan

The two method tables partition the definition and the catalogue covers the enum: checked with a script that parses the chapter the way the PR11 guard will (first backticked cell of each table row, between the named headings). 126 + 92 = 218, no name in both tables, no name missing; 75 tags in, 75 out.

Replaces the plan's H5 wording

Decision H5 in the program record said "HTTP POST for one-shot calls on the same port, through the WebSocket library's request hook". That was verified against websockets 16.1, the version the Python package's lock file pins (the package accepts 12 up to 17, so 16.1 is the only version checked), before this chapter was written, and it is dead: the handshake parser accepts only GET and closes any other method with no HTTP response at all, before the hook runs. A POST would fail with zero bytes back rather than with a JSON-RPC error. The chapter therefore specifies WebSocket only, records the verified fact under "Why there is no HTTP" so it is not re-added, says the reference server pins it with a test, and allows an optional unauthenticated GET /health through the hook (which does reach it) carrying only the server version and carrier kind. The plan's own fallback for exactly this case applies.

Also in this PR

  • Index rows in docs/spec/README.md, docs/README.md and docs/bridges/README.md.
  • docs/README.md said the shared bridge contract has ten rules; it has thirteen. It said twenty-four ADRs; there are twenty-five. Both corrected.
  • docs/README.md was missing the peer-stream framing chapter from its specification table since docs(spec): the peer-stream framing chapter #456; the row is added.
  • A CHANGELOG.md entry under Unreleased, following docs(spec): the peer-stream framing chapter #456's precedent for a chapter with no code.

Review round 1

Eight findings from the fresh-context review, all fixed in the amended commit:

  1. Transport vocabulary. message_received, message_delivered, the DORS events and an engine-emitted transport_switched carry TransportType::label() (ble, wifiDirect, internet, reticulum, nostr, or delayed); neighbor_discovered and an FFI-emitted transport_switched carry the bridge's casing (BLE, WiFiDirect, Internet, Reticulum, Nostr). wifi_direct never occurs. Both vocabularies are now listed per tag.
  2. "Same tags as C10" was false. The hold covers two of C10's three tags plus media_resend_required; message_decryption_failed is excluded until it carries an app_id. Chapter and bridge page corrected.
  3. The drain's return value. L2 now says receive_message() returns the core Message JSON (id, capitalised priority, forwarded_from, no transport), that the FFI crate discards it on purpose, that the Python manager synthesises a second off-catalogue message_received from it, and that a server drops the synthesised one.
  4. Self-declared ids. Invariant 2 is restated per connection; invariant 4 says ids are self-declared, root and same-uid processes are inside the boundary, and the rules separate applications from each other's mistakes, not from a hostile local process. A fourth server-side rule: once any space allow-list or method deny is configured, a hello with an unlisted id is refused with PermissionDenied (otherwise a denied application reconnects under an unlisted id and bypasses everything); with no rules, any id is accepted. Hold delivery text made consistent.
  5. Stamped tag with app_id absent. file_received.app_id and media_resend_required.app_id are optional, and the engine emits file_received without it when the assembly has no metadata entry. Such an event is broadcast, not held, and logged.
  6. MediaMetadata gains sticker_remote_id? and sticker_kind?.
  7. "The pinned library" now cites the lock file, says the behaviour was verified on 16.1 only, and that the reference server pins it with a test.
  8. Boundary wording. Token file created atomically with mode 0600; peer credentials not checked; root and every process of the owner's uid inside the boundary. Error codes: zero-based position versus UniFFI's one-based discriminant stated so nobody "corrects" it.

Re-check

Three residuals from the re-check at 15b7618, landed in the amended commit:

  1. The unlisted-id rule is triggered only by the two configured rules (space allow-list, method deny). Service ownership is a runtime shadow built from the clients' registrations, and the chapter now says so explicitly, so a first register_service can never flip an open server to default-deny.
  2. L3 and L4 in the bridge page list the unlisted-id refusal beside the other rules and refusals.
  3. The FFI-emitted transport_switched also carries the literal None as to when a carrier disconnects and nothing takes over (four sites); added to the bridge-casing vocabulary.

Validation

  • A script in the working notes parses the chapter's three tables and compares them to the UDL and to events.rs with the same enum scan react_native_types_cover_all_event_variants uses: partition exact, coverage exact. Re-run after the review fixes.
  • Every relative link and anchor in both new pages resolves (scripted check over the headings of each target). Re-run after the review fixes.
  • cargo fmt --all -- --check passes. cargo test -p offline-protocol --lib spec_vector_index (the two guards that read docs/spec/README.md) pass, before and after the fixes. No Rust source in the repository reads docs/README.md, docs/bridges/README.md or the changelog.
  • Claims audited against the code before pushing: message_received is emitted from inside receive_message() and the delivery ACK is sent there too (the first invariant); message_sent is emitted inside the send call on an immediate transport success (the correlation rule's "during a client's call" clause exists for it); the priority string is lowercase on message_sent and capitalised on ack_evicted; the transport label vocabularies above, checked at TransportType::label(), the FFI notify_neighbor_reachable call sites and the DORS call sites; file_data and attachment data are base64 in events while other byte fields are number arrays; the file size limit is 100 MiB and not reachable through the interface; the app id rule is validate_id (256 bytes, no control characters, no /, \, :); file_received is emitted with app_id: None on the no-metadata path.
  • No em dashes in the new text.

Not in this PR

  • The reference server, its dispatch table and the Rust guard (PR11). The chapter states the exact shape the guard parses.
  • The client examples and the user guide (PR12).
  • Any engine change. Two gaps the chapter records for the engine: message_decryption_failed carries no app_id although the frame did, so it is broadcast and not held until the engine adds the field (additive under C3); group sends carry only the configured application id, because the per-send id is a 1:1 option today.

Notes for reviewers

  • The platform set is wider than the plan's 56. The chapter's rule is: anything that drives the run loop, installs a callback, feeds or drains a carrier, attaches storage, owns telemetry, states a host fact or signs as the device is the server's. sign_data stays exposed (every binding exposes it, and every client shares the identity by construction) with a per-application deny available to the operator; identity_assertion and gateway_address_declaration do not, because they sign what a carrier presents to prove the device.
  • Correlation includes "emitted during the client's call". The engine emits synchronously on the calling thread, so message_sent fires before the send returns and before the server holds the message id. Without that clause the first event of every send would be broadcast. The reference server implements it with a context variable around each dispatched call.
  • Error codes from enum position is a deliberate reuse of C2 rather than a hand-assigned table. If a reviewer prefers hand-assigned codes, the table is the only place to change.

@bahdotsh
bahdotsh force-pushed the docs/headless-local-api-spec branch from 35df2d7 to 15b7618 Compare September 30, 2026 16:01
One server process fronting one engine for several local applications:
JSON-RPC 2.0 over a WebSocket, a Unix domain socket by default and 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
engine's events relayed unchanged as notifications.

The chapter 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 and acknowledges a message only from inside the drain. It is
the first complete catalogue of the engine's 75 event tags with their
fields, vocabularies and routing (by application id, by an identifier
the server issued, or to everyone), and it keeps the 25-variant error
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's handshake
parser accepts only GET and drops any other method without a response,
which replaces the program record's H5 wording and is recorded in the
chapter so it is not re-added. An optional unauthenticated GET /health
through the request hook is allowed.

docs/bridges/local-api.md states which shared bridge rules a server
inherits (C1, C2, C3, C6, C10, C12) and the new rule that a Rust guard
pins the three tables to the definition, the engine and the reference
server. Index rows added; docs/README.md corrected to thirteen shared
rules and twenty-five ADRs, and given the peer-stream framing row it
lacked.
@bahdotsh
bahdotsh force-pushed the docs/headless-local-api-spec branch from 15b7618 to 3c88ae8 Compare September 30, 2026 16:07
@bahdotsh
bahdotsh merged commit c6489f4 into main Sep 30, 2026
22 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant