Skip to content

Protocol number registry

KM43 / numeric authority

Every message type, field, outcome, capability, metric, and event gets one permanent number here before an implementation is allowed to use it.

Source
protocol.toml and generated tables
Allocation rule
Reserve the number before writing code
Lifecycle
Live, reserved, withdrawn, or retired
Vendor range
0xF000–0xFFFF for metric and event kinds

A number is allocated here before it appears in any implementation. Two people picking 0x0503 independently produce two firmwares that disagree about what a generator is doing, and the disagreement is invisible until one of them is at a site. The collision must surface in the pull request that allocates the number, not on the wire.

Retired numbers stay in the table, marked retired, with the date and the reason. Deleting the row is how a number gets reused by somebody who never knew.

Reserved ranges. 0xF0000xFFFF is reserved for vendor and experimental use in the metric-kind and event-kind spaces, and will never be allocated here. A device may emit one, and a client shows that channel or that record as unrecognised and keeps the rest of the message.

Skip what you cannot name; reject what you cannot act on. Those two spaces are labels on data — not knowing a metric kind costs one row on a screen. Every other number in this file is a value something decides on, and there the rule is P-014 in PROTOCOL.md: an unrecognised discriminant is error 1, with no vendor range and no 0 = unknown to fall back to. P-019 is the carve-out by space and it names these two and no others; P-125 is the carve-out by position, and it is the one place a discriminant is not in a discriminant field — the three metric kinds below whose key 3 is an answer rather than a measurement.

SpaceAn unrecognised value is
Metric kinds, event kindsskipped — that one Value or Event is surfaced as unrecognised, the rest of the message stands
Qualityrejected — a reading whose trustworthiness cannot be named is not a reading
Generator state, generator selector, boot reasonskipped for that one channel — P-125, and it is the second carve-out: an answer nobody can name is not rendered, and the Snapshot around it stands
Command, SetConfig, Pair, Firmware and Time outcomesrejected — a client that cannot tell accepted from inhibited has learned nothing
Client kinds, time sources, config sections, error codesrejected

What separates the first row from the rest is the difference between the label on a quantity and the answer to a question. Generator state is the label, and a client that does not hold metric 0x0501 can say there is a channel here I cannot name and be honest. The generator is in state 6 is the answer, and a client that renders an answer it cannot read is the room card showing 0.0 °C all over again.

Status column, and the four are genuinely different things:

StatusThe number isMay it be emitted?
liveallocated and fully specifiedYes
reservedallocated; its body or argument schema is deferred, see DEFERRED.mdYes, where a normative rule requires it — but a receiver must not depend on the body’s contents, because the body is what is deferred
withdrawnallocated; the condition it named is answered somewhere else nowNo. Nothing emits it, and the number is not free
retiredwas used, and is gone foreverNo

reserved is not withdrawn, and conflating them was a real bug. An earlier revision defined reserved as “nothing may emit it”, which quietly forbade three things the specification requires: the records dropped event that explains a hole in seq, the time set event that records which source moved the clock, and the comms-processor lifecycle events LINK.md leans on. All three have allocated kinds whose bodies are deferred — the number is emittable, the schema is not yet written. A status that forbids a MUST is a status that is wrong.


The high bit means response to a request. Event is the only unsolicited message. 0x600x7E is the link-local range from LINK.md, whose responses are 0xE00xFE — the same high-bit rule, so nothing special has to be remembered. 0x7F is deliberately left out of the range, because 0x7F with the high bit set is 0xFF, which is Error: 31 requests against 31 responses is a rule that stays true, and a 32nd request whose response is somebody else’s opcode is a trap for whoever allocates last. A link-local type arriving on a client-facing transport is error 257, not error 2: it is a routing bug in the comms processor, not a client sending nonsense, and the two want different investigations.

The two auth columns are one column in disguise, and splitting them is the point. A request and its response are not authenticated the same way — a Snapshot request is wrq and its response is rsp, under different preimages — and a single column saying “session” covered both while agreeing with P-052 only by luck. These are the labels P-052 defines, and nothing else is a legal value.

