Skip to content

KM43 v1.0

KM43 / normative specification

The wire contract between a controller and every client that talks to it: bounded for fixed memory, authenticated across an untrusted relay, and unchanged by the transport underneath.

Status
Normative draft
Message model
Requests, responses, and an event stream
Trust boundary
Messages are proven at the controller
Transports
UART, WebSocket, USB, BLE, and MQTT

This document says what an implementation must do. Every mandatory rule has a stable P-nnn identifier; the design rationale explains why the rule exists. Link-local controller↔comms messages have their own specification, and unresolved body schemas are named in deferred decisions instead of being guessed here.


MUST, MUST NOT, SHOULD and MAY carry their RFC 2119 meanings. Every mandatory requirement is numbered P-nnn so that a test can cite the line it proves.

Two independent implementations — one no_std Rust on the controller, one TypeScript in a browser — must interoperate having read only this document and the files it links. Anywhere you have to guess, this document has a bug; say so.

P-001 — An implementation MUST reproduce every vector in protocol/vectors/v1.json exactly before it is connected to another implementation. A key-derivation disagreement is invisible on the wire and costs a bench day to find by inspection.


Every one of these is a fixed array with a named capacity. Nothing on the controller allocates after init, so a limit is refused rather than grown.

They are not all the same kind of thing, and one table was hiding that.

A decoder needs these before it decodes. MAX_PAYLOAD decides whether there is a buffer to read the frame into at all; MAX_FRAME is what a receiver sized that buffer at before a byte arrived; MAX_STRING and MAX_DEPTH are what stop a hostile body from walking off the stack while it is being parsed. Not one of them can be learned from inside the message it bounds, so not one of them can be negotiated. They are the same on every device, forever.

NameValueBehaviour when reached
MAX_PAYLOAD1024 bytesError 5, frame dropped
MAX_FRAME1032 bytesFrame dropped, resynchronise
MAX_OPERATION960 bytesError 5
MAX_LOG_PAGE64 entries or 896 bytesPage truncated, complete = false
MAX_STRING64 bytes, UTF-8Error 1
MAX_DEPTH8Error 1

The rest are device capability, not wire format. A controller with more RAM holds more sessions; a smaller one holds fewer. A client carrying these compiled in is a client that has to be reissued to meet a controller it was not built against, and the failure is quiet rather than loud: it keeps four requests in flight at a controller that allows two, collects error 7 for the afternoon, and tells somebody the site is busy.

So the controller reports them in Hello 0x81, and the figures below are what this controller enforces rather than what the protocol permits.

NameThis controllerHello 0x81 keyBehaviour when reached
MAX_SESSIONS812Error 8, oldest is not evicted
MAX_CHANNELS3213Config write refused, SetConfigAck outcome 5 exceeds_cap
MAX_CLIENTS814Pair outcome 4 table_full
MAX_EVENT_QUEUE16 events per session15Class B dropped, oldest first, and counted; a class A event that cannot be queued closes the connection (P-098)
MAX_INFLIGHT4 requests per session16Error 7 busy
MAX_CMD_DEDUP32 entries, 10 minutes, no client above 1617Error 7 busy

Key 17 reports the entry count. The 10 minutes is not reported and is not negotiable: it is how long a retry stays safe, which is a property of a client on a bad radio rather than of the controller’s memory. Neither is the per-client half (P-122): a client that never exceeds 16 live entries never observes it, and one that does is being told to slow down rather than being told the site is full.

P-005 — The controller MUST report keys 12 to 17 with the values it actually enforces, and a client MUST use the reported value in place of any it was built with. A client MUST NOT exceed one it has been told.

Reporting a number the controller does not enforce is worse than not reporting it. A client that is told it may keep eight requests in flight, and is then refused the third with error 7, has been handed a field that made it behave worse than the compiled-in guess it replaced.

P-006max_channels (key 13) MUST only ever be reported downward from 32. A controller MUST NOT report more, and a client MUST reject a Hello 0x81 that does, with error 1.

Thirty-two is a cap with margin, not the ceiling — the ceiling is 35, and the gap between them is the point. 966 bytes of headroom divided by a 27-byte Value at its widest is 35.7, so 35 fit and 36 do not; the sentence here used to claim 32 was the largest that fits, which was an assertion dressed as the derivation two blocks above it. Three spare entries is what absorbs a Value gaining an optional key in a later minor version without the cap becoming a frame that gets built and refused. P-089 bounds Value key 3 to i32 precisely so that 32 Values at their widest fit inside MAX_PAYLOAD, and P-090 says there is no paging — so a device reporting 64 does not get a bigger snapshot. It gets a frame it builds and then refuses with error 5, at a fully configured site with every probe reading, with no smaller answer to fall back on and nothing a client can do about it. Downward costs nothing: a device that holds twelve channels reports twelve and every frame it builds fits. Upward is a limit that cannot fit inside the thing that carries it, which the arithmetic below already says is not a limit at all.

NameValueBehaviour when reached
MAX_CHALLENGES8Error 7 busy; nothing is evicted
MAX_AUTH_FAILURES8 per connection in 60 secondsConnection closed, CloseConnection reason 3 authentication_failures; a fresh Hello does not clear the count

Both bound what the controller will spend on a peer that has proved nothing, and there is nothing a client does differently for knowing either. A limit is reported so a client can stay inside it; these two are reached only by a client doing something it was not supposed to be doing — holding challenges it never answers, or presenting proofs it cannot pass.

MAX_SESSIONS is the cap on bindings, not on transports. Every client transport terminates on the comms processor today, and it refuses a ninth connection at ClientConnected before any Hello can be sent (LINK.md L-061); error 8 is what refuses a session on a transport the comms processor does not own.

MAX_PAYLOAD bounds the encoded envelope — the whole message as it is framed. The word payload on its own means key 1 of the wrapper in P-050, which is bounded by what fits inside the envelope that carries it.

MAX_FRAME is derived, not asserted:

envelope 1024 + crc16 2 = 1026 bytes into COBS
COBS worst case 1026 + ceil(1026 / 254) = 1026 + 5 = 1031
delimiter + 1 = 1032

So is MAX_LOG_PAGE, and the arithmetic is why it is not 1024. A page is not a frame — it travels inside one, under an envelope, a wrapper and a MAC:

MAX_PAYLOAD 1024
less envelope [type, session_id, req_id, body] -11
less wrapper map + bstr header + mac(16) -23
less LogPage keys 2, 3 and 4 -26
────
headroom for entries 964 -> 896 with margin

The envelope is 11 rather than 9 because req_id is a u32 (P-022): a CBOR integer at the top of the u16 range is three bytes and at the top of the u32 range it is five. Two bytes on every frame, and every derivation on this page carries them.

MAX_CHANNELS has to survive the same arithmetic, and it did not. A Snapshot carrying 32 Values at their widest, with an unbounded integer in key 3:

Value map hdr 1 + channel 4 + kind 4 + value 10 + quality 2 + at 10 = 31
× MAX_CHANNELS 32 992
+ the values array's own key and header 3
+ Snapshot map header 1, seq 10, at 10 21
──────
inner Snapshot body 1016
+ wrapper map + bstr header + mac(16) +23
+ envelope [type, session_id, req_id, body] +11
──────
1050 > MAX_PAYLOAD

That is a fully configured site — 32 channels, all readable, clock set — refused with error 5 by the controller that built it. P-090 says there is no paging, so there is no smaller answer to fall back to and no client action that fixes it.

P-089 bounds key 3 to a signed integer no wider than i32, which costs nothing real because P-018 already makes every value a scaled integer: at scale −3 an i32 still spans millions of volts. Key 3 goes from 10 bytes to 6:

Value map hdr 1 + channel 4 + kind 4 + value 6 + quality 2 + at 10 = 27
× MAX_CHANNELS 32 864
+ the values array's own key and header 3
+ Snapshot map header 1, seq 10, at 10 21
──────
inner Snapshot body 888
+ wrapper 23 + envelope 11 +34
──────
922 fits, with 102 to spare

A limit that cannot fit inside the thing that carries it is not a limit, it is a frame that gets built and then refused. MAX_LOG_PAGE was derived that way from the start; MAX_CHANNELS was picked and not checked, and the check is what turned a round number into a wire rule.

P-002 — A receiver MUST size its frame buffer at MAX_FRAME and MUST discard, without allocating, any frame that would exceed it.

P-003 — Refusing MUST be preferred to evicting. Silently dropping a client’s enrolment is how a cabin loses its ability to be told to stop.

MAX_EVENT_QUEUE is the one deliberate exception, and it is an exception because refusing there means one slow client on a bad radio stalls the other seven. Telemetry drops and is counted; a safety event does not drop, and a session that cannot take one is closed rather than lied to. What that costs and how a client sees it are in P-096 to P-098.

P-004 — Every duration in this document MUST be measured on a monotonic tick since boot, never on the wall clock: the 15-minute session expiry, the 120-second challenge and pairing windows, MAX_CMD_DEDUP’s 10 minutes, the 60-second MAX_AUTH_FAILURES window, the 5-second BLE reassembly timeout, the 50 ms incomplete-frame timeout, and every other one below. The tick is milliseconds in a u64, it never runs backwards, and a clock write — a client Time 0x0A, or a TimeOffer the controller accepts — MUST NOT move it. The wall clock timestamps records and decides nothing else.

The clock is settable by any enrolled client and movable by the comms processor, so a duration measured on it is a duration somebody else sets. Deduplication is where that ends up on a maintained contact: client A sends a command and the ack is lost; a compromised client advances the clock eleven minutes; the dedup entry ages out; A retries with the same cmd_id and — correctly, under P-082 — a new counter. The counter check passes because the counter really is new, the dedup table has nothing left to match, and the generator starts a second time. That is exactly what P-121 puts the table in FRAM for and what P-122 refuses to evict for, defeated without forging anything.

The width is stated because the tempting one is wrong. A u32 of milliseconds wraps at 49.7 days, and a controller seven weeks into an uninterrupted run is the normal state at this site rather than the exception; after the wrap every duration above measures backwards — a session that never expires next to a dedup window that expired the moment it was written. A u64 does not wrap inside any service life, and an implementation whose hardware counter is narrower MUST widen it in software before anything here reads it.


CBOR (RFC 8949), definite length everywhere.

P-010 — Envelopes MUST be CBOR arrays. The envelope is fixed forever; anything that needs to change belongs in a body. What “fixed” means element by element, and what a decoder does with an envelope longer than the one it knows, is P-028.

P-011 — Bodies MUST be CBOR maps with integer keys only. Never strings.

P-012 — A retired field number MUST NOT be reused. Retired numbers stay recorded in REGISTRY.md.

P-013 — A decoder MUST skip map keys it does not recognise, and MUST NOT reject a message for containing them. A v1 controller has to survive a v2 client’s extra fields and the reverse.

A later field added to Pair 0x0B, Pair 0x8B or the signed body MUST enter that message’s preimage. Those three are the only bodies whose MAC covers fields rather than an encoding, so a key added outside the preimage is one the comms processor can write, alter or remove with every tag still verifying. P-013 still applies to them — a v1 controller skips a key it does not know, because refusing means a v2 client cannot enrol at, or write to, a v1 controller, and enrolment is the one exchange that needs somebody at the panel. What makes skipping safe is the rule above and the corollary in PROTOCOL-RATIONALE.md: a field whose absence has no sane default is a new message type rather than a new key.

The signed body was missing from that list for as long as the list existed. Its km43/v1/req preimage covers client_id and counter as fields and the operation as a blob — never an encoding of the map around them — so a key 5 added in a later version lands outside the MAC in exactly the way this paragraph was written to forbid, on the four messages that carry every write.

P-014 — A decoder MUST reject an enum discriminant it does not recognise, with error 1. This is the opposite of P-013 and deliberately so: an unknown extra field is a newer peer being chatty, but an unknown value in a field that decides behaviour is a message whose meaning is not knowable. There is no 0 = unknown fallback anywhere in this protocol.

P-019 — P-014 has exactly one exception: the vendor and experimental range 0xF0000xFFFF, reserved by REGISTRY.md in the metric-kind and event-kind spaces only and never allocated by this document. A decoder MUST NOT reject a message for carrying one. It MUST surface that one carrier as unrecognised and keep the rest of the message: a Value whose kind is in the range is shown as a configured channel with no reading rendered, and an Event or LogEntry whose kind is in the range is surfaced with its body unread, its seq still advancing the highest the client has accepted (P-056). Blanking a snapshot because somebody hung a vendor meter next to the frost probe is the worse failure by a distance. Every other discriminant space — quality, section, source, client_kind, every outcome, every error code — has no vendor range and no exception.

P-015 — Every key listed in a body definition below is REQUIRED unless marked optional. A missing required key is error 1.

A map carrying the same integer key twice is error 1 as well, refused before anything in that map is interpreted. P-013 is about a key a decoder does not recognise, not one it recognises twice: RFC 8949 §5.6 leaves the resolution to the decoder, and two libraries that pick differently read different bytes out of one authenticated message. This is a check on the frame as received rather than a re-encoding, so it does not lean on P-016 and does not disturb P-017.

