Device
protocol.
A device sits behind a shop's router on a network nobody at ArdaForm administers, so the device dials out and ArdaForm never dials in. Everything — check-ins, updates, settings, a screenshot, a shell — rides one connection the device opened. This page is how that connection works, at the level a security reviewer can check. You do not need it to build an app.
Overall shape
Three paths, each used for what it is good at. The device opens all three; nothing on the shop's network, and nothing on the internet, can open a connection to it.
| Path | Transport | Carries |
|---|---|---|
| Control | One WebSocket to connect.ardaform.net, held open for as long as the device runs | Check-ins, instructions, settings, dataset sync, logs, alerts, operator actions, webhook calls |
| Downloads | HTTPS GET to the same host, one short-lived URL per object | Project files, app containers, player builds |
| Tunnel | A second WebSocket, opened only when an operator asks for one | An SSH session, the portal's shell and its device view |
None of it is needed for the app to run. A device that cannot reach ArdaForm keeps running the app and the data it already holds; everything below is how it is kept up to date and looked after.
Transport and framing
- WebSocket over TLS, through Cloudflare. Cloudflare terminates that TLS, so it is not what keeps the traffic private. The session inside it is: every frame after the handshake is encrypted and authenticated end to end between the device and the control plane, and Cloudflare carries ciphertext.
- Binary frames. Each message is a fixed 16-byte header — revision, message type, flags, a numeric method id, a stream id, a correlation id and a sequence number — followed by a JSON payload. Methods are numbers, not names, so nothing readable is on the wire even before encryption.
- Padded before it is sealed. Plaintext is rounded up to a multiple of 64 bytes, so a check-in and a wipe cannot be told apart by their length.
- 256 KiB per frame. Anything bigger — a file, an app, a player build — goes over the download path instead.
- Compression is negotiated (gzip, when both sides declare it), and never applied to a frame carrying a credential: enrolment, the device password, de-registration.
- Four message kinds — request, response, event, error — plus streams with credit-based flow control, so a fast sender on a slow link waits rather than filling memory.
Handshake and keys
A signed ephemeral Diffie-Hellman exchange — the same arrangement as the TLS 1.3 and SSH handshakes, built only from primitives in the standard libraries of Go and Node: X25519, Ed25519, HKDF-SHA256 and AES-256-GCM. No third-party cryptography on either side.
device control plane
│ fresh X25519 key pair
│── 1 ephemeral public key, revision, connection class ──▶
│ fresh X25519 key pair
│◀── 2 ephemeral public key + Ed25519 signature ──────────│
│ over the transcript, by the control-plane key
│ verify against the control-plane key built into the player
│ both sides: HKDF-SHA256(X25519 shared secret, transcript)
│ → one key per direction
│── 3 (encrypted) device public key + Ed25519 signature ─▶
│ over the transcript so far
│ verify; look the key up
│── Hello ──────────────────────────────────────────────▶
│◀── Welcome ───────────────────────────────────────────│- Both signatures cover the running transcript hash, not a nonce, so a signature belongs to one handshake and cannot be replayed into another. The transcript includes the revision and the connection class, so neither can be downgraded by anything in the path.
- The two directions have separate keys; a frame the plane sent can never be played back to it as though the device had sent it.
- Nonces are counters, never random, and every frame carries a sequence number that must match. A session ends before its counter could wrap.
- Keys are ephemeral, so a later theft of either long-term key does not decrypt a recorded session.
- Every handshake failure looks the same to the other side — the same error, the same close code. A bad signature and an unknown device are indistinguishable, so the error is not an oracle.
- A socket that has not finished the handshake within 5 seconds is closed.
After the handshake the device sends Hello — its connection class, player build, device id, a session counter, a boot id, the features it supports, its dataset positions and its own clock (for diagnosis only). The plane answers Welcome with the revision it will speak, the authoritative time, the check-in interval, the features it supports, the account's limits and, when it is shedding load, how long to wait.
The keys
| Key | Proves | Private half held by |
|---|---|---|
| Device identity (Ed25519) | That this device is the one that enrolled — on every connection, including tunnels | The device. Generated there at first start; never leaves |
| Control-plane key (Ed25519) | That the device is talking to ArdaForm's control plane, not to whatever is in the path | The control plane. The public half is built into every player |
| Instruction key (Ed25519) | That an instruction — update, wipe, lock — came from ArdaForm, for this device | The control plane. The public half is built into every player |
| Session keys (AES-256-GCM) | Nothing on their own; they encrypt one session | Both ends, for the life of one connection |
What a device proves, and when
| Moment | What is established |
|---|---|
| Handshake | The device holds the private half of the key it presented, and the plane holds the control-plane key. Possession only — not yet that the device belongs to anyone. |
| Hello | The key is looked up. A known key gets a session for that device. An unknown one gets a session in which the only thing it may do is ask to join. A key the plane no longer knows is closed with NotEnrolled — a reason to try again later, never a reason to wipe: a missing database row is not an instruction. |
| Two connections at once | The device numbers every dial. If a second socket claims the same device, the one with the older number is closed with Superseded and told not to redial straight away, so two copies of one device cannot fight. |
| Every frame | Authenticated encryption plus an expected sequence number. A frame that was altered, reordered or replayed ends the session. |
| A tunnel | Runs the full handshake again on its own socket. The single-use token it presents only says which request it answers; the handshake says which device, and the two must agree. |
Joining: the code on the screen
The device shows a code and a person types it into the portal — not the other way round.
- At its first start the device generates its identity key, dials the control plane and sends
EnrolRequest: its public key (which must equal the key from the handshake), a hardware id, its model, OS, architecture and player build. - The plane mints a five-character code from an alphabet with no look-alike characters and returns it in
EnrolPending. The device shows a code only after this arrives, so a code on screen is always one that works. While the device stays connected — and for ten minutes after it was last seen — it is handed the same code again, so a Wi-Fi blip does not invalidate the one somebody is carrying to the back office. - In the portal, under Add device, somebody types the code. The portal shows the device's model and the last characters of its hardware id and asks for confirmation — that step is what stops a mistyped code claiming somebody else's device.
- On confirm, the plane sends
EnrolGrantdown the waiting connection: the device id, a signed device certificate and the device password. The device stores them first and only then answersEnrolAck; a device that missed the grant is handed it again on its next request. - The plane closes the session, the device reconnects as itself, and its code function switches off for good. Only a wipe turns it back on.
At five characters the code is not the security. What stands behind it is a claim rate limit, per account and overall, and the confirmation step. The same code can also reach the portal without being typed — the Adopt tab lists devices showing a code on the same network, and Web Bluetooth can read it off a device with no screen. An install code runs the other way round: minted in the portal and given to the device (on the USB installer, or -claim CODE), sent in Enrol, and optionally holding the device locked until somebody presses Approve. See adopting a device.
The check-in
The device sends a Heartbeat every 30 seconds by default; the plane can change the interval in Welcome or in any reply, without a player update. Each check-in carries real state rather than nothing, and each reply carries anything the device should act on — so most changes reach a device without a single extra frame.
| Direction | Carries |
|---|---|
| Device → plane | Records waiting in its outbox · its position in each dataset · the revision of the file list it holds · the revision of the settings it holds · errors since the last check-in · round-trip time · uptime |
| Plane → device | The authoritative time · how many instructions are waiting · a new interval, if any · which datasets moved past its position · whether the file list moved on · whether the settings moved on · the log level to ship at |
- The log level is state, not a command. It is said again on every reply, and an absent or unknown value means off. A "stop" message can be lost; a level re-stated every 30 seconds cannot get stuck on, so a closed browser tab never leaves a device shipping debug lines for a week. Capture has an end time set in the portal.
- Nudge. When something changes in the portal, the plane sends a
Nudgewith the same flags straight away, so a saved setting reaches the screen in seconds rather than at the next check-in. Only a hint: a lost nudge costs one interval, never correctness. Sent only to devices that declared they understand it. - App updates are checked when a device connects and as it checks in; a push to a connected repository also tells every device on that branch at once.
- Gone. A device that misses two check-ins is treated as gone. Independently, the plane pings a connection that has gone silent and marks the device offline within 14 to 34 seconds of it vanishing, depending on the device's detection setting.
Leaving and coming back
Bye. A device that is stopping, restarting, rebooting or shutting down on purpose sends Bye with a reason before it closes, and the plane ends the session on it. It exists because the socket cannot be trusted to say so: through Cloudflare, a cleanly stopped player took forty seconds to surface as a closed connection, where Bye arrives in milliseconds. It is not sent on a reconnect, and it is only a hint — a device switched off at the wall sends nothing, so the check-in deadline stays the real backstop.
Reconnecting. The device retries for ever, with a random delay that doubles from half a second to a ceiling of five minutes. It may reset that delay only after a session has been healthy for a full minute, which is what stops a fleet hammering the plane in lock-step after an outage. The close code tells it what to do:
| Close | Device does |
|---|---|
Normal, AuthFailed, NotEnrolled, timeouts | Retries with the delay above. NotEnrolled is never treated as an instruction to wipe. |
ServerShutdown, Overloaded | Waits the time the plane gave, so a restart sheds a fleet with a spread instead of a stampede. |
Superseded | Holds off — another connection for this device is newer. |
ProtocolError, UnsupportedRevision | Starts again from the handshake. |
RefusedBuild | Stops: this player build is refused. |
Relocate | Dials the address in the close reason from now on. In the contract for a second server; no control plane sends it yet. Planned |
Signed instructions
Anything that must happen even if the device is switched off today is an instruction: a row in a queue, signed for one device, delivered as IntentDeliver whenever that device is connected, and cleared only by the device's IntentAck.
- Signed over exact bytes. The Ed25519 signature covers a length-prefixed encoding of the device id, the instruction id, its type, its payload exactly as sent, and its issue and expiry times. The payload travels as a string so it never has to be re-serialised before it is checked. The device verifies before it parses.
- Bound to one device. The device id is inside the signed bytes, so an instruction captured from one device is worthless at another.
- Expiry judged by server time from the last
Welcomeor check-in, never the device's own clock. - Idempotent. Redelivery is normal and applying twice equals applying once; the same id carrying different content is refused.
- Only the acknowledgement clears it — never a closed socket.
AppliedandStagedclear it;Rejected(bad signature, expired, unknown type) clears it for good;Failedkeeps it, and it is offered again on the next connection. - An instruction is queued for seven days by default; a wipe is owed for a year.
- Delivery is marked must understand: a device that does not know the message must answer with an error, so an ignored wipe can never look like a completed one.
| Instruction | What it does | Status |
|---|---|---|
Update | Names an app container by SHA-256 and carries its content key and the branch's apply policy. The device fetches it (below), verifies it and stages it. | Working |
PlayerUpdate | Names a player build by version, OS, architecture and SHA-256 — all inside the signature. The device refuses a build for the wrong machine, checks the new binary starts, and swaps it in for the next restart. Offered only forwards. | Working |
Wipe | Erases the device and its identity. See below. | Working |
Lock / Unlock | Stops the app behind a lock screen that survives a restart, and clears it. Used today for devices an install code holds for approval. | Working |
Password | Sets the device password. The device keeps only a salted slow hash, so a person at the machine can be checked with no network. | Working |
RotateKey, Repoint, Config | Reserved in the contract; nothing issues them. | Planned |
Wipe and removal
Remove in the portal queues a signed Wipe and keeps the device's record; it does not delete the row, because a deleted row cannot send anything. On receipt the device works in a fixed order:
1 acknowledge, so the plane does not redeliver to a device that complied
2 flush the outbox — records taken and not yet delivered — bounded in time
3 erase identity, app, data and settings
4 exit; the service restarts it as a stranger showing a new codeThe flush is the one step that protects anything: the outbox is the only thing on a device that is not a copy of something ArdaForm already holds. It is bounded, so a large backlog on a poor connection cannot stall the wipe. The plane marks the device decommissioned when the acknowledgement arrives; from then on any account can claim the hardware again.
- A device whose record was deleted — Forget completely, a deleted project or account — is still wiped. Before the record goes, the plane signs the wipe that device is owed and keeps it apart from the record. When a device presenting that identity next dials, it is handed the wipe and nothing else; with no record left to file them under, its undelivered records are not accepted.
- A device can ask to leave.
ardaplayer -unbindsendsDeregisterwith the device password, checked on the device and again on the plane. The plane forgets the device first and answersDeregistered; only then does the device erase itself, so nothing is ever left holding a key the plane no longer knows.
Files and updates
The control channel says what a device should hold; the bytes come over HTTPS. The device is never given a storage provider's address — every download goes through the control plane.
device ── FileManifest ──────────────▶ which files should I hold?
◀─ FileManifestResult ───────── rev, [{path, name, sha256, bytes}], hasMore
compares by hash: an unchanged file is never fetched again
── FileURL {sha256} ──────────▶ only for what it is missing
◀─ FileURLResult ────────────── an opaque URL, minutes long
── HTTPS GET /f/<token> ──────▶ checks the SHA-256 as it streams- The list is scoped by the device's own record, including its data partition. A request for a hash outside that scope is refused, so knowing a hash does not get you a file.
- The hash is the trust anchor. It arrives over the authenticated control channel and the device verifies the bytes against it, so nothing on the download path has to be trusted for integrity.
- The token is sealed. A download URL carries an AES-256-GCM-encrypted token that holds nothing readable — no hash, no project name, no storage key, no server path. File tokens last 15 minutes and app tokens 2 minutes; a 403 means "ask for a fresh one", never failure.
- A wipe revokes at once. The device's record is checked again at download time, so a wiped device stops downloading immediately rather than when its tokens lapse.
- After a sync the device sends
FileReport: counts, bytes moved, and the failures only.
An app update uses the same pattern. The signed Update names the container's SHA-256; the device asks for a URL with AppFetch at the moment it fetches — never earlier, since an instruction can sit queued for days — downloads from /a/<token>, checks the hash, then opens the container with the key the instruction carried. Player builds arrive the same way. The verification order and the container format are on the package format page.
Datasets and the outbox
- Down:
Fetchasks for a dataset from a position;FetchResultreturns one window of changes and deletions, the next position, and whether there is more. The device never chooses its own partition — the plane applies the one on the device's record. The check-in reply says which datasets have moved. - Up: records from
Arda.db.push()wait in the device's outbox and go up inRecordBatchwith one increasing sequence number per device.RecordAckanswers with a high-water mark and a list of refused records; a refused record is final, and the high-water moves past it so one bad record cannot block the queue. - At least once. A record whose acknowledgement was lost is sent again, so a record must carry a key; one without is refused.
Hellocarries the device's positions and outbox high-water, so a reconnect resumes with no extra round trips.
How an app uses this is in the manual's datasets and sync page.
Settings
- One document per device.
ConfigDocumentasks for it;ConfigResultreturns the project's values with this device's own overrides on top, and a revision number. The device applies it whole, never merged, so a setting deleted in the portal really leaves the device. - Unset is absent, never
null. A setting nobody filled in is left out of the document, so the app's own default applies. "Nobody set it" and "somebody set it to nothing" stay different answers. - Change detection is the revision: the device reports what it holds on each check-in and is told when the project has moved on, or nudged at once.
- The app may write its own value with
ConfigSet— only for a setting the manifest marks writable by the device, never a secret one, and only this device's override, never the project's. It is a compare-and-swap on the value the app last saw: if the effective value has changed since, the write is refused and the current value comes back inConfigSetResult. A device sends it only to a plane that declared it can answer; until then the write waits on the device.
See settings in the manual.
Operator actions
Actions somebody takes from a device's screen in the portal. The ones that act on the machine now are online only, never queued: the plane refuses when the device is not connected, because a reboot asked for on Tuesday that lands on Friday restarts a till in front of a customer.
| Message | What happens | Status |
|---|---|---|
Screenshot | A PNG of the screen, inline. A device with no screen answers with an error, never a blank picture. | Working |
ConsoleEval | Runs one expression in the running page and returns the result, serialised on the device. | Working |
Reboot, Shutdown | Answered before they happen — afterwards there is nothing to answer from. Nothing can turn a machine back on after a shutdown. | Working |
ApplyUpdate | Swaps in a staged app update now, over the branch's policy and the app's say. Fetches nothing. | Working |
LogBatch | Log lines, tagged player, app or sync, shipped only while the check-in reply asks for them. Best effort by design: logs are diagnostics, and the outbox is where things that must arrive live. | Working |
Alert | The moment a condition is raised or cleared — under-voltage, throttling, low disk, a missing screen or printer, a hardware binding that no longer matches. Current state stays in the device's system report; this carries only changes, which is what makes a history. | Working |
SysInfo | The device's hardware and OS report, on connect and on change. Free-form, so a new board's new field needs no protocol change. It also lists the features the player supports, which decides which portal buttons light up. | Working |
TunnelOpen | Asks the device to open a second, fully authenticated connection for an SSH session, the portal's shell or its device view. The plane names one of those three, never a host and port, and the device decides whether to honour it. The token is single-use and short-lived. | Working |
Webhooks
When an app calls Arda.webhook.call(slug, data), the device sends WebhookCall and the control plane makes the HTTP request, then answers WebhookResult with the delivery id, whether the server answered 2xx, the status, the time taken and the start of the body.
- The webhook's key never reaches a device. A delivery is signed —
X-Arda-Signature, HMAC-SHA256 over the timestamp and the body — and every call comes from ArdaForm's address, which is the one your server allow-lists. - The request goes only to a public address: loopback, private and link-local addresses are refused after the name is resolved, and the connection is pinned to the address that was checked. No redirects; one 10-second budget.
datais at most 64 KiB. Calls are limited per account per minute by the plan. A webhook with no URL is recorded instead of called, and answered with status 202.- A device sends it only to a plane that declared it can answer, so an app on an older plane is told "not offered" rather than waiting out a timeout.
See webhooks in the manual and checking a signature.
Licences
A device that is never reached cannot be told anything — so right after each check-in the plane hands it a signed Licence: how long it may keep running without hearing from ArdaForm again, and, for an app bought on the marketplace, whether this device holds one of the licences the account paid for.
- The platform licence runs to the end of the account's current billing period — for an account with no paid plan, the current month — plus 14 days. A connected device is renewed long before it runs out; a device that has not connected by then stops its app, keeps everything it holds, and starts again the moment it connects. The rule is the same for every account: connect at least once in each period, and within 14 days of each renewal.
- An app licence names the install and runs to the end of the period its buyer last paid for, plus the same 14 days; a one-off purchase never expires. Licences are bought up front, and the devices that enrolled earliest hold them. A device beyond that number is sent a small "this device needs a licence" app in place of the seller's — its data is kept — until a licence is bought or another device is removed.
- The document is signed with the same key the device already trusts for instructions, under a different label, and bound to the device's identity: one device's licence is no use to another. The device keeps only the newest, judges expiry against a clock that never goes backwards, and answers
LicenceResult— a refusal is logged on the plane, because it means a key the device does not trust. - Sent only to a device that declared the feature. An older player sees nothing and is never refused for it.
Versions and older devices
Players update themselves, but never all at once, so every change has to land on a fleet running several builds.
- Revisions are for breaking changes only. The plane speaks the current revision and the one before it; a device on neither is closed with
UnsupportedRevision. Revision 1 is the only one so far. - Features are how anything optional is added. Each side declares a bitmap — compression, nudges, webhooks, app-written settings, licences so far — and each sends a feature's messages only to a peer that declared it.
- An unknown message marked must understand is answered with a
NotUnderstooderror; any other unknown message is ignored. Enrolment, instructions, settings, downloads, webhooks and every operator action are marked. - An unknown instruction type is acknowledged
Rejected— "not implemented" — and cleared, never retried. - Portal actions follow the features a device lists in its system report: an action an older player does not support is shown dimmed, rather than sent and refused.
Status of each part
| Part | Status |
|---|---|
| Transport, handshake, encrypted session, reconnect rules | Working |
Joining by code, Adopt tab, install codes, -unbind | Working |
Check-in, nudges, Bye, fast offline detection | Working |
| Signed instructions: update, player update, wipe, lock, password | Working |
| File and app downloads with sealed tokens | Working |
| Datasets, outbox, settings, app-written settings | Working |
| Screenshot, console, reboot, shutdown, apply now, logs, alerts, tunnel | Working |
| Webhooks | Working |
| Device licences | Working — the plane signs and sends them; the player keeps and enforces them from ArdaPlayer v1.2.4 |
| Instruction key held by the portal instead of the control plane | Planned |
| Device certificate checked by other devices with no server in reach | Planned — issued at enrolment today, read by nothing |
Relocate to a second server; RotateKey, Repoint and Config instructions | Planned — in the contract, not sent |