RequestResponseNameAuth (request)Auth (response)SinceStatus
0x000x80Discovernonenone1.0live
0x010x81Helloproofrsp1.0live
0x020x82Snapshotwrqrsp1.0live
0x030x83Subscribewrqrsp1.0live
0x04Eventevt1.0live
0x050x85ReadLogwrqrsp1.0live
0x060x86GetConfigwrqrsp1.0reserved
0x070x87SetConfigsignedrsp1.0reserved
0x080x88Commandsignedrsp1.0reserved
0x090x89Firmwaresignedrsp1.0reserved
0x0A0x8ATimesignedrsp1.0live
0x0B0x8BPairpair_keypair_key1.0live
0x0C0x8CGoodbyewrqrsp1.0live
0x600x7E0xE00xFElink-local, see LINK.md1.0live
0xFFErrorrsp_or_bare1.0live

GetConfig and SetConfig are reserved rather than live because their envelopes are settled and their body is not: no section has a field list yet, so two implementations can exchange a config read and disagree about every byte inside it. The numbers are allocated; the schemas are deferred — see DEFERRED.md.

Pair authenticates under pair_key, which P-088 derives from printed_secret with the label km43/v1/pair-key. The printed secret is never an HMAC key itself: it can never be rotated, and the comms processor watches every pairing exchange, so keying it directly would hand the one component this protocol calls hostile an oracle under the one secret the device depends on.

Goodbye exists because a session table with no way to empty it locks every client out after eight reconnections inside the expiry window. It is not politeness; it is the difference between a browser refresh working and not.

Section titled “Goodbye exists because a session table with no way to empty it locks every client out after eight reconnections inside the expiry window. It is not politeness; it is the difference between a browser refresh working and not.”
CodeMeaningMAC’d?Status
1Malformed framenolive
2Unknown message typenolive
3Protocol major mismatchnolive
4Hello required firstnolive
5Payload too largenolive
6Unknown sectionyeslive
7Busy — retryyeslive
8Session table fullnolive
9Session expirednolive
10Bad MACnolive
11Counter not freshyeslive
12Unknown clientnolive
13Pairing window closednowithdrawn
14Stale challenge — reconnect and retrynolive
15Snapshot exceeds channel capyeswithdrawn
16Not permitted on this transportnowithdrawn
17Clock not setyeswithdrawn
256–511link-local, see LINK.mdnolive

13, 15, 16 and 17 are withdrawn, not live, because nothing produces them.

13 because P-066 answers a Pair arriving with no window open with Pair 0x8B outcome 2 window_closed, MAC’d under the printed secret. That is P-141 doing its job: 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.

15 because P-090 refuses an over-cap configuration at write time with SetConfigAck outcome 5 exceeds_cap. Nothing builds an over-cap Snapshot to refuse — the failure lands on the person editing config rather than on a client at 2 a.m.

16 because there is no per-transport permission policy yet — that is DEFERRED.md entry 4 — and the only signal a controller has about which transport a client arrived on is a field the comms processor writes, which is the one party the boundary exists to distrust.

17 because no rule in PROTOCOL.md or LINK.md raises it. A controller whose clock has never been set does not refuse anything: P-093 omits at, and P-092’s stale simply stops being expressible until the first Time write. There is nothing left for a refusal to say.

All four numbers are held so nobody else takes them. They are retired-adjacent rather than free — each one had a rule pointing at it once, and reusing a number somebody’s early implementation may already have emitted is the collision this file exists to stop. Each stays unemitted until a rule says what refuses what.

This table allocates a meaning. The rule that produces a code lives in PROTOCOL.md or LINK.md, and a condition those documents describe without naming a code here is a bug in them: two implementations will pick different numbers for the same refusal and neither is wrong.

The MAC’d column is the receiver’s rule, not the sender’s. It says which codes a receiver refuses to read out of a bare Error body — a bare error 6 or 11 is discarded, not acted on. What shape a sender emits is P-142’s question and is answered by whether that sender holds a session, never by looking a code up here. One column, one meaning, or the two tests disagree in exactly the case that matters: a session the controller has already dropped.

Link-local codes start at 256 so a client can tell the link failed from your request failed. Three of them reach a client on purpose: 257 (it sent a link-local type), 258 (it connected before the two firmwares had exchanged LinkUp) and 259 (its handle is gone, which is what a controller reboot looks like from the outside). All three mean reconnect, and none is MAC’d, so the rule below applies to them like any other unauthenticated error. The rest — 256 and 260264 — never leave the UART, and one of those in a client-facing frame is wrong on sight.

