docs(spec): the local API chapter - #486
Merged
Merged
Conversation
bahdotsh
force-pushed
the
docs/headless-local-api-spec
branch
from
September 30, 2026 16:01
35df2d7 to
15b7618
Compare
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
force-pushed
the
docs/headless-local-api-spec
branch
from
September 30, 2026 16:07
15b7618 to
3c88ae8
Compare
This was referenced Sep 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdis the contract;docs/bridges/local-api.mdis 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.hello, one request, and closes.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.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 plusmedia_resend_required, same 256 cap.0600in a0700directory); TCP on loopback only, opt-in, with a per-launch token in a file created0600atomically. 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 existingProtocolErrorvariants.error.data.variantis the variant name;error.codeis-32000 - positionin 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
OfflineProtocol(the plan counted 174 at its base commit; #455, #470 and #457 added three), 31 onDataStore, 6 onMeshServices, 4 namespace functionsservices.anddata.prefixes keep the three objects distinct on the wiredata.wipe_allas the server'smessage_sent) is staleThe 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
websockets16.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 onlyGETand closes any other method with no HTTP response at all, before the hook runs. APOSTwould 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 unauthenticatedGET /healththrough 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
docs/spec/README.md,docs/README.mdanddocs/bridges/README.md.docs/README.mdsaid the shared bridge contract has ten rules; it has thirteen. It said twenty-four ADRs; there are twenty-five. Both corrected.docs/README.mdwas missing the peer-stream framing chapter from its specification table since docs(spec): the peer-stream framing chapter #456; the row is added.CHANGELOG.mdentry 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:
message_received,message_delivered, the DORS events and an engine-emittedtransport_switchedcarryTransportType::label()(ble,wifiDirect,internet,reticulum,nostr, ordelayed);neighbor_discoveredand an FFI-emittedtransport_switchedcarry the bridge's casing (BLE,WiFiDirect,Internet,Reticulum,Nostr).wifi_directnever occurs. Both vocabularies are now listed per tag.media_resend_required;message_decryption_failedis excluded until it carries anapp_id. Chapter and bridge page corrected.receive_message()returns the coreMessageJSON (id, capitalisedpriority,forwarded_from, notransport), that the FFI crate discards it on purpose, that the Python manager synthesises a second off-cataloguemessage_receivedfrom it, and that a server drops the synthesised one.hellowith an unlisted id is refused withPermissionDenied(otherwise a denied application reconnects under an unlisted id and bypasses everything); with no rules, any id is accepted. Hold delivery text made consistent.app_idabsent.file_received.app_idandmedia_resend_required.app_idare optional, and the engine emitsfile_receivedwithout it when the assembly has no metadata entry. Such an event is broadcast, not held, and logged.MediaMetadatagainssticker_remote_id?andsticker_kind?.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:
register_servicecan never flip an open server to default-deny.transport_switchedalso carries the literalNoneastowhen a carrier disconnects and nothing takes over (four sites); added to the bridge-casing vocabulary.Validation
events.rswith the same enum scanreact_native_types_cover_all_event_variantsuses: partition exact, coverage exact. Re-run after the review fixes.cargo fmt --all -- --checkpasses.cargo test -p offline-protocol --lib spec_vector_index(the two guards that readdocs/spec/README.md) pass, before and after the fixes. No Rust source in the repository readsdocs/README.md,docs/bridges/README.mdor the changelog.message_receivedis emitted from insidereceive_message()and the delivery ACK is sent there too (the first invariant);message_sentis emitted inside the send call on an immediate transport success (the correlation rule's "during a client's call" clause exists for it); theprioritystring is lowercase onmessage_sentand capitalised onack_evicted; the transport label vocabularies above, checked atTransportType::label(), the FFInotify_neighbor_reachablecall sites and the DORS call sites;file_dataand attachmentdataare 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 isvalidate_id(256 bytes, no control characters, no/,\,:);file_receivedis emitted withapp_id: Noneon the no-metadata path.Not in this PR
message_decryption_failedcarries noapp_idalthough 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
sign_datastays exposed (every binding exposes it, and every client shares the identity by construction) with a per-application deny available to the operator;identity_assertionandgateway_address_declarationdo not, because they sign what a carrier presents to prove the device.message_sentfires 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.