Device Mesh¶
The Device Mesh lets a user's own devices act on each other's behalf: « ouvre mes tâches sur mon PC » typed on a laptop opens a screen on the desktop, and a reminder created on a Mac appears on a phone.
It is a fleet of one owner. There is no server, no account, and no notion of sharing between people. Every device holds the same private data and the same authority; the mesh only carries intent between them.
Trust model¶
A device is in the fleet or it is not. There is no partial membership.
| Concept | Meaning |
|---|---|
| Owner id | Names the fleet. Minted once, propagated by pairing. A command carrying a different owner id is refused without further examination. |
| Device id | sha256(public_key)[:24], prefixed dev_. Derived, never asserted — a device cannot choose its own name in the protocol sense. |
| Trust level | TRUSTED after redeeming an invitation; REVOKED after the user removes it. Revocation is terminal: the only way back is to forget the device entirely and pair it again. |
| Capabilities | What the device may be asked to do. Always the intersection of what it declares and what its platform ceiling allows (capabilities.py), never the union. |
Pairing is mutual and short-lived. The host mints a single-use invitation (10 minutes); the joining device redeems it with its public key, and receives the host's identity and address in the same response. Both sides now hold each other's public key, which is the only credential the mesh ever uses afterwards.
The platform ceiling¶
PLATFORM_CAPABILITIES in capabilities.py is the answer to "what could this class of device honour even in principle". iOS, iPadOS and Android exclude host automation and desktop.open; WEB is client-only; an unrecognised platform falls to a read-only floor.
Filesystem access is not a ceiling question. No verb in this vocabulary grants it on any platform — filesystem.workspace.read / .write were removed on 25 August 2026, having never been declared by any client — and FORBIDDEN_PARAMETER_NAMES in tools.py refuses a path parameter structurally. File transfer is a separate subsystem (/v1/mesh/files) with its own session, guarded by a signed offer and a session token rather than by a capability.
This is enforced at every point a device could try to widen its own grant — pairing, an explicit declaration, a presence beacon, a poll — because a check that exists at only one of those is a check that will eventually be bypassed by the others.
What can be commanded¶
The remote catalogue is closed and small (tools.py):
| Tool | Effect | Offline policy |
|---|---|---|
app.navigate |
Open a screen named by a success:// route |
REQUIRE_ONLINE |
app.show_resource |
Show one task, project, note or habit | QUEUE_UNTIL_EXPIRATION |
app.open |
Bring the app to the front | REQUIRE_ONLINE |
notifications.show |
Display a notification | QUEUE_UNTIL_EXPIRATION |
desktop.open |
Open an app, URL, file or search on the target computer | REQUIRE_ONLINE |
desktop.open is the only verb that leaves the application to drive the
desktop, and the only one with an open-ended target. Two consequences,
both settled on 25 August 2026: it declares requires_confirmation, so the
receiver refuses an envelope that does not attest the user agreed — which is
what finally makes check 10 of verify_command a live check rather than a
documented one; and it is not offered to the model (_HORS_PORTEE_DU_MODELE
in tools/mesh_tools.py), because a sentence should not grant more power over
a distant machine than the same sentence grants locally.
Each tool declares typed parameters, and a structural guard refuses any tool whose parameters include a passthrough name — command, path, url, sql, script, eval and the rest:
FORBIDDEN_PARAMETER_NAMES = frozenset({
"action", "command", "method", "code", "script",
"sql", "query", "exec", "eval", "path", "url", "shell",
})
The point is not that today's four tools are safe. It is that the next tool cannot quietly be a shell wearing a costume.
The command envelope¶
Every command is an Ed25519-signed envelope. verify_command() runs eleven checks in a deliberate order:
- protocol version
- owner — same fleet
- destination — addressed to us
- origin — a device we know, trust, and hold a key for
- expiry, with bounded clock tolerance in both directions
- signature, over the envelope minus itself
- the tool exists in the catalogue
- arguments match the tool's declared shape
- capabilities — what this device can honour
- confirmation — an impactful tool may not run unconfirmed
- nonce spent, last
The order carries meaning. Cheap structural checks come first so a misaddressed command never reaches the cryptography. The nonce is spent last so a command rejected for any other reason does not burn a nonce the legitimate sender still needs.
NonceStore.spend() uses a PRIMARY KEY insert as its atomic test — two racing deliveries of the same command cannot both succeed, because only one insert can win.
Two transports¶
The mesh has to reach two very different kinds of device, and one shape does not fit both.
Push — computers¶
A machine with a reachable address is dialled directly: the sender POSTs the signed envelope to POST /v1/mesh/commands/deliver. Lowest latency, and the sender learns the outcome in the same round trip.
The address is learned from the device itself and is a promise: local_address() reports where the process is actually listening, never a guess from configuration. A server bound to loopback advertises loopback, even though a LAN address would look more useful — peers off that machine genuinely cannot reach it, and telling them otherwise sends commands into the void and has them reported as delivered.
Pull — phones and tablets¶
Succès Flutter runs on devices that cannot be dialled: no stable address, a carrier NAT in the way, and an operating system that suspends the app whenever the user looks away. The direction flips. The device asks:
POST /v1/mesh/commands/poll → { commands: [...signed envelopes...] }
POST /v1/mesh/commands/ack → what it did with them
Two consequences fall out of this rather than being designed in:
- The poll is the heartbeat. A device asking for its commands has proved it is awake more convincingly than any beacon could, so the same request records presence.
- A queued command is not a failed one. A phone polling every few seconds collects within seconds, so
« elle n'a pas été effectuée »would be a lie.
A device is treated as pull-mode when it has told us so by polling (transport == "pull"), never inferred from the absence of an address — a desktop that has simply not announced yet also has no address and will never come to fetch.
Presence¶
Presence is derived, never stored as a state. presence_of() reads the last-seen timestamp and returns one of four states:
| State | Age of last contact |
|---|---|
ONLINE |
≤ 45 s |
IDLE |
≤ 5 min |
BACKGROUND |
≤ 30 min |
OFFLINE |
beyond, or revoked |
A revoked device is OFFLINE regardless of how recently it was seen.
Devices announce themselves with the same credential they use to command — an Ed25519 signature — because a joining device never holds this machine's API key. Replay is stopped by monotonicity rather than nonces: a beacon must be strictly newer than the last accepted one. A heartbeat every fifteen seconds would mint 5 760 nonces per device per day to protect a message whose entire content is "still here"; one integer per device refuses the same attack for nothing.
The honesty contract¶
The rule the whole system exists to keep:
A command that was merely queued must never be reported as done.
Every terminal status carries a French sentence true of that status and no other, and dispatch.py is deliberately the only place that decides what the user is told.
| Situation | What the user reads |
|---|---|
| Delivered and executed | « C'est fait. » |
| Device asleep, tool needs it awake | « … est hors ligne : cette action demande un appareil actif, elle n'a pas été effectuée. » |
| Device awake but unreachable on the network | « … n'a pas pu être joint : … » |
| Device asleep, tool can wait | « … est hors ligne : la commande est en attente et partira dès son retour. » |
| Polling device, awake | « C'est prêt pour … : l'appareil le récupérera dans quelques secondes. » |
| Polling device, asleep | « … : l'appareil le récupérera à son réveil. » |
The distinctions are not decoration. "Offline" and "unreachable" call for different things from the user — waiting versus checking the network — and being told the wrong one wastes their time on the wrong machine.
Security boundary¶
Routes outside the API key wall¶
Five routes are reachable without the local API key, because the device calling them has never had it:
| Route | Credential |
|---|---|
POST /v1/mesh/pairings/redeem |
the one-time invitation |
POST /v1/mesh/commands/deliver |
Ed25519 signature over the envelope |
POST /v1/mesh/presence |
Ed25519 signature over the beacon |
POST /v1/mesh/commands/poll |
Ed25519 signature over the poll |
POST /v1/mesh/commands/ack |
Ed25519 signature over the results |
A signature proves more than a shared secret would: it says which device, and it binds the exact contents. The seven checks common to the last four live in one place (signed.py) so there is exactly one copy to get right.
The local-only exemption¶
Diapason's local_only mode is fail-closed: nothing leaves the machine. The mesh holds one documented exemption, and both halves are required:
if (device or {}).get("trustLevel") != "TRUSTED":
raise LocalOnlyError(...)
if not address_is_private(address):
raise LocalOnlyError(...)
A paired, trusted device at a private address is the user's own other computer, not "elsewhere". Anything failing either half is refused exactly as before. mesh/transport.py and mesh/beacon.py are listed in tests/privacy/outbound_manifest.txt, and the ratchet test fails in both directions if that stops being true.
What the assistant can do¶
The chat assistant sees two tools, split deliberately: mesh_devices only looks, mesh_send acts. Both entered _TROUSSE_ASSISTANT on 25 August 2026 — until then this paragraph described an intention, not the code: the tools were registered and handed to nobody, so « ouvre mes tâches sur mon PC » had no path at all. A test now guards their presence, mirroring the one that guards their absence in voice.
mesh_send declares risk: "outward_action". It is not in the live-voice allow-list (speech/realtime/tools.py), because that path runs its tools directly rather than through ToolExecutor, where the approval system lives. A tripwire test enforces this and says when to delete itself.
Known limits¶
- The server binds
127.0.0.1by default, so the mesh does not yet cross machines without the user opening the network interface. That is a security decision that belongs to them. execute_voice_toolbypassesToolExecutor, and therefore approvals. This must be fixed before anyremote.*tool is exposed to the voice path.- The inbox queue is in-process memory, capped at 16 entries. A backend restart loses whatever was waiting for the desktop shell to collect.
- Joining is one-way in the UI. The host can mint an invitation from the
Devices page; nothing there redeems one. The guest path lives in the CLI
(
diapason mesh join <address> <code>) and inmesh/join.py. Before 25 August 2026 it did not exist at all in this repository, which is why the only paired peers were created by the Flutter client. - There is no file transfer. This is a mesh of commands: stateless,
short-lived, signed envelopes. A resumable transfer needs a session
lifecycle that neither
commands.pynorqueue.pycarries — and it must not be bolted onto the command envelope, since adding a signed field breaks every phone in the field. - Envelopes are signed, not encrypted. On a trusted LAN that is enough; it stops being enough the day a relay exists.
Files¶
| Module | Responsibility |
|---|---|
identity.py |
keys, device id, canonical bytes, envelope signing |
registry.py |
paired devices, invitations, trust, revocation |
capabilities.py |
the platform ceiling |
presence.py |
derived presence |
commands.py |
the envelope, its eleven checks, nonces |
tools.py |
the closed remote catalogue |
queue.py |
durable command queue |
transport.py |
LAN delivery and the local-only exemption |
beacon.py |
outgoing and incoming presence |
pull.py |
poll and acknowledgement, for devices that fetch |
signed.py |
the seven checks shared by device-signed requests |
dispatch.py |
sending, and what the user is told |
executor.py |
running a verified command here |
resolver.py |
« sur mon PC » → a device id, or a question |
routes.py |
the HTTP surface |