An unauthenticated error is a hint, never a fact. The comms processor can forge every no row above. A client may retry or reconnect on one; it must never render one as a statement about the site, and it must never conclude from error 9 that its own writes did not land — that is what the log is for.


Every metric carries a unit and a scale, and the value on the wire is an integer. There is no FPU on the target, and a scaled integer cannot disagree with itself the way a float rounded by two languages can.

value = wire_integer × 10^scale, in the stated unit.

A metric kind and an event kind can be the same number, and fourteen of the twenty event kinds are. 0x0604 is log ring utilisation here and time set in the event table; 0x0301 is ambient temperature here and behaviour decision there. Nothing on the wire is ambiguous — a metric kind appears only in Value.kind, an event kind only in Event.kind and LogEntry.kind, and no field takes both — so this is a trap for a person rather than for a decoder: somebody greps 0x0604, finds one row, and writes it into the wrong table. That is the exact mistake this file exists to catch, sitting inside the file itself.

The two spaces are not renumbered onto disjoint prefixes. It would buy a decoder nothing, cost half of both spaces, and retire twenty numbers that four documents and the vector file already name in prose — and a renumber applied here and not in LINK.md is precisely the collision the allocation rule exists to stop. What is required instead is a habit with teeth: prose naming one of these numbers says which space it is inmetric 0x0604, event 0x0604 — and a bare number in a sentence about kinds is a bug in that sentence.

P-019’s vendor range 0xF0000xFFFF spans both spaces on purpose. It is the same rule in each — surface that one carrier as unrecognised, keep the rest of the message — so it needs one range and not two.

KindNameUnitScaleStatus
0x0101battery voltageV−3live
0x0102battery current (+ charge, − discharge)A−3live
0x0103battery powerW−1live
0x0104state of charge%−1live
0x0105battery temperature°C−1live
0x0110PV array voltageV−3live
0x0111PV array currentA−3live
0x0112PV array powerW−1live
0x0120load currentA−3live
0x0121load powerW−1live
0x0130start battery voltageV−3live

Battery current is signed, and that is the whole point of it. A PZEM-017 reports current as unsigned and therefore cannot fill this slot at all — it cannot tell 40 A into the bank from 40 A out of it. A source that cannot distinguish direction does not implement this measurement, even if it exposes an ampere value.

KindNameUnitScaleStatus
0x0201AC voltageV−1live
0x0202AC currentA−3live
0x0203AC powerW−1live
0x0204AC frequencyHz−2live
0x0205AC energyWh0live

AC frequency is the discriminator that proves an engine caught. Current into the bank is also what the sun does; 120 V at 60 Hz is not.

KindNameUnitScaleStatus
0x0301ambient temperature°C−1live
0x0302probe temperature°C−1live
KindNameUnitScaleStatus
0x0401tank level%−1live
0x0402tank sender currentA−6live
KindNameUnitScaleStatus
0x0501generator state (enum, see below)0live
0x0502generator run hoursh−2live
0x0503generator starts since commissioningcount0live
0x0504run contact commanded (bool)0live
KindNameUnitScaleStatus
0x0601generator selector position (enum, see below)0live
0x0602uptime since boots0live
0x0603last boot reason (enum)0live
0x0604log ring utilisation%−1live

0x0501, 0x0601 and 0x0603 carry an answer in a Value’s key 3 where every other metric carries a measurement. P-125 is the rule that follows from that, and it is the one place in the protocol where a discriminant is not in a discriminant field: a value here that a client cannot name is surfaced as unrecognised for that one channel and never rendered as a state, while the Snapshot around it stands.

They were three sentences of prose under the tables above until a client had to decide what an unrecognised one means. A number a reader has to parse out of a sentence is a number the bindings cannot be generated from, which is the whole argument for this file.

ValueNameMeaning
1stoppedNo AC and our contact open. Autostart may fire
2startingOur contact closed, no AC yet. Gives up after the start window
3runningAC present with our contact closed. Ours, and ours to stop
4running, not oursAC present with our contact open — somebody started it with the fob. Not a fault, and never rendered as one
5stop not honouredOur command was withdrawn and the AC persists. Notify; never retry
6gave upCommanded to run and it did not, as many times as the starter is worth. The contact stays open and nothing here will try again until a person clears it — out of fuel, an oil-alert shutdown and a flat start battery are indistinguishable from the controller, and hammering a start line into any of them is wrong