P-016 — Encoders MUST emit RFC 8949 §4.2 deterministic encoding: shortest form integers, sorted map keys, definite lengths, no indefinite strings.

P-017 — Authentication MUST NOT depend on P-016. Every MAC in this document is computed over a byte string that is carried on the wire and verified exactly as received, never over a re-encoding. P-016 exists for debuggability and byte-stability; if it and a MAC ever disagree, the MAC wins.

P-018 — Floating point MUST NOT appear on the wire. Every measurement is an integer with a unit and a scale from the metric registry. There is no FPU on the target, and two languages rounding the same float differently is a defect nobody can see in a hex dump.


[ type: u8, session_id: u16, req_id: u32, body: map ]

P-028 — The array length is the extension point. A v1 decoder MUST reject an envelope whose array length is not exactly 4, with error 1, before it reads any element. Anything a later version adds is a fifth element appended at the end; no existing element ever changes meaning, order or width, which is what P-010 means by fixed forever.

P-010 said the envelope is fixed and left the interesting half unwritten: what a decoder does when it meets a five-element one. Undefined meant one implementation reads the first four and carries on — acting on a message whose fifth element it cannot see and which may be the one that changes what the other four mean — and another rejects it, and both were obeying every rule on the page. Refusing is the answer for the same reason P-014 refuses an unknown discriminant: an unknown extra field is a newer peer being chatty, but an envelope of a shape this version has never been told about is a message whose meaning is not knowable.

There is no reserved flags byte, and that is a decision rather than an oversight. A byte that is always zero and that nothing reads is dead weight on every frame for the life of the product, and it does not stay unread: the first person who needs a bit puts one in it, in a field no decoder was ever taught to check, so nothing rejects the frame and the two sides disagree in silence. The array header already carries the same information and carries it self-describingly — a four-element envelope and a five-element envelope are different bytes, a generic CBOR dump shows the difference to somebody at 2 a.m. without being told what version it is looking at, and the check costs one comparison a decoder is already making to read the array at all.

P-029 — Every seq bound in this document names the first position included. from_seq, accepted_from_seq, oldest_seq and the from_seq of a ReadLog all mean start here, and the record at that position is delivered.

Four requirements were each correct under the opposite reading and no two of them had to agree. P-099 clamps a ReadLog to oldest_seq so the client “can then see it lost data”, which only works if the record at oldest_seq arrives. P-104 sets accepted_from_seq and P-056 makes a client reject anything not greater than that mark, which under an inclusive reading drops the first replayed event on the floor. Getting it wrong loses exactly one record per subscription, which is invisible on a busy stream and is a class A alarm on a quiet one — and a quiet stream is what this site has for eleven months of the year.

P-020type with the high bit set means response to a request. 0x04 is the only unsolicited message. 0x600x7E and their 0xE00xFE responses are link-local (LINK.md L-002) and MUST NOT appear on a client-facing transport; one that does is error 257. The range stops at 0x7E because 0x7F with the high bit set is 0xFF, which is Error — a request whose response opcode is already spoken for is a trap for whoever allocates last.

P-021 — A client that has no session yet MUST send session_id = 0, and MUST NOT rely on that value surviving. The comms processor overwrites session_id with the connection handle on every inbound client frame, and the handle is what the controller sees. Handles are never 0, so a client frame reaching the controller still carrying 0 is a comms-processor bug.

P-026 — The controller MUST answer a pre-session request — Discover 0x80, Pair 0x8B, and any bare Error answering one — with the connection handle in session_id, so the response routes back to the connection that asked. That is not a session; a session exists only after Hello.

Without this stamping rule the only demux field on the wire is one the client is told to zero, so the controller cannot tell which of eight connections a Discover arrived on — and P-060’s fresh-challenge-per-connection is unimplementable, falling back to a single device-wide challenge and the two-client livelock it exists to prevent.

P-022req_id is a u32, MUST be strictly increasing within a session, and MUST NOT be reused or restarted. A client MUST NOT have more than MAX_INFLIGHT outstanding.

The controller MUST enforce this rather than trust it. It MUST hold, per session, the highest req_id it has accepted and a record of which of the last MAX_INFLIGHT it has already accepted, and MUST refuse — without acting, and before the counter is read — any request whose req_id it has already accepted, or whose req_id is below highest_accepted − MAX_INFLIGHT.

The tolerance is not slack, it is the reorder window this protocol already permits: a client may hold four requests in flight and they may arrive in any order, so a strict must exceed rule would refuse honest traffic on a bad radio.

This is what bounds how old a signed write may be when it lands, which nothing else does. counter orders a client’s writes and cmd_id suppresses duplicates — P-082 says exactly that — and neither is a clock. A frame captured and withheld replays whenever the holder chooses, and every rule it meets is satisfied: the MAC is valid, the counter is above the stored one because it was above it when the frame was made, and the dedup entry aged out ten minutes later. Start the generator, sent at nine in the morning and delivered at midnight.

With the receiver rule, the window closes at whichever comes first: the client’s next accepted request, which moves highest_accepted past the banked frame, or P-077’s fifteen-minute session expiry, which destroys the session_key the frame was MAC’d under. Both are already in this document; what was missing was the requirement that makes the controller notice.

This is what gives a response its freshness. The response MAC covers (type, session_id, req_id) and nothing that changes over time, so if a req_id could recur, a response recorded earlier in the same session would verify against a later request — the comms processor answering a “is the generator running” with a genuine, correctly-MAC’d “no” from an hour ago. Strictly increasing and never reused makes that impossible without a second counter on every message.

The width is u32 because a u16 ran out. It was two bytes, with a rule saying a session that would exhaust the space had to be ended and re-established. A browser polling Snapshot every two seconds issues 43,200 requests a day and exhausts 65,536 of them in a day and a half — so the rule came due on the client that polls hardest, on a session that was working perfectly, and what it demanded was a reconnect nothing was wrong with. Every client would have had to implement a wrap-out path that only ever fired in normal use, and the one that got it wrong would have wrapped instead, which is the failure the paragraph above describes.

Four bytes reach 4.29 billion, which at two seconds a request is more than two centuries: the counter outlasts the hardware, so there is nothing to write down about what happens when it runs out. Two bytes on every frame is the price, and the limits arithmetic above pays it with 102 to spare on the worst frame this protocol can build.

P-023 — For an unsolicited Event the sender MUST set req_id = 0, and it enters the MAC preimage as 0x00000000 — four zero bytes, the same width every other req_id occupies in a preimage, because a field that changes width between messages is a field two implementations pad differently.

A receiver MUST refuse an Event whose envelope carries a non-zero req_id, before verifying it. That half was missing, and it is the half that matters: because the preimage carries a literal zero rather than the field, req_id is the one envelope value on an event that the MAC does not cover. Left unchecked, the comms processor stamps any number it likes onto an event and the tag still verifies — the frame is authentic and one of its fields is not. That is P-084’s shape exactly, one message over: a preimage that covers a field without constraining it, which P-047 spends four bytes on every other message to prevent. It surfaced while the wrapper was being written, which is the cheapest place for it to surface.

P-024 — A client MUST drop a response whose (session_id, req_id) matches no outstanding request. It MUST NOT re-match a response by inspecting its body.

Before a session exists, a client matches on req_id alone, and MUST take the session_id the response carries as its connection handle — the value it puts on every frame from then until Hello gives it a session. This covers Discover 0x80, Pair 0x8B and Hello 0x81, and any bare Error answering one.

The pair rule cannot be satisfied on message one and never could. A client with no session sends session_id = 0 (P-021); the comms processor overwrites it with the connection handle on the way in (P-021 again); and the controller answers with that handle so the response routes back (P-026). So the response’s session_id is provably not the one the client sent, by three requirements working exactly as written — and a client obeying the pair rule literally drops the first message of every session it will ever open, including the Hello 0x81 that would have given it the session. Matching on req_id is sufficient there because req_id is already unique among that client’s outstanding requests (P-022) and there is only one connection to confuse it with.

It is also where the handle comes from. Nothing else told a client what to put in session_id between Discover and Hello; it sends 0, has it rewritten, and never learns the value — which is fine while the comms processor rewrites every frame, and stops being fine on any transport that does not.

An Error carrying session_id = 0, req_id = 0 is the carve-out, and it is carved out because it is not a response to anything: it answers no request by construction (P-027), so there is nothing for it to match and dropping it on that ground drops it always. A client MUST surface it as a link diagnosticthe link between here and the controller is mangling frames — and MUST NOT match it to any outstanding request, MUST NOT let it complete one, and MUST NOT read it as a statement about the site (P-055). It is unauthenticated, so what it licenses is retrying or reconnecting and nothing else.

P-025 — A frame too malformed to parse its envelope MUST be answered with session_id = 0, req_id = 0, and the comms processor MUST route that answer back on the connection the bad frame arrived on. It cannot route by session_id here — there is no usable one, which is the entire reason the frame is being refused — so it routes by the connection it just read the bytes from, which it knows because it read them.

The earlier rule had the comms processor swallow this as a link-level diagnostic and route nothing, and that is the wrong end. A client whose frame was unparseable then hears nothing at all: it waits out its own timeout with an empty screen and the person holding it says it just stops working, which is the one bug report nobody can act on. The 0, 0 error is the only thing on the wire that says your frames are arriving corrupted — the comms processor keeping it to itself makes a link fault look like a dead controller.

P-027 — An Error answering a frame whose envelope parsed MUST echo that frame’s session_id and req_id, and those echoed values are what enter the rsp MAC preimage. An Error about a frame whose envelope did not parse (P-025), or about the link rather than about any request, carries session_id = 0, req_id = 0.

Echoing was only ever implied, and implied is what this document says it must never be. Two sides that disagree about which req_id went into the preimage compute different MACs for codes 6, 7 and 11 — every code the registry still marks MAC’d and live — so a refusal somebody needed to read arrives as a verification failure instead. P-024’s carve-out is the other half of it: an error about the link answers no request, and a rule that drops everything unmatched drops 259 after a controller reboot — which is the signal to reconnect, and a signal nobody receives is not one.


COBS( envelope | crc16 ) 0x00
Baud921600, 8N1, RTS/CTS hardware flow control
CRCCRC-16/CCITT-FALSE — poly 0x1021, init 0xFFFF, no reflection, xorout 0x0000, check 0x29B1
CRC coversthe CBOR-encoded envelope bytes exactly, first to last — computed before COBS, and covering neither the delimiter nor the CRC itself
CRC on the wirelittle-endian, immediately after the envelope, and the two are COBS-encoded together
Delimiter0x00
Incomplete frame timeout50 ms since the last byte → discard and resynchronise
RX buffer2 × MAX_FRAME, DMA circular

“Encoded” means CBOR here, not COBS, and the two orders are not interchangeable. The sender encodes the envelope as CBOR, computes the CRC over those bytes, appends it little-endian, and COBS-encodes the pair. A receiver undoes COBS first and checks the CRC second. Computing the CRC over the COBS-encoded bytes instead is a frame that verifies on both sides of a bench and fails against anybody else’s implementation, and the word “encoded” sitting in a table headed by COBS(...) is exactly how somebody arrives at it. This was raised while the framing layer was being written, which is the moment it was cheapest to answer.

There is no length field. COBS plus the delimiter already frames the message, and a second length source is a second thing to disagree with the first. The earlier draft carried one and never said what to do when they disagreed.

P-030 — A receiver MUST resynchronise by reading to the next 0x00.

P-031 — A frame failing its CRC MUST be dropped silently. There is no NAK at this layer: the request layer retries, and a link-level retransmit would duplicate a command.

P-032 — A COBS code byte of 0x00 inside a frame, or a block running past the end of a frame, MUST be treated as corruption: drop and resynchronise. A resynchronising receiver is handed arbitrary bytes by definition and MUST NOT panic, allocate, or loop unboundedly on any input.

P-033 — RTS/CTS is REQUIRED at this rate and the production connector MUST carry both signals. A board that omits them because bring-up worked at 115200 drops bytes in the field and it looks like a protocol bug.

P-034 — One protocol message per binary WebSocket frame. Already framed, already ordered: no COBS, no CRC. Text frames MUST be rejected.

P-035 — As UART, including COBS and the CRC, without the flow-control requirement.

Specified, unimplemented, unverified. Not conformance surface — see DEFERRED.md entry 7.

One service, two characteristics: rx (client → controller, Write Without Response) and tx (controller → client, Notify).

[ msg_id: u8 ][ flags: u8 ][ fragment data ]

P-036flags bit 7 (0x80) is last; bits 6–0 are the fragment index, 0127. Bit order is stated because it was previously drawn as frag: u7, last: u1 with no byte layout, which two implementers pack differently.

P-037 — Fragment data MUST be at most ATT_MTU − 5 bytes: three for the ATT notification header and two for the fragment header above. The earlier draft said MTU − 3, which does not leave room for its own header.

