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.
How to read this
Section titled “How to read this”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.
Limits
Section titled “Limits”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.
Fixed on the wire
Section titled “Fixed on the wire”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.
| Name | Value | Behaviour when reached |
|---|---|---|
MAX_PAYLOAD | 1024 bytes | Error 5, frame dropped |
MAX_FRAME | 1032 bytes | Frame dropped, resynchronise |
MAX_OPERATION | 960 bytes | Error 5 |
MAX_LOG_PAGE | 64 entries or 896 bytes | Page truncated, complete = false |
MAX_STRING | 64 bytes, UTF-8 | Error 1 |
MAX_DEPTH | 8 | Error 1 |
Reported by the controller
Section titled “Reported by the controller”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.
| Name | This controller | Hello 0x81 key | Behaviour when reached |
|---|---|---|---|
MAX_SESSIONS | 8 | 12 | Error 8, oldest is not evicted |
MAX_CHANNELS | 32 | 13 | Config write refused, SetConfigAck outcome 5 exceeds_cap |
MAX_CLIENTS | 8 | 14 | Pair outcome 4 table_full |
MAX_EVENT_QUEUE | 16 events per session | 15 | Class B dropped, oldest first, and counted; a class A event that cannot be queued closes the connection (P-098) |
MAX_INFLIGHT | 4 requests per session | 16 | Error 7 busy |
MAX_CMD_DEDUP | 32 entries, 10 minutes, no client above 16 | 17 | Error 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-006 — max_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.
Neither fixed nor reported
Section titled “Neither fixed nor reported”| Name | Value | Behaviour when reached |
|---|---|---|
MAX_CHALLENGES | 8 | Error 7 busy; nothing is evicted |
MAX_AUTH_FAILURES | 8 per connection in 60 seconds | Connection 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 COBSCOBS worst case 1026 + ceil(1026 / 254) = 1026 + 5 = 1031delimiter + 1 = 1032So 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 marginThe 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_PAYLOADThat 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 spareA 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.
Encoding
Section titled “Encoding”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
0xF000–0xFFFF, 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.
Envelope
Section titled “Envelope”[ 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-020 — type with the high bit set means response to a request. 0x04 is
the only unsolicited message. 0x60–0x7E and their 0xE0–0xFE 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-022 — req_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 diagnostic — the
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.
Framing
Section titled “Framing”UART — controller ↔ comms processor
Section titled “UART — controller ↔ comms processor”COBS( envelope | crc16 ) 0x00| Baud | 921600, 8N1, RTS/CTS hardware flow control |
| CRC | CRC-16/CCITT-FALSE — poly 0x1021, init 0xFFFF, no reflection, xorout 0x0000, check 0x29B1 |
| CRC covers | the CBOR-encoded envelope bytes exactly, first to last — computed before COBS, and covering neither the delimiter nor the CRC itself |
| CRC on the wire | little-endian, immediately after the envelope, and the two are COBS-encoded together |
| Delimiter | 0x00 |
| Incomplete frame timeout | 50 ms since the last byte → discard and resynchronise |
| RX buffer | 2 × 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.
WebSocket
Section titled “WebSocket”P-034 — One protocol message per binary WebSocket frame. Already framed, already ordered: no COBS, no CRC. Text frames MUST be rejected.
USB CDC
Section titled “USB CDC”P-035 — As UART, including COBS and the CRC, without the flow-control requirement.
BLE GATT
Section titled “BLE GATT”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-036 — flags bit 7 (0x80) is last; bits 6–0 are the fragment index,
0–127. 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-039 — msg_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 assemblies | one per connection |
| Assembly timeout | 5 s since the last fragment → discard |
| Duplicate or out-of-order index | discard the assembly, wait for the next index 0 |
msg_id differs from the assembly in progress | discard the assembly, wait for the next index 0 |
| Max reassembled | MAX_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.
| Topic | Direction |
|---|---|
km43/<device_id>/rx | to the controller |
km43/<device_id>/tx | from 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.
Cryptographic conventions
Section titled “Cryptographic conventions”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-041 — HMAC 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-042 — HKDF 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.
| Label | Kind | Used for |
|---|---|---|
km43/v1/pair-key | HKDF info | deriving the pairing key from the printed secret |
km43/v1/client-key | HKDF info | deriving a client’s long-term key |
km43/v1/session-key | HKDF info | deriving a session key |
km43/v1/pair-proof | MAC preimage | the client’s proof during pairing |
km43/v1/pair-ack | MAC preimage | the controller’s pairing response |
km43/v1/hello-proof | MAC preimage | the client’s proof during Hello |
km43/v1/req | MAC preimage | a signed request — one that carries a counter |
km43/v1/wrq | MAC preimage | a wrapper-authenticated request — read-only, no counter |
km43/v1/rsp | MAC preimage | a response |
km43/v1/evt | MAC preimage | an event |
Labels are ASCII, no trailing NUL.
Key derivation
Section titled “Key derivation”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-085 — epoch 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-086 — client_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-087 — Discover 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-088 — printed_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-044 — printed_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.
Message authentication
Section titled “Message authentication”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-046 — type MUST be inside every preimage, so a signed SetConfig cannot
be replayed as a signed Command.
P-047 — req_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-048 — operation and payload are byte strings carried on the wire,
and the MAC covers those literal bytes. A verifier MUST NOT re-encode before
verifying.
Authenticated bodies
Section titled “Authenticated bodies”P-050 — Every message listed in P-052 has a body that is exactly this wrapper:
1: payload bstr the CBOR-encoded inner body2: mac bstr16The 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:
| Messages | Label |
|---|---|
Requests 0x02, 0x03, 0x05, 0x06, 0x0C | km43/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 0x04 | km43/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-054 — 0x00/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-057 — Hello 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.
Sessions
Section titled “Sessions”Discover — 0x00 / 0x80
Section titled “Discover — 0x00 / 0x80”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.
Pair — 0x0B / 0x8B
Section titled “Pair — 0x0B / 0x8B”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-069 — client_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-058 — Pair 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 / 0x81
Section titled “Hello — 0x01 / 0x81”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 negotiableP-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-071 — client_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-074 — state_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 / 0x8C
Section titled “Goodbye — 0x0C / 0x8C”Goodbye 0x0C wrapper, empty inner bodyGoodbye 0x8C wrapper, empty inner bodyP-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.
Signed requests
Section titled “Signed requests”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 bstr16P-080 — The controller MUST take these steps in this order:
- Verify the MAC. A MAC that fails is P-051’s error 10.
- Check the counter. A counter that does not exceed the stored value MUST be refused with error 11.
- 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. - 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. - Execute.
- 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-082 — counter 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-083 — operation 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.
State and events
Section titled “State and events”Snapshot — 0x02 / 0x82
Section titled “Snapshot — 0x02 / 0x82”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 takenP-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-090 — values 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 / 0x83
Section titled “Subscribe — 0x03 / 0x83”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 boolP-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-104 — accepted_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-144 — seq 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_seq — it 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-095 — gap 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
Section titled “Event — 0x04”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.mdP-096 — seq 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 / 0x85
Section titled “ReadLog — 0x05 / 0x85”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 newestA 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.
Configuration
Section titled “Configuration”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 REGISTRYP-100 — expected_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 setP-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:
- 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.
- 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. - 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.
- Does not lift P-118’s rate limit. A
Time 0x0Arefused 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.
Commands
Section titled “Commands”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 bytesP-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 gets — duplicate 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-123 — inhibited 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.
Firmware
Section titled “Firmware”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.
Errors
Section titled “Errors”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 bstr16Which 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.
The comms processor boundary
Section titled “The comms processor boundary”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.
| Where | Why | |
|---|---|---|
| Wi-Fi credentials | Cached on the comms processor, encrypted in its own NVS; controller holds the master copy | It must associate at boot without waiting for the controller |
| TLS certificates, cloud endpoint | Comms processor | Transport concerns |
| Connection routing table | Comms processor, 8 rows (LINK.md L-060, L-061) — it owns the transports, not the bindings | Transport concern by definition |
| Device key, client keys, counters | Controller only, never transmitted | The whole basis of authentication |
| Everything about the site | Controller | It is the thing that decides |
Conformance
Section titled “Conformance”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.
- Every vector in
protocol/vectors/v1.jsonreproduces exactly. - 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. - Every single-bit flip in a framed message is caught by the CRC or the MAC.
- A truncation at every byte offset of every message decodes to an error, never a panic and never a partial accept.
- Random bytes fed to the resynchroniser for a million frames produce no panic, no allocation and no unbounded loop.
- 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
Snapshotcarrying oneValuewhosekindis in the vendor range alongside a known one is accepted, with that channel surfaced as unrecognised (P-019). - 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_FAILURESis in that sweep too, and it is a counter rather than a table: the connection is closed at the cap, and aGoodbyeand a freshHellopart-way through the run does not reset the count. - A replayed request is rejected by the counter; a replayed event is rejected by
seq; aLogPagewhose entries go backwards is accepted; a response moved to anotherreq_idfails its MAC. - Removing any single MAC check causes at least one test to fail loudly.
- An
Erroranswering a request is matched to that request by its echoedreq_id; anErrorcarryingsession_id = 0, req_id = 0is surfaced as a link diagnostic rather than dropped, and never completes a request. - A
SnapshotatMAX_CHANNELSwith everyValueat its widest — key 3 at thei32limit,qualitypresent, bothatfields present — encodes, wraps, MACs and frames insideMAX_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. - A five-element envelope is rejected with error 1 and nothing inside it is
read (P-028), and a
Hello 0x81reportingmax_channelsabove 32 is refused rather than clamped (P-006). - A
Commandretried with identical operation bytes answersduplicate; the samecmd_idwith different operation bytes inside the window answersrejectedand 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. - 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.