The state model is two facts: is there AC, and is our contact closed. 4 is the row that matters: somebody started it with the fob, and closing our contact on top of that converts the fob owns it into both own it, after which the human’s stop button does not work.

ValueNameMeaning
1autoBehaviours may command the generator
2offNever command it, for any reason. A behaviour that needs it reports itself inhibited
3manualThe operator is driving. The controller monitors and logs only

Named for what it selects, because SelectorPosition on its own is a client rendering three positions of something without knowing what they govern. It is also a different thing from the lockout switch at the genset: that one is a service lockout — nothing cranks while somebody’s hands are in there — and this one is an operator override, at the controller.

ValueName
1power-on
2watchdog
3brown-out
4software reset
5panic

Five different situations, and a generator that was running through one of them is a sixth — the log record carries that, not this metric.


ValueNameMeaning
1measuredRead directly from an instrument
2countedIntegrated or accumulated, and trustworthy as a total
3estimatedDerived from something else, e.g. SoC from terminal voltage
4staleWas measured, is no longer current: older than its channel’s maximum age, or the same reading for so long that the instrument is presumed hung
5absentNo reading. The value key is omitted entirely

A behaviour that needs a counted figure refuses an estimated one. This is the founding rule of the project and the reason 5 exists rather than a zero.


This is a different space from the metric kinds above, and fourteen of the twenty numbers below are also a live metric kind. 0x0201 is generator state changed here and AC voltage there. Nothing decodes ambiguously — the two never meet in one field — but a sentence that says 0x0201 without saying which space it means is a sentence somebody implements backwards. Say event 0x0201. The argument for leaving the numbers overlapping is at the head of the metric table.

KindNameClassStatus
0x0101value changedBreserved
0x0102value quality changedAreserved
0x0201generator state changedAreserved
0x0202generator command withdrawnAreserved
0x0203generator stop not honouredAreserved
0x0301behaviour decision (shadow or applied)Areserved
0x0302behaviour inhibitedAreserved
0x0401output changedAreserved
0x0501alarm raisedAreserved
0x0502alarm clearedAreserved
0x0601bootAreserved
0x0602config changedAreserved
0x0603client enrolledAreserved
0x0604time setAreserved
0x0701records droppedAreserved
0x0702record failed CRCAreserved
0x0801comms link lostAreserved
0x0802comms power cycledAreserved
0x0803comms unrecoverableAreserved
0x0804sessions shed for backpressureAreserved

Every row is reserved, and the reason is the body map. The kind numbers and the classes are settled; not one kind says what is inside its body, so two implementations can agree on framing, on the MAC and on the sequence number and still disagree about every event they exchange. The numbers are allocated; the body schemas are deferred — see DEFERRED.md.

The Class column decides which records survive a controller under pressure, and the two words are defined here, normatively, rather than left to a design document. A column with no definition beside it is a column every implementation fills in for itself, and the two answers it picks between are keep this alarm and drop it. A controller that ranks alarm raised below value changed loses the alarm on the busiest day, which is the day it mattered.

Class A is never dropped — not from the log, and not from a queue. When the implementation’s bounded global write budget is exceeded on class A alone, the controller raises a diagnostic instead of shedding: a site producing that many state changes has something genuinely wrong with it, and the record of what went wrong is the last thing to throw away. When a session’s MAX_EVENT_QUEUE cannot take a class A event, P-098 in PROTOCOL.md closes that session and the client reconnects and catches up with ReadLog. That is worse than a delivered event and far better than a client sitting on a socket it believes is current while an alarm never reached it.

Class B is dropped first, under pressure, and every drop is counted. In the log it is what the write budget sheds; in a session’s outbound queue it goes oldest first, and the count reaches that session in its own records dropped (0x0701) record — a log-ring count cannot explain a hole one queue made. Counted is half the definition and not a detail: a hole in seq with no number attached is the mystery P-097 forbids a client to render as a complete stream.

Today class B is exactly one kind — value changed — and that is the whole of the split. It is the only kind whose rate follows a sampling loop rather than something happening at the site. Everything else in the table is a state change, a command outcome, an alarm, a boot, or a record about the log itself, and every one of those is a sentence somebody has to be able to find in April.