P-039msg_id identifies the protocol message a fragment belongs to. A sender MUST use one value for every fragment of a message and MUST increment it modulo 256 for the next. A receiver MUST discard the assembly in progress on any fragment whose msg_id differs from the one that opened it, and MUST begin a new assembly only at index 0. With one assembly per connection this byte is a mismatch detector rather than a demultiplexer: it is what makes the tail of a message whose last fragment was lost visible instead of silently concatenated onto the front of the next one. What wrap does behind a notify queue that has backed up is unverified — see DEFERRED.md entry 7.

Concurrent assembliesone per connection
Assembly timeout5 s since the last fragment → discard
Duplicate or out-of-order indexdiscard the assembly, wait for the next index 0
msg_id differs from the assembly in progressdiscard the assembly, wait for the next index 0
Max reassembledMAX_PAYLOAD; exceeding it discards

At the minimum 23-byte ATT MTU a full 1024-byte payload needs 57 fragments, inside the 128 the index can express.

Specified, unimplemented, unverified. Not conformance surface — see DEFERRED.md entry 7.

TopicDirection
km43/<device_id>/rxto the controller
km43/<device_id>/txfrom the controller

P-038<device_id> MUST be the 16 bytes rendered as 32 lowercase hexadecimal characters, no separators. A client learns it from the QR code it already scans to pair, because it needs the topic before it can send a Discover.

QoS 1. The counter rule below is what makes at-least-once delivery safe.


Everything in this section is fixed by protocol/vectors/v1.json. If your implementation disagrees with a vector, your implementation is wrong.

P-040 — Every integer entering a MAC or a KDF MUST be encoded big-endian, fixed width, with no padding and no length prefix. The | operator below joins fixed-width fields with no separator.

P-041HMAC means HMAC-SHA256. Where 16 bytes are specified it is the leftmost 16 bytes of the 32-byte output.

Sixteen bytes is a 128-bit authentication tag, and 128 bits is the security target. It is not a number arrived at by trimming until things fit, and the truncation is not a weakening anybody should have to infer from the word truncated: what it buys is frame size on a link where every byte is budgeted — MAX_PAYLOAD is 1024, a Snapshot at the channel cap clears it with 102 to spare, and the full 32-byte tag rides on every response, every signed request and every one of eight separately-MAC’d copies of each event. Truncating an HMAC to its leftmost bytes is the standard construction (RFC 2104 §5, NIST SP 800-107), and 128 bits is where this document stops. Anybody tempted to trim further to win a few more bytes in a frame is trading the property the whole protocol rests on for something the limits arithmetic can be made to give up instead.

P-042HKDF means HKDF-SHA256 (RFC 5869) with salt, IKM and info as named arguments. It MUST NOT be implemented as a hash over a concatenation; the earlier draft wrote HKDF(a | b | c), which assigns none of the three.

P-043 — Every domain input MUST begin with its label, so a value computed for one purpose can never verify for another. There are two kinds of domain input and the distinction is load-bearing: a MAC preimage prefix, and an HKDF info argument. An implementer who reads the table below as nine MAC preimages derives keys that are wrong on both sides and identical to nobody — the defect P-001 exists to catch, because it is invisible on the wire.

LabelKindUsed for
km43/v1/pair-keyHKDF infoderiving the pairing key from the printed secret
km43/v1/client-keyHKDF infoderiving a client’s long-term key
km43/v1/session-keyHKDF infoderiving a session key
km43/v1/pair-proofMAC preimagethe client’s proof during pairing
km43/v1/pair-ackMAC preimagethe controller’s pairing response
km43/v1/hello-proofMAC preimagethe client’s proof during Hello
km43/v1/reqMAC preimagea signed request — one that carries a counter
km43/v1/wrqMAC preimagea wrapper-authenticated request — read-only, no counter
km43/v1/rspMAC preimagea response
km43/v1/evtMAC preimagean event

Labels are ASCII, no trailing NUL.

pair_key = HKDF(salt = device_id, 16 bytes
ikm = printed_secret, 32 bytes
info = "km43/v1/pair-key",
L = 32)
client_key = HKDF(salt = device_id, 16 bytes
ikm = printed_secret, 32 bytes
info = "km43/v1/client-key" | epoch:u32be | client_id:u32be,
L = 32)
session_key = HKDF(salt = challenge | client_nonce, 16 + 16 bytes
ikm = client_key, 32 bytes
info = "km43/v1/session-key" | session_id:u16be,
L = 32)

P-085epoch is a u32 monotonic counter in FRAM. It starts at 1, MUST be incremented on every factory reset, and MUST never be decremented. Every client_key derived under epoch n is unusable at epoch n+1.

A factory reset MUST increment epoch, persist it, read it back and verify it before clearing anything else — the client table, the counters and the dedup table in that order, and only after the read-back agrees. On a failed write or a failed read-back the reset MUST NOT proceed, the controller MUST raise a class A alarm raised (0x0501) naming a failed epoch write, and it MUST refuse every Pair until the write succeeds.

The order is the load-bearing half. A power cut between the two steps must leave an epoch that is too high, which only over-invalidates — the worst case is a phone that has to be paired again. The reverse order leaves a cleared table at a stale epoch, and then the next enrolment mints client_id 1 under the same epoch the stolen phone holds a key for. That is the exact failure P-085 exists to prevent, arriving through the write that implements it.

Refusing Pair until it succeeds is the same argument as P-079’s, one level up: a controller that cannot invalidate key material must not mint new material into the slots it could not invalidate. This requirement got the treatment P-079 gave the counter write because the epoch write is the one FRAM write the entire revocation story rests on, and it was the one with nothing said about it failing.

This is the only thing on this design that can invalidate key material, and without it a factory reset invalidates nothing. device_id is etched and printed_secret is on a label that cannot be reprinted into a unit already on a wall — so before this counter existed, all three KDF inputs were immutable for the life of the product. Press the button, reset, re-pair a new phone, it is issued client_id 1, and the phone that was stolen last week already holds client_id 1’s key. The documented remedy for a compromised client removed no access at all, and a resold unit left its previous owner with a working key for somebody else’s site. LINK.md’s L-135 already clears the Wi-Fi passphrase on a factory reset for exactly that scenario; the reasoning simply had not been applied to key material.

P-086client_id MUST be allocated as the lowest free slot index in the client table, counting from 1. This is a KDF input, so it is frozen as hard as the formula around it, and leaving it to the implementer means two firmwares mint different keys from the same label. Slot reuse within an epoch is what P-085 exists to make safe.

P-087Discover 0x80 MUST carry the current epoch (key 8), so a client whose key no longer derives is told why. Without it the symptom is an unexplainable bad_proof on a phone that worked yesterday.

P-088printed_secret MUST NOT be used directly as an HMAC key. Both pairing proofs are computed under pair_key.

It is a master secret that can never be rotated, and the comms processor observes every pairing exchange — using it raw would hand the one component this document declares hostile an HMAC oracle under the one secret the whole device depends on. RFC 5869 §3.1 and NIST SP 800-108 both say the same thing: derive a purpose-specific key, do not MAC under the master. One extra HKDF at enrolment buys it, and it is free only until the first unit is paired.

P-044printed_secret MUST be exactly 32 bytes of entropy, carried in the QR code as 64 lowercase hexadecimal characters. The KDF consumes the decoded 32 bytes, never the printed text. A six-digit PIN is brute-forceable offline from a single observed proof and MUST NOT be used.

P-045 — No key is ever transmitted. What crosses the link is a proof of knowledge, which an observer learns nothing from.

P-049 — The QR code payload MUST be exactly

km43:1:<device_id>:<printed_secret>

— the literal ASCII km43, a colon, the payload version 1, a colon, the device_id as the 32 lowercase hexadecimal characters of P-038, a colon, and the printed_secret as the 64 lowercase hexadecimal characters of P-044. That is 5 + 1 + 1 + 1 + 32 + 1 + 64 = 105 characters: no whitespace, no URI escaping, no trailing newline, nothing else.

A scanner MUST refuse a payload that does not match that shape exactly, and MUST NOT pair from a partially parsed one — no uppercase hex, no tolerance for surrounding whitespace, and never a bare hex string for either field. The secret is meaningless without the device_id that salts its KDF, and a scanner that will take a fragment is a scanner that can be fed one.

It is a bare string rather than a km43:// URI because a scheme is an app-link registration and a claim on the operating system, and this is the one artefact in the whole protocol that is fixed at print time: a label cannot be reprinted into a unit already on a wall. Two fields were already specified character by character with nothing saying what carried them, which reads as settled and is not.

req mac = HMAC(session_key, "km43/v1/req" | type:u8 | session_id:u16be
| req_id:u32be | client_id:u32be | counter:u64be
| operation)[0..16]
wrq mac = HMAC(session_key, "km43/v1/wrq" | type:u8 | session_id:u16be
| req_id:u32be | payload)[0..16]
rsp mac = HMAC(session_key, "km43/v1/rsp" | type:u8 | session_id:u16be
| req_id:u32be | payload)[0..16]
evt mac = HMAC(session_key, "km43/v1/evt" | type:u8 | session_id:u16be
| 0x00000000 | payload)[0..16]

P-046type MUST be inside every preimage, so a signed SetConfig cannot be replayed as a signed Command.

P-047req_id MUST be inside both the request and the response preimage. (session_id, req_id) is exactly what the untrusted comms processor correlates on; without it in the request MAC, it can move a validly-MAC’d answer onto the wrong outstanding request.

P-048operation and payload are byte strings carried on the wire, and the MAC covers those literal bytes. A verifier MUST NOT re-encode before verifying.


P-050 — Every message listed in P-052 has a body that is exactly this wrapper:

1: payload bstr the CBOR-encoded inner body
2: mac bstr16

The wrapper MUST contain exactly keys 1 and 2. Any other key in it is error 1, and P-013 does not apply to the wrapper — the one structure in this document where skip-unknown-keys is switched off. P-051 switches it off for key 2 of this same wrapper, which is the other half of the same rule rather than a second place.

That is a rule about the future rather than about today. The MAC covers payload and nothing else, so a key 3 added to the wrapper in v2 would be semantically meaningful and structurally outside the authentication, and P-013 would have every v1 decoder skip it politely on the way past. It is the classic shape of the bug: the field added later lands on the wrong side of the MAC, and nobody notices because skip-unknown-keys is correct everywhere else. Everything that needs to grow, grows inside payload, which is authenticated.

Hello 0x01 and Pair 0x0B/0x8B are authenticated by the proof formulas in their own sections and are deliberately not this wrapper: what verifies them comes from inside the body, so the wrapper’s verify-then-decode ordering is not available. P-057 says what they do instead.

P-051 — For every message in P-052 a receiver MUST verify the MAC before decoding payload, and MUST discard the message on failure. P-013’s skip-unknown-keys rule MUST NOT be applied to key 2: a body arriving without a MAC where one is required is discarded, never accepted as a message with an unknown key missing.

Discarding is about not acting, not about staying silent. A receiver MAY answer a bare Error code 10 carrying the session_id and req_id from the envelope it received, at most once per offending frame; a client treats that under P-055 and concludes nothing about the site from it. The controller counts MAC failures per connection and sheds with CloseConnection reason 3 (LINK.md) at MAX_AUTH_FAILURES — 8 inside 60 seconds, measured on P-004’s tick. The count belongs to the connection row rather than to the session, so a Goodbye and a fresh Hello does not clear it: a threshold the peer resets by handshaking again is not a threshold. The same applies to a signed request whose MAC fails under P-080 and to a Hello proof that fails under P-057 — one condition, one code, rather than each implementer picking a number.

The number is named for the reason every other cap here is named. It is not what makes a forgery hard — 128 bits of truncated HMAC-SHA256 is what makes a forgery hard — it is what bounds the CPU a peer can spend on MACs it cannot pass, and it is what turns the controller sheds a client that keeps failing into something conformance item 7 can prove rather than something each firmware picks at a bench: 3 on one, 1000 on the other, and the site that got the second one never sheds anybody.

Pair is the exception, and P-141 is why. A Pair 0x0B whose proof fails MUST be answered with Pair 0x8B outcome 3 bad_proof, client_id = 0, carrying the P-064 MAC — never with a bare error 10. Outcome 3 exists for exactly this condition, and P-141 settles which of the two is sent wherever both could answer: the outcome. P-066’s argument applies here unchanged and is the reason — a refusal the comms processor can forge is a refusal that sends somebody back to the panel. A forged error 10 tells a person standing at the device that they mis-scanned a label they scanned correctly, and the only cure they can think of is pressing the button again.

The exception is about which message answers, not about the count. A Pair 0x0B answered outcome 3 bad_proof MUST count against MAX_AUTH_FAILURES for that connection: it is the only path on which an unproven peer makes the controller compute an HMAC, which is precisely the CPU the counter bounds. Outcome 2 window_closed and outcome 4 table_full verify no proof and MUST NOT count — refusing to look at a proof costs nothing, and counting it would let anybody close a technician’s connection by sending Pair at a controller with no window open.