A new event kind is allocated with its class, or it is not allocated. A row added with the class left blank is a number on the wire before anybody has decided whether it may go missing.

Behaviour decision carries whether the decision was applied or shadowed. While every site runs in shadow, a client that cannot tell a shadowed decision from an applied one cannot audit the thing the shadow deployment exists to prove.

0x08xx is the link to the comms processor. LINK.md L-023 and L-110 through L-112 require the controller to log every one of these — the heartbeat ladder ends in a rail switched off — and had no numbers to log them under, which is how a number gets invented at a bench by whoever hit it first. Comms power cycled carries the cycle count, because three power cycles in an hour, rail left off is a sentence somebody has to be able to read at a client: a link that goes quiet leaving no record behind looks exactly like a site that is fine.

Sessions shed for backpressure is the newest of them, and it is allocated because LINK.md’s L-023 requires a class A record for the first session shed in an hour and had no kind to log it under. A comms processor that answers every heartbeat and drains the UART slowly closes all eight sessions without forging a byte or dropping a frame, and the shed record is the only trace it leaves — the link is up and carrying nothing is otherwise a state the untrusted peer can drive at will with nobody able to name it afterwards. It is class A for the same reason: it is the evidence, so it is the one record that must not be shed by the pressure it describes. Comms link lost (0x0801) stays the escalation at three in an hour; 0x0804 is the first occurrence.

A clock that jumps is not a fourth kind. LINK.md’s L-160 logs a step of more than five seconds in 0x0604 time set, carrying the old value alongside the new and the source that set it. One clock change is one record. Splitting it into a routine kind and a jumped kind gives a client two things to reconcile and gives whoever is asking did the schedule fire twice on Tuesday two places to look.


SectionNameStatus
0x0001identity and sitereserved
0x0002channelsreserved
0x0003buses and devicesreserved
0x0010generator behaviourreserved
0x0011frost behaviourreserved
0x0012schedule behaviourreserved
0x0013load-shed behaviourreserved
0x0020networkreserved
0x0021cloudreserved

Every row is reserved, and again it is the body. The section numbers are settled — a client can ask for 0x0011 and be told whether it exists — and no section has a field list, a key number, a unit or a scale for anything inside it. The numbers are allocated; the body schemas are deferred — see DEFERRED.md.

The network and cloud sections hold credentials — the site’s Wi-Fi passphrase among them, because the controller holds the master copy and LINK.md says why. One rule about those schemas is settled already and is not waiting on the field lists: a field marked secret is never returned by GetConfig, which the controller answers with a body and which every enrolled client can call, the cloud client included. DEFERRED.md entry 9 carries it. Anybody drafting a section body from this table alone would write the leak straight back in.

Every behaviour section carries a shadow flag. It is a flag rather than a subsystem, and it is readable by a client so that “nothing is actuating” is a thing a person can confirm rather than believe. Its key number arrives with the section schemas, and it must be the same number in all four behaviour sections: a shadow that means key 9 for frost and key 4 for the generator is a flag somebody reads wrong exactly once.


Reserved, not specified. No actuation until a controller is granted authority over an output — see DEFERRED.md. The space is allocated now so that nothing squats on it in the meantime.

KindNameStatus
0x0101start generatorreserved
0x0102stop generatorreserved
0x0103run exercise cycle nowreserved
0x0201set outputreserved
0x0202clear alarmreserved
0x0301clear overridereserved

Those six names are allocations, not specifications: none has an argument schema and none may be sent by a client yet. 0x80000xFFFF is where bench and experimental kinds go instead — permanently non-normative, never allocated in this file, and free to abandon. That is the whole of the squatting rule, and DEFERRED.md entry 8 says when a low number becomes sendable.

There is deliberately no factory-reset command and no unpair command. A compromised cloud that can unpair a controller which starts engines is worth more to an attacker than any single command. Both require the physical button, and that is the only way a full client table is emptied.


ValueNameMeaning
1acceptedActed on
2rejectedRefused, detail says why
3duplicateAlready seen this cmd_id; not acted on twice
4inhibitedThe selector is at Off, or the generator is not ours
5unauthorisedThis client may not do this
6shadowedWould have been accepted; nothing was actuated

4 and 6 are not failures. inhibited is the controller declining with a reason a person can read; shadowed is every decision a site makes while it runs in shadow.

5 is what the client capability mask below produces. A client whose mask does not carry send commands is refused here, inside a MAC’d response, rather than with an error the comms processor could have forged — P-141, applied.

ValueName
1accepted
2stale_version
3invalid
4unauthorised
5exceeds_cap

4 is the capability mask refusing one of two different things: a client that may not write configuration at all, or a client that may not write this section — a cloud client editing 0x0020 network. One outcome for both, because from the client’s side there is one thing to do about either, and the section it named is already echoed back in key 1.

ValueName
1enrolled
2window_closed
3bad_proof
4table_full
5reclaimed

5 is what stops the client table being a consumable, and the rule that produces it is P-078 in PROTOCOL.md, not this file. Which Pair reuses an occupied row, that the label comparison is over the exact UTF-8 bytes that entered the pair-proof preimage, that matching runs before allocation so P-067’s table_full means eight distinct labels rather than eight pairings, that a reclaim is not a revocation, and why setting the counter back to 0 does not re-open replay — all of it is there, with the arithmetic that says why eight cumulative re-pairings is a season and not a lifetime. What this file allocates is the number.

Outcome 5 rather than outcome 1 is the part that belongs here, because it is the allocation decision: a reclaim silently reported as an enrolment is a row overwritten with nobody told. The person at the panel needs to read this replaced the row called kitchen phone, and the log record needs to say the same — see DEFERRED.md entry 10.

ValueName
1accepted
2rejected
3unauthorised
4needs_button

3 exists because the capability mask can refuse a Time write, and rejected would have told a client its clock was implausible when the real answer is that it is not allowed to set one. Moving the clock moves schedule, exercise and quiet_hours with it, so who may and is this a sane time are two questions and a client that cannot tell them apart retries forever against the wrong one.

4 splits the same hair one more time. A backward set refused for P-114’s floor is refused for a reason a person can act on — hold the button at the panel and send it again — and under 2 it was indistinguishable from a time nobody could believe. A client that cannot tell those apart puts the controller says that time is implausible on the screen while the correction somebody drove four hours to make sits one gesture away.

ValueNameMeaning
1clientA signed Time write from an enrolled client
2ntp-via-commsA TimeOffer the controller accepted from the comms processor

One space for what gets recorded. The Time operation carries a value from this table and P-111’s time set event records one, and there is no second space either of them may draw from. An accepted TimeOffer from LINK.md is recorded as 2 (L-162). That offer carries a source field of its own — a link-local enum with one value in it — and its number is never copied through: 1 there means NTP, 1 here means a client set the clock, which is the opposite. The point of recording a source at all is to tell a drifted RTC from a lying uplink, so a number copied straight across inverts the one thing the field exists to say.

Reserved — see DEFERRED.md.

ValueNameStatus
1acceptedreserved
2bad_offsetreserved
3bad_signaturereserved
4wrong_targetreserved
5too_largereserved
6no_transfer_in_progressreserved
7not_ownerreserved
8unauthorisedreserved

7 and 8 are different refusals and were one word covering both. 7 not_owner is about a transfer: a Firmware operation naming a transfer that another client began. One image lands at a time, and a second client feeding chunks into somebody else’s transfer is how a half-and-half image gets written and then fails its signature after the reboot. 8 unauthorised is about the client: its capability mask does not carry push firmware, and no transfer of its own would have been accepted either. A client told not_owner retries when the other transfer finishes, which is right for one and a loop for the other.

Nothing produces 7 yet, and that is a gap in the message bodies rather than in this table. No document says how a transfer records which client owns it, so there is nothing an implementation could compare a second client against. It is owed by DEFERRED.md entry 6 along with the rest of the Firmware field list, and it is named there so the number does not sit here looking implemented.


ValueName
1app
2browser
3cloud
4cli

A per-client bitfield, fixed at enrolment from client_kind and stored in FRAM beside that client’s counter. P-105 in PROTOCOL.md is the rule — where the mask comes from, why client_kind is a sound input where transport is not, that no message raises or lowers one, and what each refusal is answered with. This file allocates the bits and the row each client_kind is handed.

It is not a wire discriminant — P-014 does not reach it, and an unallocated bit is a capability nobody has defined yet, not a value to reject. It is allocated here because two firmwares that disagree about what bit 4 means disagree about whether the cloud can push firmware, which is the same collision as two people picking 0x0503 and worse in its consequences.