Both halves matter and only one is obvious. Without the first, Pair is an unmetered HMAC oracle sitting behind the one message type that has to work while somebody is standing at the panel with a phone. Without the second, the shed threshold fires on refusals the controller reached without doing any work.

P-052 — The following are authenticated by the wrapper above under session_key, each with the label its direction requires:

MessagesLabel
Requests 0x02, 0x03, 0x05, 0x06, 0x0Ckm43/v1/wrq
Responses 0x81, 0x82, 0x83, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8C, and 0xFF in its wrapped form (P-142)km43/v1/rsp
Event 0x04km43/v1/evt

The read-only requests need their own label because they carry no counter and so cannot use the signed-request preimage. Without one they would have no preimage at all, which is five request types that cannot be authenticated.

P-053 — Signed requests (0x07, 0x08, 0x09, 0x0A) use the signed body in the next section instead, because they carry a counter the wrapper has no room for.

P-0540x00/0x80 (Discover) are unauthenticated: no key exists yet. 0x0B/0x8B (Pair) are authenticated under pair_key, which P-088 derives from printed_secret with the label km43/v1/pair-key, rather than under a session key — there is none yet either.

This used to say the printed secret was keyed directly, with no derived pairing key in between, which is the exact thing P-088 forbids and says why: the comms processor watches every pairing exchange, and MACing under the master secret hands the one component this document declares hostile an HMAC oracle under the one secret the whole device depends on. Two requirements one page apart said opposite things about which key signs the same message, and an implementer would have picked whichever they read second.

P-055 — A client MUST NOT render an unauthenticated message as a statement about the site. From an unauthenticated Error a client may retry or reconnect, and may conclude nothing else.

P-056 — A client MUST hold a mark meaning the lowest seq it will still accept, and MUST reject an unsolicited Event (0x04) whose seq is below it. On accepting a SubscribeAck the mark is set to accepted_from_seq; on accepting an event it is set to that event’s seq + 1. That is what stops a replay inside a session.

Written as not greater than the highest accepted, with the mark reset to accepted_from_seq, the rule discarded the first replayed event of every subscription: accepted_from_seq names a position that is delivered (P-029), and the record at exactly that position is not greater than the mark. One record per subscription, always the oldest one the client asked for, and on a stream that is quiet for eleven months of the year that record is as likely as not the alarm somebody subscribed to find. Phrasing the mark as a floor rather than as a high-water line makes both cases the same sentence.

Without the reset on SubscribeAck, a second Subscribe from an earlier from_seq delivers events the client is then obliged to reject one by one, and the catch-up P-094 exists to guarantee does nothing at all.

LogEntry values inside a LogPage are outside this rule entirely. They are records the client asked for, by req_id, under a response MAC bound to that request — going backwards is the whole point of ReadLog, and a rule written for the live stream must not reach the catch-up path it exists alongside.

P-057Hello 0x01 and Pair 0x0B cannot be checked before their bodies are read: Hello’s key comes from a client_id inside the body, and Pair’s preimage covers client_kind and label, which are inside it too. So the order is fixed here rather than left to P-051: decode the body under MAX_PAYLOAD, MAX_STRING, MAX_DEPTH and P-015; take the fields the proof needs; verify the proof; and only then act on anything that was decoded. A decode failure is error 1 and an unknown client_id is error 12, and neither does any further work. With P-072 this is the second place a value is used before it is authenticated, and conformance items 4 and 5 are what stand behind it.


The only thing an unauthenticated peer gets: enough to know what it is talking to and whether it can be paired with.

Discover 0x00
(empty map)
Discover 0x80 unauthenticated
1: protocol_major u8
2: protocol_minor u8
3: device_id bstr16
4: model text
5: provisioned bool true once at least one client is enrolled
6: pairing_open bool true while the physical button has opened a window
7: challenge bstr16 fresh per connection, from the controller
8: epoch u32 provisioning epoch; keys derived under an older
one no longer verify (P-085)

Everything else — snapshot, log, configuration, firmware state, diagnostics — requires an authenticated session. Configuration alone would otherwise leak occupancy, generator activity, energy use and network settings to anyone within BLE range.

P-060 — The controller MUST mint a fresh challenge per connection, using the connection handle from LINK.md L-060, and MUST hold at most MAX_CHALLENGES. A single device-wide challenge livelocks two clients against each other exactly the way a device-wide counter would.

A Discover MUST be answered with that connection’s current challenge. If the connection holds none — the one it had was consumed, or it expired at 120 seconds — the controller MUST mint another for that handle and discard the old, so at most one challenge exists per connection at any moment and a client always proves against something live. Handing back a challenge that is already dead sends a client off to compute a proof that cannot verify, and error 14 is the only way it finds out.

P-061 — A challenge MUST be single-use: consumed by the first Hello or Pair that presents it. A second use is error 14.

Consuming one leaves the connection holding none, and something has to replace it or a browser would have to drop its socket in the middle of enrolment. Two things do: P-058 hands the replacement back inside the Pair response, which is what makes the flow with somebody standing at the panel Discover, Pair, Hello on one transport; and P-060 mints one on the next Discover for every other case, a Hello that consumed one, a challenge that expired, a client that ignored key 4.

P-062 — A challenge MUST expire 120 seconds after it is minted, and MUST be discarded when its connection drops. Error 14 tells the client to reconnect and retry. Exactly two conditions produce it and no others: a challenge presented after it expired or after its connection dropped, and a challenge presented a second time (P-061). A client cannot tell them apart and does not need to — both mean the thing you proved against is gone, go back to Discover for a live one, which is why they share a code rather than costing the registry two.

P-063 — A challenge MUST come from a CSPRNG. A predictable challenge makes the session key predictable.

A client is enrolled once, in person, and derives a key it is never sent.

Pair 0x0B
1: client_kind u8 see REGISTRY
2: label text what a person sees in the client list
3: proof bstr16
4: client_nonce bstr16 fresh per attempt, from the client's CSPRNG
proof = HMAC(pair_key, "km43/v1/pair-proof" | device_id | challenge
| client_nonce | client_kind:u8 | label)[0..16]
Pair 0x8B
1: outcome u8 see REGISTRY
2: client_id u32 0 when outcome is neither 1 enrolled nor 5 reclaimed
3: mac bstr16
4: next_challenge bstr16 the challenge this connection holds now that the
one just presented has been consumed
mac = HMAC(pair_key, "km43/v1/pair-ack" | device_id | challenge
| client_nonce | outcome:u8 | client_id:u32be | epoch:u32be
| next_challenge)[0..16]

P-069client_nonce MUST be fresh per pairing attempt and MUST be in both preimages. Without it every input to the pair-ack MAC is chosen by the controller or fixed by the device, so an ack recorded from an earlier enrolment verifies again on a later attempt — and the ack is the message that fixes a client’s identity. The controller would be telling one phone it is client_id 3 while a replayed ack tells the next phone the same thing.

label enters the preimage as its UTF-8 bytes, without the CBOR text-string header, and it is last and carries no length prefix — it is the one variable-width field in any preimage in this document, everything ahead of it is fixed width, and that is the same convention P-048 uses for operation and payload. MAX_STRING bounds it at 64 bytes.

It is in the preimage because it is the field a person reads when deciding which enrolments belong at this site. Left outside, the comms processor rewrites it in flight at no cost and the controller stores the rewritten value and authenticates it onward — an audit record naming a device that was never there. P-070 already states the same principle one message over.

P-058Pair 0x8B MUST carry next_challenge: the challenge the controller mints for that connection as the presented one is consumed, minted under P-060 and P-063 like any other. It MUST be inside the pair-ack preimage, and it is present on every outcome.

The enrolment flow was Discover, Pair, Discover, Hello — four messages, of which the third exists only to collect a replacement challenge, because P-061 makes a challenge single-use. That is a whole round trip on a link somebody is standing in front of with a phone, spent asking for a value the controller had already decided to mint. Issuing it in the ack makes the flow Discover, Pair, Hello.

It is inside the MAC because the next Hello proof is computed against it. Left outside, the comms processor substitutes a challenge of its own choosing and the Hello that follows proves against a value the untrusted party picked, which is the one input to session_key the controller is supposed to own. That is P-069’s argument for client_nonce, one field over and in the other direction.

Every outcome carries it because P-061 consumes a challenge on presentation, not on success: a Pair that failed its proof has still spent it. A client told bad_proof needs a live challenge to retry the thing it just got wrong, and without key 4 in that response the answer would be another Discover — which is the round trip this requirement deleted, put back on the path where somebody is already frustrated. A client MAY ignore key 4 and Discover again anyway; P-060 mints a fresh one and discards this one, exactly as it does for a challenge that expired.

P-064 — An enrolment that succeeds MUST be answered with Pair 0x8B outcome 1 enrolled, carrying the allocated client_id. Every refusal names its outcome somewhere below; the one that succeeds did not, and an implementer reaching it by elimination is the guessing this document says it must never require.

The pairing response MUST carry the MAC above, and a client MUST discard a response that fails it rather than enrolling. Nothing else in this protocol can authenticate this message: no session exists, and client_key cannot be derived until client_id has been read out of this very message. The earlier draft left it authenticated by nothing, which let the comms processor choose a client’s identity and its replay baseline.

P-065 — A newly enrolled client’s counter MUST start at 0, and the response MUST NOT carry a starting counter. A counter supplied by the network is a counter an attacker can set to 2^64 − 1, after which every write that client ever makes fails as stale and recovery is a four-hour drive.

P-066 — The physical button MUST gate enrolment. Knowing the printed secret is not by itself sufficient. The window is 120 seconds. A Pair arriving with no window open MUST be answered with Pair 0x8B outcome 2 window_closed and client_id = 0, carrying the MAC above — not with an unauthenticated error. A refusal the comms processor can forge is a refusal that sends somebody back to the panel to press a button that was never needed.

P-067 — A full client table MUST refuse with outcome 4, unless P-078’s reclaim matched first. It MUST NOT evict. table_full therefore means eight distinct labels rather than eight pairings.

P-068 — Counter recovery is a re-pair, not a reset. A reinstalled app pairs again, in person, and is given the same client_id when its label matches an occupied row byte for byte (P-078, outcome 5 reclaimed), and a new one otherwise. What must never happen is handing a client a fresh counter for an existing id on request — that is a replay hole with a friendly name. What makes a reclaim different from a request is the evidence behind it: the button, the 120-second window and a proof under pair_key that needs the printed secret, which is the same evidence as a first enrolment and not a message anybody can send.

P-078 — Inside an open pairing window, a Pair whose proof verifies and whose label is byte-identical to an occupied row’s label MUST reuse that row: same client_id, counter back to 0, client_kind and capability mask re-fixed from this proof exactly as at first enrolment, answered with outcome 5 reclaimed. Matching MUST run before allocation — reclaim, then P-086’s lowest free slot, then P-067’s outcome 4 — and it MUST compare the exact UTF-8 bytes that entered the pair-proof preimage: no case folding, no trimming, no Unicode normalisation.

Every session bound to that client_id MUST be unbound before the row is rewritten, and the counter MUST NOT be reset while any binding on the row survives.

Without that the reset is a replay hole. The three rules below hold for a frame captured under an old session; they say nothing about a session that is still bound when the reclaim happens, and nothing else unbinds one. The counter row is per client_id (P-081) and is selected by the session rather than the body (P-084), so a session that survives the reclaim reads a row that was just set to 0 under a session_key nothing touched — and every signed frame captured from that session carries a counter above 0. Ten minutes later the dedup entry ages out and a captured Command is executable again.

Unbinding is enough on its own, and the transport does not need closing: the client discovers it through error 9 and reconnects, which is the signal P-143 already documents for a session that no longer exists.

Without this the client table is a consumable. MAX_CLIENTS is 8 and those eight are shared by every phone, every browser, the cloud and the CLI, while P-068 makes spending one routine: app reinstalled, phone replaced, browser data cleared. The break point is eight cumulative re-pairings, not eight people, which one phone reaches in a season — and after it, the answer to the site cannot be told to stop is a four-hour drive. The cost of physical-only revocation was priced against a lost phone, which is rare. Nobody priced the reinstall.

Nothing new is granted by it. Whoever can reclaim a row can already enrol into a free one and can already factory-reset the whole table.

Matching on label is sound only because label is inside the pair-proof preimage (P-069). Outside it, the comms processor rewrites it in flight — to point a reclaim at somebody else’s row, or to stop a legitimate match and burn a slot. The byte-exact comparison is the same argument one layer down: two implementations that normalise differently hand one client another client’s row. The cost is that renaming a phone spends a slot, which is the right way round, because that is visible to the person standing at the panel and the other error is not.

A reclaim is not a revocation, and reaching for it as one is the wrong tool. Same epoch and same client_id means a byte-identical client_key, so the old install still holds a working key. This is for the honest case. A stolen phone is still the button and a factory reset, which bumps the epoch and invalidates every key at once (P-085).

Setting the counter back to 0 does not re-open replay, and that is worth writing down because it is not obvious. A signed request is MAC’d under session_key, never under client_key, and session_key binds a single-use CSPRNG challenge (P-061, P-063) and the client’s own nonce (P-071). A frame captured under an old session cannot verify under a new one at any counter value. And P-078 unbinds every session on the row before the counter moves, so there is no session left that a captured frame could verify under. The counter orders a client’s writes within a session and across a reboot; it is not what stands between a recorded frame and a replay. Weaken any one of those four rules and this reset becomes a hole — which is the sentence to check against before weakening one. The fourth was missing until a reviewer checked the other three against it and found the case they do not cover.

P-105 — Every enrolled client MUST carry a capability mask, fixed at enrolment from the attested client_kind, stored in FRAM beside that client’s counter, and never changed by any message. The bit allocation and the per-kind rows are in REGISTRY.md. A client_kind with no row there MUST be refused at enrolment. A refused capability MUST be answered inside the MAC’d response for that message — SetConfigAck outcome 4, Ack outcome 5, TimeAck outcome 3, Firmware outcome 8 — and never with an Error, which is P-141 applied.

Three outcomes spelled out an authorisation model that no normative document defined a single permission for, which left all eight enrolled clients strictly equipotent: the cloud client could push firmware, write the site’s Wi-Fi passphrase and move the clock. That client is reachable from the internet by definition. A vocabulary of refusals with nothing behind it is worse than an outright gap, because it reads as implemented to everybody who greps for it.

client_kind is a sound input and LINK.md’s transport (L-072) is not, and the difference is provenance: client_kind is inside the pair-proof preimage — its last two fields are client_kind:u8 | label — so it is attested under pair_key by somebody standing at the panel inside the 120-second window P-066 opens, while transport is written by the comms processor, which lifts any rule keyed on it for free.

No message raises a mask and no message lowers one. Either is a re-pair, which means the button. This is the rejected-Revoke argument one field over: a mask any client can edit is a mask the most exposed client edits first, and the party that most wants firmware authority is exactly the party that must not be able to grant itself firmware authority. A reclaimed row re-fixes its mask from the proof P-078 just verified, so a row’s permissions never outlive the enrolment that set them.

The mask is only as good as the enrolment, said out loud. Whoever holds the printed secret with the button pressed chooses client_kind, and can therefore enrol a cloud relay as 1 app. That person already has the label and the button, which is the whole of the authority in this design. The mask defends against a client that turns hostile later, not against the person who enrolled it.

The limitation, stated rather than left to be discovered: anyone who photographs the label can derive keys, and there is no forward secrecy. The upgrade is an authenticated key agreement — see DEFERRED.md.

Hello 0x01
inner body:
1: protocol_major u8
2: protocol_minor u8
3: client_id u32
4: client_version text
5: client_nonce bstr16
body:
1: payload bstr the inner body above, encoded
2: proof bstr16
proof = HMAC(client_key, "km43/v1/hello-proof" | challenge | client_nonce
| client_id:u32be | payload)[0..16]
Hello 0x81 wrapper under session_key
1: protocol_major u8
2: protocol_minor u8
3: session_id u16 also in the envelope; MUST match
4: fw_controller text
5: fw_comms text what the comms processor says about itself.
Diagnostic only — a client MUST NOT decide on it
and MUST NOT read it as confirmation that a
comms image is installed
6: capabilities u32 bitfield, see REGISTRY
7: log_oldest_seq u64
8: log_newest_seq u64
9: state_seq u64
10: time_known bool
11: counter u64 this client's last accepted counter
12: max_sessions u8 the reported limits, P-005. What this controller
13: max_channels u8 enforces, not what the protocol permits.
14: max_clients u8 Key 13 is capped at 32 and only ever reported
15: max_event_queue u16 downward from it (P-006)
16: max_inflight u8
17: max_cmd_dedup u16 entries; the 10-minute window is not negotiable

P-070 — The Hello proof MUST cover the whole inner body, so that protocol_major, protocol_minor and client_version cannot be rewritten in flight. Version negotiation running on attacker-controlled values is a downgrade with extra steps.

P-071client_nonce MUST come from a CSPRNG and MUST be fresh per handshake. Without it every input to the session key is either long-term or chosen by the untrusted side, and a recorded session replays cleanly at a client that cannot tell.

P-072 — The client derives session_key using the session_id from the envelope of the Hello response, then verifies the body MAC. This is the one place a value is used before it is authenticated, and it is safe because session_id is inside the preimage: a rewritten session_id yields a different key and the MAC fails.

P-073 — A major version mismatch MUST refuse the session with error 3. A minor mismatch MUST proceed at the lower of the two. A newer client degrades; it never assumes.

P-074state_seq is the state store’s own counter. log_oldest_seq, log_newest_seq and every seq elsewhere in this document are positions in the log sequence space. They are two spaces and MUST NOT be compared.

P-075 — A client SHOULD refuse, and MUST surface, a session whose log_newest_seq or state_seq has regressed below the highest it previously accepted from that device_id. This is SHOULD rather than MUST because a board swap or a NOR erase regresses it legitimately, and a hard rule would turn a repair into a lockout at a site four hours from a road.

Goodbye 0x0C wrapper, empty inner body
Goodbye 0x8C wrapper, empty inner body

P-076 — A connection row has two independent states: allocated, meaning a transport exists, and bound, meaning a session is running on it. Goodbye clears the binding only. The row stays allocated to the transport that is still open, its handle is not reusable, and the controller MUST free the row itself on ClientDisconnected from LINK.md and on a new comms boot_id (L-041). Without the binding being freed, a browser refreshed eight times inside the expiry window locks every client out for fifteen minutes; without the distinction, one polite Goodbye makes the controller’s connection count disagree with the comms processor’s and the heartbeat resync costs the other seven clients a reconnect.

A client that has sent Goodbye MAY Hello again on the same transport: the row is still allocated, and the next Discover on it returns a live challenge under P-060.

A Hello whose proof verifies on a row that is already bound MUST replace the binding, deriving the new session_key from the challenge and client_nonce that Hello presented. The previous session_key MUST be destroyed and that session’s outstanding requests abandoned. It MUST NOT be answered with error 8.

The state is reached routinely and not by anybody misbehaving: the controller binds the row, the response is lost on the way back, and the client — holding no session_id and no key — has nothing to do except Hello again. Refused, it is stuck until P-077’s fifteen minutes expire the session it never learned it had, on a transport it believes is healthy. Error 8 is the worst of the available refusals, because it means session table full and a client reading it waits rather than reconnecting; a binding held by the client that is asking is not a full table.

Replacement rather than refusal is safe because the proof is the whole check. A peer that can verify a Hello on this row can already open a fresh row, and the row is not a permission — it is a place to put a key. Note that the replacing Hello may carry a different client_id: the session binds whichever one proved, and counters are per client in FRAM (P-081), so nothing about the previous client’s counter row moves.

P-077 — Sessions expire after 15 minutes without traffic, and the controller sends CloseConnection with reason session_expired so the row and the transport go at the same moment rather than leaving a socket the client believes is healthy.

Only an authenticated inbound frame refreshes that timer — a request whose wrapper MAC or signed-body MAC verified on that session. Outbound MUST NOT refresh it: not a response, not an event, not a records dropped record the controller generated by itself. A frame that fails its MAC does not count as traffic either; it counts against MAX_AUTH_FAILURES.

Traffic was never defined, and the reading that costs nothing to arrive at is the wrong one. If an outbound event refreshed the timer, a subscribed session would never expire at all — the controller is publishing to it every time anything on the site moves, so the session stays alive on the strength of the controller talking to itself. And the sessions most likely to be stale are exactly the subscribed ones: the browser tab somebody closed the lid on still holds one of eight bindings, cheerfully fed events nobody is reading, until somebody at the panel is refused with error 8 for a session that stopped existing in any useful sense hours ago. Expiry has to measure the client still being there, and only something the client sent measures that.

MAX_SESSIONS is 8, and a Hello that finds no binding free is refused with error 8. Today that only fires on a transport the comms processor does not own, because it refuses a ninth connection at ClientConnected before any Hello can be sent. A session table that grows with reconnections is an unbounded allocation wearing a different hat.


Every write is signed: configuration, firmware, time and commands. Not just commands — authenticating one write and not another is a locked door next to an open one.

body of any signed request
1: client_id u32
2: counter u64 MUST exceed this client's last accepted value
3: operation bstr the CBOR-encoded operation body
4: mac bstr16

P-080 — The controller MUST take these steps in this order:

  1. Verify the MAC. A MAC that fails is P-051’s error 10.
  2. Check the counter. A counter that does not exceed the stored value MUST be refused with error 11.
  3. For a Command 0x08, look up (client_id, cmd_id) in the dedup table and answer per P-120 and P-124 on a match, without executing.
  4. For a Command 0x08, reserve a dedup entry marked in flight, and persist it together with the new counter in one FRAM transaction. A failure here is P-079’s error 7 and P-122’s error 7.
  5. Execute.
  6. For a Command 0x08, mark the reserved entry complete, carrying the outcome it produced.

Persisting before executing is deliberately fail-closed: a brown-out between the two loses the operation, and the client’s retry carries a new counter and succeeds. The reverse order leaves a replayable counter after a power cut, which is the worse failure at this site. One number each for the two refusals, named here rather than picked at a bench.

The dedup entry is written in the same transaction as the counter for the same reason the counter is written before the handler runs. Written after execute, it is exactly the record that a reset between the two destroys — which is the window P-121 puts the table in FRAM to close, reopened three lines below the requirement that closes it. Written before, a crash leaves an entry saying an operation may have run, which is a question the controller can answer.

What a retry meets against an entry left in flight by a reset is the case this ordering exists for, and ordering alone does not answer it. The controller cannot know from the entry whether the operation ran: the reset could have landed on either side of execute. It MUST NOT assume either. It MUST answer from the state store, which is authoritative for what the hardware is doing — if the contact the command asked for is already in the asked-for state, the entry completes as accepted and the retry is answered duplicate; if it is not, the entry is discarded and the retry executes normally. That is the one reading under which a start interrupted by a brown-out neither starts twice nor silently fails to start, and it is available because the state store is read from the hardware at boot rather than remembered.

P-079 — If persisting the counter fails, the operation MUST NOT execute. The controller MUST answer error 7 busy and MUST raise a class A alarm raised (0x0501). It MUST NOT execute anyway and leave a counter store that does not know it happened.

Fail-closed is the whole of P-080’s ordering, and it had no exit. FRAM is where the per-client counters live, and the client keys, the dedup table, the A/B configuration pointer and the panic reason with them; a write that fails there is the controller losing its record of what it has already accepted. Execute anyway and the frame that just ran replays the moment somebody sends it again — the hole P-080 orders its steps to close, opened by the one step that did not work. LINK.md L-137 gives the comms processor nvs_write_failed for exactly this on its own side, where what is at stake is a Wi-Fi passphrase; the controller side, where it is a counter, said nothing at all.

Error 7 because there is one instruction to give and it is the controller did not do this, send it again: a retry carries a new counter under P-082 and lands if the next write succeeds. P-141 is not in tension with that — the persist sits ahead of execute in P-080’s order, so nothing has reached a handler yet, which is the condition P-141 keeps Error for. And what tells somebody the part is wearing out is the class A record, which lands in the log in NOR — a different device from the one that just failed — rather than the error code, which the comms processor can drop.

P-081 — Counters are per client, stored client_id → highest accepted in FRAM. A single device-wide counter livelocks the moment two clients are active: both read 100, both send 101, one is rejected forever.

A client that receives error 11 MUST take its next counter from Hello 0x81 key 11, re-Helloing if it no longer holds a live value, and MUST NOT retry by incrementing the value it just had refused.

A local increment from a value that is already behind the stored one never catches up: the client sends 101, is refused because the row holds 340, sends 102, is refused, and spends the afternoon walking towards a number it cannot see. That is P-081’s livelock reached by one client on its own, and it is reachable in the honest case — two installs of the same app on one client_id, or a client whose local counter did not survive a crash. Key 11 is the way out, and until this rule existed the field list described it as this client’s last accepted counter and nothing told anybody to read it.

P-084 — Key 1 client_id MUST equal the client_id the session was bound to at Hello. A mismatch MUST be refused with error 12 before the counter is read or written. Key 1 is on the wire only because it is inside the MAC preimage; it is never the lookup key, and the counter row is selected by the session, not by the body.

Without this the counter store is keyed by a field the sender chooses, and the MAC does not save it: the sender computes it under its own session_key, so the preimage covers the field without constraining it. Any enrolled client could write 2^64 − 1 into another client’s row, and that client’s every later write would fail as stale until somebody drove out — which is exactly the lockout P-065 exists to prevent, reached through a different message. This document already sets the precedent it nearly omitted: Hello 0x81 key 3 carries session_id with MUST match, and the framing section refuses a second length field on the grounds that a second source of the same fact is a second thing to disagree with the first.