BitThe client mayappbrowsercloudcli
0write configuration at allyesyesyesyes
1write the network (0x0020) and cloud (0x0021) sectionsyesyesnoyes
2send Command 0x08yesyesyesyes
3set the clock with Time 0x0Ayesyesnoyes
4push firmware with Firmware 0x09yesyesnoyes
5–15unallocated

Allocated in LINK.md, which is where the rule for each one lives; L-180 is the rule for the third column. They are listed here because this file is where a number is handed out, and a space allocated somewhere else is a space two people can allocate from.

Three of them reach a client on purpose and six never do. That distinction is the reason they are a separate space from the client error codes rather than a continuation of them: a client that cannot tell the link failed from your request failed retries against the wrong thing.

CodeMeaningReaches a client?
256Unknown link-local opcode, or one sent from the wrong sideno
257Link-local type on a client transportyes
258Client frame before LinkUp completedyes
259Unknown connection handleyes
260Connection table fullno
261Link protocol major mismatchno
262Too many outstanding link-local requestsno
263Link-local type with a non-zero sessionno
264No authorisation matches this imageno

Bit 1 is only consulted when bit 0 is set: a client that may not write configuration cannot write two particular sections of it either.

Which response answers which denial, because those are outcome numbers and outcome numbers live here. P-105 is what requires every one of them to ride inside the MAC’d response rather than inside an Error the comms processor could have forged:

RefusedAnswer
SetConfig, bit 0 clear — or bit 1 clear on 0x0020/0x0021SetConfigAck 0x87 outcome 4 unauthorised
Command, bit 2 clearAck 0x88 outcome 5 unauthorised
Time, bit 3 clearTimeAck 0x8A outcome 3 unauthorised
Firmware, bit 4 clearFirmware 0x89 outcome 8 unauthorised

A client kind added to the table above arrives with its row here in the same commit — P-105 refuses enrolment of a kind with no row, and this is the allocation half of the same rule. A kind with no mask row is a client whose permissions are whatever the implementer’s zero value happens to be, and a default that opens is the one direction this project never defaults.

A cloud client keeps bit 2, and that is deliberate. Telling a generator to stop from somewhere that is not the site is the whole point of having a cloud at all, and every command is MAC’d end to end regardless — a compromised relay cannot forge one, it can only refuse to carry it. What it does not need is the ability to re-flash the controller, move the clock under a schedule, or read back the credentials it forwards. Command authority is separately gated in any case: no kind below 0x8000 is sendable by anybody yet (DEFERRED.md entry 8).

The failure the cloud column closes is that Command 5 unauthorised, SetConfig 4 unauthorised and Firmware 8 were a vocabulary of refusals with nothing behind them — allocated in this file, produced by nothing, and reading as implemented to everybody who grepped for them. P-105 says what that left the most exposed client in the product able to do; this table is where it stops.

The mask granted at enrolment belongs in the client enrolled record (event 0x0603) when that body schema is written — DEFERRED.md entry 10. Somebody was enrolled and somebody was enrolled and may push firmware are different sentences, and only one of them is an audit record.

Returned in the Hello response. A bit set means the controller can do the thing; a client must not assume an unset bit is a failure, only an absence.

BitMeaningStatus
0event log readablelive
1configuration writablelive
2commands acceptedreserved
3firmware update acceptedreserved
4clock settable by clientlive
5counted state of charge availablelive
6AC metering on the generatorlive
7any behaviour is in shadow modelive
8–31unallocated

Bit 7 is deliberately coarse. A client that wants to know which behaviour is shadowed reads the config sections; the bit exists so an app can put a banner up without four round trips.

Bits 1, 2, 3 and 4 are answered per calling session, not per device: the controller reports its own capability and the calling client’s mask. A cloud client sees firmware update accepted and clock settable by client clear, and does not draw a button that can only ever be refused. No new field carries this, because the Hello response is bound to exactly one client_id by construction — the field that can say it is already in the right place.

Bit 1 is coarser than the mask and stays that way. A cloud client may write configuration and may not write two sections of it, so it reads bit 1 set and is still refused with outcome 4 on the network section. A bit here is a claim about a message type, not about every section that message can name.


None yet. When the first one lands it stays here forever, with the date and the reason, because the alternative is somebody reusing it in three years.