P-082counter prevents replay. cmd_id suppresses duplicates. They solve different problems and both are required: a retried command carries the same cmd_id and a new counter.

P-083operation MUST NOT exceed MAX_OPERATION (960 bytes). The worst-case CBOR overhead of the signed body and the envelope around it is 49 bytes, so a payload holds an operation of 975 — and 960 is a cap with margin, not that ceiling, exactly as MAX_CHANNELS is.

The fifteen bytes are what absorbs a later key. The signed body is one of the three whose MAC covers fields rather than an encoding, so a key added to it enters the preimage and these bytes both; without the margin, adding one turns an operation that was legal yesterday into a frame the controller builds and then has to refuse. This paragraph said 960 was MAX_PAYLOAD less the overhead, which is an equality that never held: the overhead is 49 and the subtraction gives 975.


Snapshot 0x02 wrapper, empty inner body
Snapshot 0x82 wrapper under session_key
1: seq u64 log position this snapshot reflects
2: at u64 optional; controller time, ms since epoch
3: values [ Value ]
Value
1: channel u16 stable id, from config
2: kind u16 metric kind, see REGISTRY
3: value int optional; scaled integer, i32 range, omitted when absent
4: quality u8 see REGISTRY
5: at u64 optional; when this value was taken

P-089 — Key 3 MUST be a signed integer in the i32 range, −2 147 483 648 to 2 147 483 647, and a receiver MUST reject a wider one with error 1. Every value on this wire is already a scaled integer under P-018, so the bound costs nothing a real instrument can produce: at the registry’s scale of −3 an i32 reaches ±2 147 483 volts, six orders of magnitude past anything at this site. The one place the ceiling is real rather than theoretical is AC energy (0x0205, Wh, scale 0), where an i32 holds 2.1 GWh — an 80 kW generator running flat out for three years. If an accumulator ever approaches that, the answer is a scale in the registry, not a wider integer on the wire.

What it buys is that a full MAX_CHANNELS snapshot fits in MAX_PAYLOAD. Unbounded, key 3 is 9 bytes of CBOR and 32 Values do not fit — a fully configured site with every probe reading gets its snapshot built and then refused with error 5, and P-090 leaves no paging to fall back on. The derivation is in the limits section.

P-090values MUST contain at most MAX_CHANNELS entries — the number this controller reported in Hello 0x81 key 13, which P-006 caps at 32 and only ever lets be smaller. There is no paging. A configuration naming more channels than that is refused at write time with SetConfigAck outcome 5 exceeds_cap, so the failure lands on the person editing config rather than on a client at 2 a.m.

P-091 — When quality is absent, key 3 MUST be omitted. The Value itself is still present, so a client can tell configured but unreadable from not configured at all — a dead frost probe from a probe nobody ever wired.

P-125 — Three metric kinds carry an enumerated state in key 3 rather than a measurement: 0x0501 generator state, 0x0601 generator selector position and 0x0603 boot reason. A value in one of those spaces that the client does not recognise MUST be surfaced as unrecognised for that one channel — never mapped onto a known state, never rendered as a state, and never replaced with a fallback. It MUST NOT invalidate the Snapshot, and quality stays authoritative for whether the reading is any good.

These three are the only place in the protocol where a discriminant is not in a discriminant field. P-014 rejects an unknown enum value with error 1, and it is written about fields that decide behaviour — type, outcome, quality. Key 3 is typed int and carries a scaled measurement everywhere else, so P-014 never reaches these, and nothing else said what to do either. Both readings an implementer could take are wrong in different directions: apply P-014 and one unknown state in one channel refuses the whole snapshot, blanking every reading on the screen because a firmware two versions newer added a state to a generator this client is not even looking at; render it as a number and the client shows generator state: 7, or worse picks the nearest one it knows and shows running.

On the response path “reject” cannot mean error 1 anyway. A client has no way to refuse part of a MAC’d response it has already verified, and refusing all of it throws away thirty-one channels that are perfectly readable. The rule that fits the direction is discard the one value and do not render it, which is the same answer the metric-kind and event-kind spaces already get for being open sets.

P-092 — When quality is stale, key 3 MUST carry the last known value and key 5 MUST carry the time it was taken. Stale means old, not missing. Both keys, always — which is why a controller whose clock has never been set does not report stale at all; see P-093.

P-093 — Key 2 (at) and key 5 (at) MUST be omitted when the clock has never been set. A zero MUST NOT be sent to mean unknown: 1970 is a plausible wrong measurement, and this project does not ship defaults that can be mistaken for readings.

It follows that before the clock has ever been set, no Value is stale. Staleness is a claim about when a reading was taken, and a controller with no wall clock cannot make it — P-092 would require a timestamp it does not have and P-093 forbids inventing one. So in that window a reading the controller still holds current is reported with the quality it was read at, and one that has stopped being current — past its maximum age, or unchanged for long enough that the instrument is presumed hung — is reported absent, with key 3 omitted under P-091. The controller still knows it is not current; uptime measures a duration without knowing the date. What it will not do is hand over a number it cannot say the age of.

A client shows that channel the way it shows a dead probe: configured, no reading. That is honest — it is the same sentence, there is no current value here — and it lasts only until the first Time write, after which stale becomes expressible and the last known value comes back with its age attached.

Subscribe 0x03 wrapper
1: from_seq u64 0 means live only, no replay
SubscribeAck 0x83 wrapper
1: accepted_from_seq u64
2: oldest_seq u64
3: current_seq u64
4: gap bool

P-094 — The transition from replay to live MUST be atomic: the controller replays retained events from accepted_from_seq and continues into live delivery with no window between. An event created between “subscription registered” and “replay finished” would otherwise be lost, and lost silently.

P-104accepted_from_seq MUST be current_seq + 1 when from_seq is 0, and max(from_seq, oldest_seq) otherwise, where current_seq is the newest seq the log holds at the moment the Subscribe is answered.

current_seq + 1 rather than current_seq, because under P-029 the mark names the first position that will be delivered and live only, no replay means the first position after everything that already exists. Answering current_seq promises the client the newest record it explicitly asked not to receive.

It is the position replay starts from under P-094 and the mark a client resets its seq filter to under P-056, and nothing said what the controller puts in it. A controller that echoed the client’s 0 replays the entire retained ring at a client that asked for no replay; one that answered current_seq for every value replays nothing at all, ever. Both obeyed every rule written down, which is two implementations disagreeing about the one field that decides how much history arrives — the disagreement this document exists to make impossible.

max(from_seq, oldest_seq) is P-099’s ReadLog clamp word for word. The same fall off the same ring answered two ways in two messages is a difference somebody would have to discover.

P-144seq 0 is not a position. The first record a log ever writes is seq = 1, and 0 in oldest_seq or current_seq means this log holds no record. A receiver MUST NOT treat 0 as a record it can ask for.

P-095 already says this about from_seqit is not a position — and the rest of this protocol already spends 0 the same way: client_id = 0 is no client (P-086) and session_id = 0 is no session (P-021). Saying it once about the seq space closes two questions that would otherwise be settled per implementation.

The first is the empty log. P-104 reads current_seq as “the newest seq the log holds”, and a log holding nothing has no such value; an implementation that puts a real position there answers accepted_from_seq one past a record that does not exist. With 0 meaning none, current_seq + 1 is 1 — the position the first record will take — and P-104 needs no exception.

The second is Subscribe itself. from_seq = 0 is the sentinel, so a client could not ask for position 0 even if one existed. A log whose first record sat at 0 would hold exactly one record no client could ever subscribe from, and ReadLog — which has no sentinel — could ask for it. One space, two messages, two answers.

P-095gap MUST be true when from_seq is not 0 and from_seq < oldest_seq. The client fell off the ring and knows precisely that it did.

from_seq = 0 is exempt because it is not a position — it means live only, no replay, and on any ring that has wrapped 0 < oldest_seq is unconditionally true. Without the exemption a client that deliberately asked for nothing behind it is told, every single time, that it lost data it never asked for.

A session MAY Subscribe more than once. The later SubscribeAck replaces the earlier subscription and re-opens a replay window from its own accepted_from_seq, which is what P-056 resets against.

Event 0x04 wrapper under session_key, req_id = 0
1: seq u64
2: at u64 optional, omitted when the clock was never set
3: kind u16 see REGISTRY
4: body map kind-specific; the schema per kind is deferred —
see REGISTRY and DEFERRED.md

P-096seq is strictly increasing but NOT contiguous. A hole means one of three things: a class B record was dropped under pressure in the log, a stored record failed its CRC and was skipped, or this session’s outbound queue was full when a class B event was published. Class A records — state changes, command outcomes, alarms, config changes, boot records — are never dropped from the log and never dropped from a queue (P-098), so no hole the controller creates hides one. The wire is a different matter: the comms processor can drop a frame, and a hole is therefore evidence rather than proof.

P-097 — A client MUST NOT re-read the log on every hole. An unconditional catch-up per hole is a ReadLog storm that anybody able to drop a frame can trigger. A hole accounted for by a records dropped event (0x0701) is explained and needs nothing further: that record carries the count, which is what turns a hole from a mystery into a number.

A hole that no 0x0701 accounts for is the other case, and a client MUST NOT render the stream as complete through one. It MUST surface it, and MAY reconcile it with a single bounded ReadLog from the last seq it accepted. That is the only in-band signal that a frame went missing between the controller and the screen.

P-119 — A subscribed client MUST compare the highest Event seq it has accepted against the newest seq reported inside the MAC’d responses it is already receiving — Snapshot 0x82 key 1, SubscribeAck 0x83 key 3 current_seq, and Hello 0x81 key 8 log_newest_seq — and MUST treat a divergence that persists across a bounded number of such responses as a broken stream: surface it, reconnect, and reconcile with a single bounded ReadLog, exactly as P-097 permits for an unexplained hole.

A hole is evidence that frames are being dropped; it is not evidence that they are not. type is in the clear (P-018), so the comms processor can drop every 0x04 without decoding a body and without knowing what any of them said. Total suppression produces no hole, because a hole is a gap between two records that arrived and none arrive. Every rule above — P-096’s three causes, P-097’s 0x0701 accounting, P-098’s separate MAC per session — runs on evidence that suppression is careful never to create. A relay that drops one event in ten is caught immediately; one that drops all of them looks exactly like a quiet site, which is what this site looks like for most of the year.

What makes the check cheap is that the answer is already on the wire and already authenticated. The controller’s newest seq rides inside three responses a client asks for anyway, under a MAC the comms processor cannot forge — so “nothing has happened” and “you have been told nothing has happened” become two different, comparable numbers, with no keepalive, no new field and no traffic on a link that is metered.

P-098 — The controller MUST send one separately-MAC’d copy of an event to each subscribed session. The comms processor holds no key and therefore cannot fan out; it routes. At eight sessions this is 0.026 % of the UART and 0.014 % CPU duty on the target, which is what makes the honest option affordable.

Each session’s outbound queue holds MAX_EVENT_QUEUE events. When it is full, class B events MUST be dropped oldest first and counted, and the controller MUST deliver a records dropped (0x0701) event to that session carrying the count dropped for it — a log-ring count cannot explain a hole one session’s queue made, and P-097 is only honest if every hole arrives with a number attached.

A class A event MUST NOT be dropped on this path. A session that cannot take one MUST be closed with CloseConnection, reason shedding (LINK.md L-022, L-023), so the client reconnects and catches up from its last seq. The record is in the log either way. What must never happen is a client sitting on a socket it believes is live and current while an alarm never reached it.

ReadLog 0x05 wrapper
1: from_seq u64
2: max_entries u16 clamped to 64
LogPage 0x85 wrapper
1: entries [ LogEntry ]
2: next_seq u64 pass back to continue
3: oldest_seq u64 what the controller still holds
4: complete bool true when caught up to newest

A LogEntry has the same keys as an Event body — seq, at, kind, body — and is never a message. The name differs because the two were the same word, and a rule written about one was being read onto the other: P-056 rejects an Event that goes backwards, which is exactly what a page of log entries does by construction.

P-099 — If from_seq < oldest_seq the controller MUST answer from oldest_seq. The client can then see it lost data. Silent loss is the failure this rule exists to prevent.

There is no outbox. The cloud is a client with a cursor, exactly like a phone. Nothing queues on the comms processor, nothing tracks “sent” state, and there is one catch-up mechanism rather than one per transport.


GetConfig 0x06 wrapper
1: section u16 see REGISTRY
Config 0x86 wrapper
1: section u16
2: version u32 increments on every accepted write
3: body map the schema per section is deferred — see REGISTRY
and DEFERRED.md
operation body of SetConfig 0x07
1: section u16
2: expected_version u32
3: body map same schema as the section's Config body
SetConfigAck 0x87 wrapper
1: section u16
2: version u32 the new one
3: outcome u8 see REGISTRY

P-100expected_version MUST be checked and a mismatch refused with stale_version. A phone and a browser editing the same setpoints is not hypothetical.

P-101 — Validation happens on the controller and rejection is loud. A configuration naming a channel that does not exist is refused, not stored, with SetConfigAck outcome 3 invalid; a GetConfig or SetConfig naming a section that is not allocated is error 6, because that one never reaches a handler at all. A behaviour that silently never runs is worse than a write that failed.

P-102 — Writes MUST land in the inactive A/B slot with a sequence number and CRC and take effect by an atomic pointer flip. A power cut mid-write leaves the previous configuration intact and running.

P-103 — Every behaviour section MUST carry a shadow flag readable by a client, under one key number that is the same in all four behaviour sections. While every site runs in shadow nothing actuates, and a deployment whose whole purpose is to be audited must let a person confirm that rather than believe it. The key number lands with the section schemas — see REGISTRY.md — and until it does, this is a promise about what a section must contain rather than a field an implementer can write.

The earlier draft carried a crc in the Config response. It is removed: it described the FRAM slot’s own CRC, which a client cannot compute or check, so it was a field that could only ever be ignored or wrongly trusted.


operation body of Time 0x0A
1: at u64 ms since epoch
2: source u8 see REGISTRY
TimeAck 0x8A wrapper
1: outcome u8 see REGISTRY
2: at u64 optional; the controller's time after the write,
omitted when the clock has never been set

P-110 — Setting the clock is a signed write. It changes what every later log record claims about when it happened.

P-111 — The controller MUST record source in the resulting time set event (0x0604). NTP arrives via the comms processor, which this document otherwise tells you not to believe; recording which one set the clock is what lets a post-mortem tell a drifted RTC from a lying uplink.

The value recorded comes from the Time sources table in REGISTRY.md, which is the one space for it. A clock moved by a TimeOffer the controller accepted (LINK.md L-162) is recorded as 2 ntp-via-comms; 1 client is only ever a signed client write. TimeOffer carries a source of its own about which NTP path it used, and that value is never copied through — copying it inverts exactly the distinction this requirement exists to preserve.

P-112 — Events already written with no timestamp MUST NOT be retroactively stamped. Their ordering is exact through seq, and a client MAY place them once time is known. Rewriting history in an audit log is worse than a gap in it.

P-113 — A Time 0x0A whose at falls outside the plausibility window MUST be refused with TimeAck outcome 2 rejected, and the clock MUST NOT move. The window opens at the timestamp of the newest log record carrying one — P-114’s floor, the same value — and closes ten years after it. On a controller holding no timestamped record at all it opens at the firmware build timestamp instead.

Below the lower edge is P-114’s case and is answered outcome 4 needs_button; outcome 2 is for the upper edge. The two requirements share that edge, so without this sentence a set below it matches both and an implementer picks. They are not the same refusal: below the floor there is something a person at the panel can do about it (P-116), and ten years ahead there is not.

That is the same window LINK.md L-140 applies to a first TimeOffer, and it has to be, because it is the same question asked at the other door. It used to say from the firmware build timestamp, which was the rule LINK.md had before it moved its own floor, so the two documents had drifted into describing different windows in the same words. The newest log record is the better floor on every count: it is in NOR, it survives the boot, and it is correct by construction — the controller was demonstrably running when it wrote that record, so no honest clock is earlier. A build timestamp is a fact about a compiler and gets weaker every day the firmware runs; a unit two years in the field is defending a window that opened two years ago. It is right for exactly one case, the unit that holds no timestamped record at all, and that is the case it is kept for.

The ten years are unchanged and are the same ten years for the same reason: the window has to reach every day of the week and every time of day, or a clock set by somebody who was simply wrong about the date gets refused for being unusual.

P-114 — A Time 0x0A whose at is earlier than the timestamp of the newest log record carrying one MUST be refused with TimeAck outcome 4 needs_button. That timestamp is the monotonic floor: the controller already holds it, and it is the best evidence on site of a moment that has certainly passed. When no record carries a timestamp — the clock has never been set — there is no floor, and P-113’s window is the whole of the check.

Outcome 4 rather than outcome 2, because the refusal has an answer and outcome 2 does not carry it. P-116 says a person at the panel can override the floor; a client told only rejected cannot tell a time nobody could believe from a correction that is one gesture away from landing, and has no reason to put hold the button and send it again on the screen. That sentence is the whole point of P-116, and until this outcome existed there was no way for a client to learn it was the right one.

One floor, both doors. The floor is not a rule about which message moved the clock. LINK.md L-140 applies it to the first TimeOffer after boot and this requirement applies it to a signed client Time 0x0A, so a client cannot be talked into what an offer was refused — which is the sentence LINK.md already uses for it, and the earlier wording here said the opposite: the floor is on Time 0x0A and not on a TimeOffer. Two documents disagreeing about which door a floor stands in is a floor with a way round it.

What it binds is the first set, and what it does not bind is the drift correction, on either door. An accepted TimeOffer inside LINK.md’s 5-second cap (L-150) is a correction and not a jump: a clock running a few seconds fast has to be walked back, that is what correcting drift is, and a floor that refused it would leave the controller unable to make the one correction it is allowed to make — on a path that is also rate-limited to one offer a quarter hour and recorded either way. Time 0x0A carries no such cap, which is why every one of them meets the floor. What P-114 refuses is the jump, not the correction.

P-115 — When the clock was already known and an accepted set moves it by more than TIME_STEP_ALARM, one hour, in either direction, the controller MUST raise a class A alarm raised (0x0501) alongside the time set record P-111 requires, whichever message moved it. A first set after boot is not a step: there is nothing to subtract it from, and a difference computed against a clock that was never known is a measurement nobody made.

Outcome 2 rejected is produced by P-113 and by nothing else, and outcome 4 needs_button by P-114 and by nothing else — before them neither had a producer at all, which made them values in the registry no controller could send and no client would ever see. On either the clock does not move and key 2 carries the time the controller kept, omitted under P-093’s rule when it has never had one: a rejected first set would otherwise have to answer with a zero, and a zero is 1970.

None of this existed, and Time 0x0A is the one message that moves the clock with nothing bounding it. LINK.md is careful about a TimeOffer — ten-year window on the first set, five seconds a step afterwards, one offer a quarter hour (L-140, L-150, L-151) — and not one of those bounds reached a signed client write. Any compromised client key wrote any time it liked.

The backward jump is the worse half and nothing addressed it at all. LINK’s cap is about the forward jump, where a schedule fires early and somebody hears an engine. Move the clock back ten years and every schedule, exercise and quiet-hours deadline lands in the future: nothing fires, nothing alarms, and the weekly exercise run never comes round again. That is not a benign delay: a generator that only starts in an emergency is a generator that does not start in an emergency, and the symptom arrives four months later, in February, as an engine that will not catch.

LINK.md’s L-161, that a clock change never replays a schedule, is written about a TimeOffer and applies here word for word. It is a rule about what a behaviour may treat as elapsed, not about which message moved the clock, so the forward jump P-113 still permits — anywhere inside a ten-year window — starts nothing.

P-116 — A Time 0x0A that the floor would refuse MUST be accepted while a floor override is armed, and the controller MUST raise a class A alarm raised (0x0501) recording that the floor was overridden and by how much — the size of the backward step — alongside the time set record P-111 requires. With no override armed it is refused under P-114 exactly as before, as TimeAck 0x8A outcome 4 needs_button.

P-117 — The floor override:

  1. Is armed only by a press pattern at the panel distinguishable from P-066’s enrolment press and from the factory-reset hold. A press that opened a pairing window MUST NOT arm an override.
  2. Is single-use: it authorises exactly one accepted floor-crossing Time 0x0A, and MUST be cleared on use, on release of the button, and on a bounded timeout no longer than P-066’s 120 seconds.
  3. Is evaluated at the instant the controller processes the operation. It is not a window a client can be told about in advance and it is not P-066’s 120-second window.
  4. Does not lift P-118’s rate limit. A Time 0x0A refused for the rate limit is refused with an override armed exactly as without one.

P-118 — The controller MUST accept at most one Time 0x0A per 15 minutes on P-004’s tick, matching the TimeOffer bound in LINK.md L-151. Beyond that it MUST refuse with error 7 busy, before the operation executes.

The armed state is what makes “somebody is at the panel” an authorisation rather than a coincidence. Without rules 1 and 2, holding the button is a condition a client can wait for: an attacker banks a floor-crossing frame with a valid MAC and a fresh counter, retries it in a loop, and it lands the moment a technician holds the button down for an unrelated reason — enrolling a new phone, most likely, since that is the press this document already asks people to make. The person who authorised nothing sees an engine that will not start in February. Not arming on the enrolment press is what breaks that, and it is also what stops pairing_open in Discover 0x80 from being a published signal for when to fire.

Rule 4 and P-118 close the retry loop itself. A client that may send Time 0x0A as fast as it likes costs nothing to park in a loop, and every rule above is a rule about when the write lands rather than whether it can keep asking. One write a quarter hour makes waiting for a press expensive and matches L-151, the bound LINK.md already places on the comms processor’s own offers — the same door should not have two widths depending on which side knocks.

There is deliberately no field reporting the button’s state. A button_held flag in Discover 0x80 would let a client prompt hold the button and send, which is the one thing outcome 4 does not do as well. It would also be an unauthenticated, pollable answer to is somebody standing at the controller right now — strictly better for the attacker above than the pairing_open oracle rule 1 exists to close, and available to anybody who can reach the port rather than only to an enrolled client. Outcome 4 tells a client the same thing one refusal later, on a MAC’d response, at a rate P-118 bounds.

The enrolment window and the floor override are separate states and either may be true without the other. The enrolment window is 120 seconds long, opened by a press, and reported as pairing_open in Discover 0x80 key 6. The override is armed by a different gesture, consumed by one write, and reported nowhere. An open pairing window is not the P-116 gate and MUST NOT be read as one. The factory-reset hold is a product-local behaviour outside KM43 because it changes no message, but it is a third distinct gesture rather than “the button” a third time.

The override is of the floor, which means P-114 and the lower edge of P-113’s window, because they are the same value and lifting one without the other lifts nothing. The ten-year upper edge still binds and is not overridable by anything: the button is evidence about a clock that ran ahead, not a licence to write any number at all.

This closes a gap P-114 used to concede in place of fixing. A clock set wrong forward, inside P-113’s window and so a date somebody could believe, stayed wrong forever: every correction downward is below the floor and refused, so the one thing a person could do about it was the one thing the controller would not accept. Every record written from then on carries a date that is years out, and the log stops being something anybody can reason about — which is the thing the floor exists to protect.

The floor is evidence, not authority. It says a moment has certainly passed, inferred from a record this controller wrote. A person standing at the panel is better evidence about the same question, and it is deliberately the same class of evidence that gates enrolment (P-066) and factory reset — not the same gesture, which P-117 rule 1 exists to keep apart — because it is the same class of decision: something that cannot be undone from four hours away, made by somebody who is not four hours away. The attack the floor refuses — a compromised client walking the clock backwards until no schedule ever fires again — needs nobody at the site by construction, so requiring somebody at the site costs it nothing and costs the attacker everything.

The alarm is what keeps it honest. An override that left no record would be a floor with a quiet door in it, and the clock was moved back eleven months, by a person at the panel, on this date is a sentence somebody reading the log in February has to be able to find.


Reserved until a controller is granted authority over an output. No actuation before then — see DEFERRED.md. The shape is fixed now so the code space and the dedup rule cannot be invented differently later.

operation body of Command 0x08
1: cmd_id u32 client-generated, unique per command
2: kind u16 see REGISTRY
3: args map
Ack 0x88 wrapper
1: cmd_id u32
2: outcome u8 see REGISTRY
3: detail text operator-facing, <= 64 bytes

P-120 — The dedup table MUST be keyed (client_id, cmd_id, operation-hash), where operation-hash is the leftmost 8 bytes of SHA-256 over the operation byte string exactly as it arrived on the wire (P-048, and never over a re-encoding).

client_id is in the key because two clients numbering their commands from zero is the normal case, and a cmd_id-only key answers duplicate to a command nobody sent twice — the failure looks like the controller ignoring a stop request.

The hash is in the key because (client_id, cmd_id) alone cannot tell a retry from a reused id. A genuine retry carries byte-identical operation bytes — same cmd_id, same kind, same args, only the counter is new under P-082 — so it hashes the same and dedups, which is the whole point of the table. A client that reuses a cmd_id inside the window for a different command carries different bytes and hashes differently.

The lookup is on (client_id, cmd_id) and the hash decides which answer the match getsduplicate when it agrees, P-124’s rejected when it does not. An implementation that hashes all three fields into one opaque key cannot tell the two apart: a reused id simply misses, and the controller executes a second command under an id it has already answered for, which is the failure the table exists to prevent arriving through the fix for a different one.

An entry is created only for a command that executed or committed to executing — outcome 1 accepted or outcome 6 shadowed. Outcome 2 rejected (P-124’s and the handler’s alike), outcome 4 inhibited and outcome 5 unauthorised MUST NOT create one.

Each of those three names a condition the client is expected to retry past. The selector was at Off and somebody has since turned it; a capability was missing and has been granted; a refusal was transient. Leave an entry behind and the retry that should finally run is answered duplicate instead — the same silent no-op wearing the word for success that P-124 exists to prevent, reached from the other side.

A live match with an agreeing hash MUST be answered with the outcome that was recorded, not unconditionally with duplicate: duplicate where the recorded outcome was accepted, and shadowed where it was shadowed. A retried command in shadow mode that comes back duplicate tells the client an action was taken at a site where, by definition, nothing was actuated — and shadow mode exists so that nobody has to guess which of those two happened.

An entry is 25 bytes — client_id:u32, cmd_id:u32, hash 8, inserted:u64, status:u8 — so MAX_CMD_DEDUP’s 32 entries cost 800 bytes of FRAM, on a part that already holds the client keys, the counters and the A/B configuration pointer. status is the in-flight/complete distinction P-080 step 4 needs and the recorded outcome together; two states and two outcomes fit a byte with room left. Eight bytes of hash is 64 bits against an attacker who does not choose the key and gains nothing from a collision anyway: the worst a collision does is answer duplicate to a command that was not one, which is the same failure the client_id in the key already exists to prevent and is bounded to one client’s own ten-minute window.

P-121 — The dedup table MUST live in FRAM and survive a reset. A reboot inside the dedup window would otherwise turn a client’s retry into a second start, on a maintained contact, at a site with nobody in the room.

On boot, every surviving entry’s inserted MUST be set to the new boot’s tick zero, so it lives a further ten minutes from that moment and never longer.

inserted is P-004’s monotonic tick, and P-004’s tick is zero at the boot P-121 requires the entry to survive — so a surviving entry has no clock to be measured against, and until this rule existed there was none to write. Four requirements could not all hold at once: the window is measured on the tick (P-004), the entry outlives the reset (P-121), it never evicts (P-122), and the entry had no field to hold a time at all. Both branches an implementer could pick were bad. Never draining fills the table at 32 and P-122 then answers error 7 to every command, which is a site that cannot be told to stop. Expiring at boot restores exactly the double-start hole P-121 exists to close.

Re-basing at boot over-retains rather than under-retains, and that is the direction chosen deliberately. Over-retaining answers duplicate to a command that was not one, bounded to ten minutes after a reset. Under-retaining starts a generator twice on a maintained contact. The first is a client that has to send its command again; the second is why the table is in FRAM.

Do not reach for a persisted tick base or a tick counted from first boot. Either makes the tick’s monotonicity depend on a FRAM write surviving the brown-out that caused the reset — and P-079 already says that write is a thing that can fail.

P-122 — The table holds MAX_CMD_DEDUP entries for 10 minutes. When full the controller MUST refuse with error 7 rather than evict. Evicting the oldest entry is what makes a duplicate executable again.

No single client_id may hold more than half of MAX_CMD_DEDUP live entries. A client at its own ceiling is refused error 7; every other client is unaffected.

Without that bound the table is one pool and MAX_CLIENTS is 8, so a single enrolled client — a commissioning laptop in a retry loop, a cloud relay with a stuck queue — fills all 32 entries in ten minutes and every other client’s next command is refused error 7. That is one misbehaving client denying a stop request to all seven others, and P-081 already rejects the same shape for counters: shared per-device state livelocks the moment two clients are active.

Half rather than MAX_CMD_DEDUP / MAX_CLIENTS: a hard eighth is 4 entries, too thin for a commissioning session, and it strands 28 entries whenever one client is the only one on the site — which is most of the time. Half means the table can only be globally full when at least two clients are jointly filling it, and it costs nothing when only one is present. Refusing still beats evicting; it just refuses the client that filled its own share first.

P-124 — A Command whose (client_id, cmd_id) matches a live dedup entry but whose operation-hash does not MUST be answered Ack 0x88 outcome 2 rejected, with detail saying the cmd_id was reused for a different command. It MUST NOT be answered duplicate, and it MUST NOT execute.

That case is a client bug and the protocol should say so out loud rather than swallow it. Keyed on (client_id, cmd_id) alone the controller answers duplicate, which reads at the client as you already sent this, it was already acted on — so the client stops, satisfied, and the command it actually asked for was never executed and never refused. A silent no-op wearing the word for success is the worst answer available on a message type that starts engines. rejected with a detail a person can read sends the same client back to look at how it numbers its commands, which is where the fault is.

Executing it instead is not the alternative. The whole point of the window is that the controller cannot tell a reused id from a retry it half-heard, and the one thing it must not do is start a generator twice on a guess.

P-123inhibited and shadowed are outcomes, not errors. The selector is at Off, or the generator is running and not ours, or the behaviour is in shadow mode. Each is the controller declining with a reason a person can read.


Reserved. The message bodies are deferred — no OTA until a bootloader exists and an image has been verified on a bench — but the signing manifest, signature algorithm and key location are not deferred that far, because a bootloader cannot be retrofitted to a unit already in a cabin. See DEFERRED.md for the trigger.

What is settled and MUST NOT be relitigated when the bodies land:

P-130 — The comms processor delivers the controller image; the controller’s own bootloader verifies the signature. Letting the untrusted chip be the gatekeeper would undo every other authentication argument in this document.

P-131 — A comms image is verified twice: the controller authorises the release, and the comms processor’s own secure boot verifies the image signature before executing it. Step two is not redundant — a comms processor that is already compromised must still refuse an invalid image at its own boot, without help.


Error 0xFF bare — no session to key a MAC with
1: code u16 see REGISTRY
2: detail text <= 64 bytes
Error 0xFF wrapper under session_key, the bare body above inside
1: payload bstr
2: mac bstr16

Which of the two a sender uses is decided by P-142 and nothing else: wrapped when the sender holds a session for that session_id, bare when it does not.

The registry’s MAC’d column is not a second test for the same question. It is the receiver’s check — the list of codes a receiver refuses to read out of a bare body — so a bare Error carrying a code marked MAC’d is discarded under P-051 rather than acted on. Read as an instruction to the sender it becomes a rule that cannot be obeyed: the conditions where there is genuinely no session are exactly the ones where the sender has no key to honour it with. One column, one meaning, and the meaning is what a receiver will accept.

P-140 — See P-055. An unauthenticated error is a hint, never a fact.

P-141 — A request that reaches its handler MUST be answered by its own response type, carrying an outcome. Error 0xFF is for conditions that stop a request reaching a handler at all. Where a registry lists both an outcome and an error code for the same condition, the outcome is what is sent.

Two answers to one refusal is one implementer emitting an error while another implements the outcome as dead code, and the split is not cosmetic: an outcome rides inside a MAC’d response and most of these error codes do not, so the duplicate is also the forgeable one. An over-cap config write is SetConfigAck outcome 5 (P-090), a closed pairing window is Pair 0x8B outcome 2 (P-066), a failed pairing proof is Pair 0x8B outcome 3 (P-051), and a version mismatch is outcome 2 stale_version (P-100). Codes 13 and 15 are the error codes the first two replaced, and both are now withdrawn in REGISTRY.md — which is what this requirement looks like once it has been applied rather than only stated. Code 10 stays live because it still answers a wrapper MAC and a signed request; what it must not answer is a Pair.

P-142 — Which of the two shapes an Error takes is decided by whether the sender holds a session for that session_id — wrapped under session_key when it does, bare when it does not. A receiver MUST NOT decide by inspecting the body: it applies its own session state, and P-051 stands, so a bare body carrying a code the registry marks MAC’d is discarded rather than read. Letting the body choose is letting the comms processor strip the MAC off a refusal to hide it.

The two sides can disagree, and one code exists for exactly that: a client whose session the controller has already dropped gets a bare error 9 where it expected a wrapper. So a client that receives an Error on a session it believes is live, and that is not the wrapper or does not verify, MUST NOT act on its code and MUST NOT conclude anything about the site from it (P-055). It reconnects and sends a new Hello. It MUST NOT conclude that its earlier writes did not land — the log is what says that.

P-143 — A request whose type requires a session, arriving before any Hello has succeeded on that connection, MUST be refused with error 4. One arriving on a session_id the controller does not hold, or on one that has expired, MUST be refused with error 9. Both are bare under P-142, because in both the controller has no key to sign with. Two conditions the whole protocol turns on had a number in the registry and no rule pointing at it, which is two implementers picking differently for the same refusal.

A frame carrying a type this document does not allocate MUST be refused with error 2, bare. That is the third such condition and it was the last live code in the registry that no rule produced. Without a rule, one implementation answers error 1 and calls it malformed — it parsed perfectly, it simply says nothing — another drops the frame and answers nothing at all, and a client that hears nothing waits out its own timeout with an empty screen. Bare like error 1 beside it and for the same reason: the refusal happens before any session lookup, because type is what selects the handler that would have found the session, and there is no handler.

A link-local type on a client-facing transport is not this code. That is error 257 under P-020 — a routing bug in the comms processor rather than a client sending nonsense, and the two want different investigations.


May: frame and unframe, fragment and reassemble, route by session_id, route a session_id = 0, req_id = 0 error back on the connection the offending frame arrived on (P-025), stamp the connection handle into session_id on every inbound client frame, overwriting whatever the client sent (P-021), rate-limit, validate frame sizes, terminate TLS, hold a Wi-Fi association, advertise over BLE, serve the web UI’s static assets.

Stamping is the one envelope field it rewrites, and it is in this list rather than assumed: rewriting an envelope field is exactly the sort of thing the “shall not” list would otherwise forbid, and LINK.md depends on it in three places (L-003, L-012, L-062).

Shall not: decode a body, cache controller state, answer a request on the controller’s behalf, hold automation configuration, interpret command semantics, or fan out an event — it holds no key, so it cannot produce a valid copy.

WhereWhy
Wi-Fi credentialsCached on the comms processor, encrypted in its own NVS; controller holds the master copyIt must associate at boot without waiting for the controller
TLS certificates, cloud endpointComms processorTransport concerns
Connection routing tableComms processor, 8 rows (LINK.md L-060, L-061) — it owns the transports, not the bindingsTransport concern by definition
Device key, client keys, countersController only, never transmittedThe whole basis of authentication
Everything about the siteControllerIt is the thing that decides

An implementation is conforming when all of these pass.

An implementation claiming conformance is claiming it for UART, USB CDC and WebSocket. The BLE GATT and MQTT sections carry no vectors and are not conformance surface — see DEFERRED.md entry 7.

  1. Every vector in protocol/vectors/v1.json reproduces exactly.
  2. COBS round-trips every length from 0 to MAX_PAYLOAD, including 254 and 255, and matches the Cheshire & Baker examples — including the one a round trip cannot catch on its own, 254 bytes followed by a zero, where an encoder and a decoder that are wrong the same way agree with each other and with nobody else.
  3. Every single-bit flip in a framed message is caught by the CRC or the MAC.
  4. A truncation at every byte offset of every message decodes to an error, never a panic and never a partial accept.
  5. Random bytes fed to the resynchroniser for a million frames produce no panic, no allocation and no unbounded loop.
  6. A message with an unknown map key is accepted; a message with an unknown enum discriminant is rejected; a message carrying the same map key twice is rejected with error 1; and a Snapshot carrying one Value whose kind is in the vendor range alongside a known one is accepted, with that channel surfaced as unrecognised (P-019).
  7. Every table reaches its cap under load and refuses, and none evicts — except MAX_EVENT_QUEUE, which drops class B and closes the connection rather than dropping class A. MAX_AUTH_FAILURES is in that sweep too, and it is a counter rather than a table: the connection is closed at the cap, and a Goodbye and a fresh Hello part-way through the run does not reset the count.
  8. A replayed request is rejected by the counter; a replayed event is rejected by seq; a LogPage whose entries go backwards is accepted; a response moved to another req_id fails its MAC.
  9. Removing any single MAC check causes at least one test to fail loudly.
  10. An Error answering a request is matched to that request by its echoed req_id; an Error carrying session_id = 0, req_id = 0 is surfaced as a link diagnostic rather than dropped, and never completes a request.
  11. A Snapshot at MAX_CHANNELS with every Value at its widest — key 3 at the i32 limit, quality present, both at fields present — encodes, wraps, MACs and frames inside MAX_PAYLOAD, with the envelope at its full 11 bytes. This is the one cap the document got wrong once, and a test that builds the worst case rather than a typical one is what caught it.
  12. A five-element envelope is rejected with error 1 and nothing inside it is read (P-028), and a Hello 0x81 reporting max_channels above 32 is refused rather than clamped (P-006).
  13. A Command retried with identical operation bytes answers duplicate; the same cmd_id with different operation bytes inside the window answers rejected and does not execute (P-120, P-124). One test per direction, and the second one fails loudly if the operation-hash is dropped from the key.
  14. A session with an active subscription, fed events and answering nothing, expires on schedule (P-077). This is the test that fails if outbound traffic is allowed to refresh the timer, and it fails nowhere else.