Proposed topology design
KM43 / proposal
A candidate answer to DEFERRED entry 13. Proposed, costed against the six fixtures, and not yet normative.
Status: proposal. Nothing here is settled. It is written in PROTOCOL.md’s shape so it can be argued with in the form it would ship in, and so the byte counts can be checked rather than believed. The settled protocol is PROTOCOL.md; the evidence is COVERAGE-RESEARCH.md; the gap it answers is DEFERRED entry 13.
It came from four independent designs — Three Tables and a Revision, Cached topology, packed samples, Slotted Profiles and Nodepath — each costed against all six fixtures and judged on three lenses. This is the synthesis, not a vote. An adversarial review then raised sixty findings against it, fifty-two of which survived a reader whose job was to refute them; where each of those landed is in What the review changed.
The shape and why it won
Section titled “The shape and why it won”The shape. Everything a client needs to interpret a number is a descriptor, fetched once, paged, authenticated, and stamped with one rev. Everything that is a number is a reading, selected by the client, referencing a descriptor by sig. Two planes, one revision joining them, and nothing else.
controller (dev 0) → bus → physical device → component → signal → element- bus is a physical port. Bus health is counter signals on the controller’s own device row, not a special case.
- device is one serial-numbered enclosure.
parentlinks a sub-device to the product that contains it, so a modular all-in-one is one identity with modules under it — not several fake instruments, not one flatDeviceKind. - component is where every instance number lives:
role+ 1-basedindexwithin(dev, parent, role).cmp = 0is reserved everywhere it appears and names the device as a whole. - signal is a quantity on a device, at a component or at the device as a whole.
- element is a position inside a series signal, addressable in a
Concernbysig+elem.
Why this spine won. Two of the three judges ranked Cached topology, packed samples first, and both did it for the same two fields, which no other design had together:
rollup separates containment from accounting — my values are already inside that component’s values. It is one pointer, so it recurses for free: fixture 1’s four trackers roll up to the PV array; fixture 5’s two 240 V legs roll up to the merged circuit and the merged circuit rolls up to the meter. Slotted Profiles had a two-valued agg flag and physically could not encode fixture 5, where one component must be a member and an aggregate at once. Nodepath’s structural idx 63 AGGREGATE swept the merged parent and both legs into one total and rendered a confidently wrong panel. Three Tables made containment mean aggregation, and its own fixture 1 then laid the aggregate and the trackers out as siblings.
esp names which enum space an enum-shaped value is drawn from, in a field. That is what retires P-125: a client learns a value is a charge stage from the descriptor instead of from a hardcoded list of three metric kinds. A vtype of 3 enum on its own says that it is an enum and never which, which puts the client straight back on the hardcoded map.
Plus: presence as a hot signal against membership as a cold descriptor (fixture 6 in one sentence — liveness is cheap, inventory is rare); and the (rev, digest) pair in Hello, which is the only mechanism in any of the four that can catch a controller changing a descriptor row without moving its revision.
Grafted from Three Tables and a Revision (2nd on every lens, 1st on bounds discipline): the whole bounds method. Two arms per page — rows or bytes, whichever binds first — each cap sitting under a derived ceiling with a stated margin and an assertion in limits.rs, in exactly the shape MAX_LOG_PAGE_BYTES sits under its 964 of headroom and MAX_OPERATION under MAX_OPERATION_CEILING. Also its paging discipline: rows in ascending id, a row never split, the cursor living entirely in the request so the controller holds zero resumption state.
Grafted from Slotted Profiles (1st on the house-rules lens): Concern key elem, which is the only way any design could say cell 23 of pack 2 is over voltage. Per-element validity, which the winner had only per-series — one open sense wire must not blank fifteen good cells. cmds on a component row, so a client draws exactly the buttons that component accepts instead of aiming a Command and learning from the Ack. A raw vendor state that cannot be mistaken for a normalized one. And its flash argument, which was the only one in four designs: the row encoder is one table-driven loop, not a codec per row kind, because generics monomorphise and the budget on this part is ~256 KB of image, not 144 KB of RAM.
Grafted from Nodepath (4th, one very good idea): DeviceRow.since — the rev at which the physical instrument behind a row last changed — and the rule that History stops at a since boundary rather than plotting a smooth line across two different chargers. That is the only explicit answer anywhere to DEFERRED entry 1’s same quantity, new hardware case, and it costs nothing on the live path. Also its monotonic age applied to concerns as well as samples, which the winner left as a wall-clock since that a controller with no clock cannot fill.
Two things the winner had that this deliberately does not take.
The packed bstr of positional records is dropped. The constraint was “keep CBOR with integer keys unless you have measured evidence against it,” and there is no measurement — there is a projection. DEFERRED entry 3 has already settled the argument in this repo’s own words: a positional encoding is right on flash, where both ends are the same firmware and the class byte versions the record, and disqualifying on the wire, where a fielded controller meets a newer app and neither can be updated first. Writing it anyway would make two documents disagree about one decision. The CBOR encoding below costs about 2× the bytes of the blob and buys back the property that a Readings response can be read in a generic hex dump at 2 a.m. and, more usefully, that it parses without the cache — scalars and series are two separate arrays, so no key’s type depends on a descriptor fetched under a different message.
The reading push is dropped. The winner added an unsolicited Samples message with its own MAC label, per-session selector and interval state, changed-only publication state, and a back-pressure rule — and its own admission test counted signals rather than bytes, so a legal subscription committed the controller to 40 frames every half second. A client polls what it renders. Subscribe 0x03 is untouched and stays the event stream.
What is polled and what is pushed, in one sentence each. A number that moves continuously is polled: Readings is the only way to learn what a value is. A change of state — a validity transition, a concern, a presence change, a revision bump — is pushed as a class A event and is applied directly, not treated as a hint to poll (P-152, P-182). Two implementers read the earlier draft’s two adjacent sentences two ways, and one of those two clients was silently wrong.
The wire
Section titled “The wire”Retired: Snapshot — 0x02 / 0x82
Section titled “Retired: Snapshot — 0x02 / 0x82”Both numbers are retired, not reused. The space has 83 free request opcodes; scarcity is not a reason, and a bench build still sending the old body should meet error 2 rather than a body it misparses into plausible readings.
Inventory — 0x0D / 0x8D
Section titled “Inventory — 0x0D / 0x8D”The cold plane. Buses, devices, components, signals and parameters are five row kinds in one paged message, because they are all descriptors and they all move together under one rev.
Two conventions apply to every body below, and both are load-bearing. P-015 makes every key REQUIRED unless it is marked optional, and a missing required key is error 1 — so a key that is present only under a condition is marked optional and carries the condition, because marking it neither way makes a conforming decoder refuse every row that legitimately omits it. And bus, dev, cmp, sig, pid and cid are each one flat space across the whole controller, not per-device numbering: rollup names a component of another device with a bare u16, a Sel names a cmp with no dev beside it, and P-173’s canonical order is id ascending within a kind, none of which has an answer under per-device ids.
ReadInventory 0x0D wrapper 1: rev u32 the revision the client is assembling; 0 on the first call 2: what u8 1 buses · 2 devices · 3 components · 4 signals · 5 parameters 3: from u16 first row id to include, inclusive (P-029); 0 means from the beginning 4: dev u16 optional; what = 5 only, parameters of this device
Inventory 0x8D wrapper under session_key 1: rev u32 the controller's current revision 2: what u8 echoed, so the response is self-describing 3: rows [ BusRow | DeviceRow | ComponentRow | SignalRow | ParamRow ] empty unless outcome is 1 4: next u16 the id to pass as `from` for the next page; 0 when this page ends the kind 5: total u16 rows this request resolves to at this rev — of this kind, and of this `dev` when key 4 of the request set one 6: outcome u8 1 ok · 2 superseded · 3 unknown_kind · 4 out_of_range 7: digest bstr8 optional; present iff outcome is 1 and key 4 is 0 — the topology digest, computed exactly as P-173 writes itOn any outcome other than 1, next is 0 and digest is absent (P-190). Without that rule next = 0 is the natural encoding of nothing follows, which under key 7’s condition makes the whole-topology digest mandatory on a response that answered nothing — and on outcome 4 the rev matches, so neither P-151 nor P-152 fires and a client can stamp a cache it never assembled.
BusRow 1: bus u8 1..MAX_BUSES; 0 is the controller's own local I/O 2: transport u8 1 rs485 · 2 can · 3 ve_direct · 4 local_io · 5 ip · 6 onewire · 7 internal 3: label text optional; ≤ MAX_LABEL 4: rate u32 optional; baud or bitrate
DeviceRow 1: dev u16 1..; 0 is the Origin 89 controller itself 2: bus u8 3: addr bstr optional; ≤ MAX_ADDR, as the bus defines an address. REQUIRED on an addressed transport, absent otherwise, unique within a bus (P-189) 4: product u16 product registry; 0xF000–0xFFFF vendor, skip-unknown 5: dialect u16 driver dialect registry; vendor range, skip-unknown 6: role u16 device role registry; vendor range, skip-unknown 7: parent u16 optional; the device this is a sub-device of. Absent = attached directly to its bus 8: serial text optional; ≤ MAX_IDENT 9: hw text optional; ≤ MAX_IDENT 10: fw text optional; ≤ MAX_IDENT 11: label text optional; ≤ MAX_LABEL 12: since u32 the rev at which this row's physical identity last changed 13: cmds [ u16 ] optional; ≤ MAX_COMPONENT_CMDS Command kinds legal at this device's own scope, cmp = 0. Absent = none (P-175)
ComponentRow 1: cmp u16 1..; 0 is reserved and MUST NOT be allocated — it names the device as a whole (P-174) 2: dev u16 3: parent u16 optional; owning component. Absent = the device is the parent 4: role u16 component role registry; vendor range, skip-unknown 5: index u16 optional; 1-based instance within (dev, parent, role). Absent = the only one of its role there. Never 0 6: rollup u16 optional; the component whose signals already account for mine. May name a component of an ancestor device (P-162) 7: label text optional; ≤ MAX_LABEL 8: cmds [ u16 ] optional; ≤ MAX_COMPONENT_CMDS Command kinds legal here. Absent = this component accepts none (P-175) 9: since u32 the rev at which this row's meaning last changed (P-156)
SignalRow 1: sig u16 1..; 0 is reserved (P-174) 2: dev u16 REQUIRED 3: cmp u16 REQUIRED; 0 = the device as a whole (P-174) 4: kind u16 metric kind registry; 0xF000–0xFFFF vendor 5: shape u8 the container: 1 scalar · 2 series 6: vtype u8 what one value is: 1 gauge · 2 counter · 3 enum · 4 flags 7: domain u8 1 live · 2 lifetime · 3 since_reset · 4 today · 5 yesterday · 6 limit_upper · 7 limit_lower · 8 max · 9 min 8: point u16 optional; measurement point registry; vendor range, skip-unknown 9: dir u8 optional; 1 positive is in · 2 positive is out · 3 magnitude only 10: n u8 optional; REQUIRED iff shape is 2, absent otherwise. Series length, 2..MAX_SERIES_LEN 11: erole u16 optional; shape 2 only; the component role each element would have if it were a component 12: ebase u16 optional; shape 2 only; the label of element 0. Element k's label is `ebase` + k. Default 1 (P-178) 13: esp u16 optional; enum space registry. REQUIRED iff vtype is 3 or 4, absent otherwise 14: unit u8 optional; unit registry. REQUIRED iff key 4 is in the vendor range, absent otherwise (P-160) 15: scale i8 optional; same condition as key 14 (P-160) 16: vns u16 optional; vendor namespace. REQUIRED iff key 4 is in the vendor range, absent otherwise 17: hist u8 optional; present iff this signal has history — the finest bucket it is stored at. The space runs coarse to fine (1 day · 2 hour · 3 quarter_hour), so a request for a finer value is outcome 6 and a coarser one is aggregated from this by P-195. MUST be absent when vtype is 3 or 4. Absent = no history, and ReadHistory answers outcome 5 18: label text optional; ≤ MAX_LABEL
ParamRow 1: pid u16 1..; 0 is reserved (P-174) 2: dev u16 3: cmp u16 REQUIRED; 0 = the device as a whole (P-174) 4: kind u16 metric kind registry 5: v int optional; i32 (P-185). Absent = never read 6: vtype u8 REQUIRED; 1 gauge · 2 counter · 3 enum · 4 flags, as SignalRow key 6. A parameter has no `shape`: it is always one value 7: domain u8 as SignalRow key 7 8: point u16 optional; as SignalRow key 8 9: dir u8 optional; as SignalRow key 9 10: esp u16 optional; enum space registry. REQUIRED iff key 6 is 3 or 4, absent otherwise 11: lo int optional; i32; the device's permitted floor 12: hi int optional; i32; the device's permitted ceiling 13: via u16 optional; the Command kind that writes it. Absent = read-only 14: unit u8 optional; REQUIRED iff key 4 is in the vendor range, absent otherwise (P-160) 15: scale i8 optional; same condition as key 14 (P-160) 16: vns u16 optional; same condition as key 14 17: label text optional; ≤ MAX_LABELA parameter is what a downstream device says about itself and does not change with the weather — a nameplate rating, an input current limit, a permitted floor and ceiling. Origin 89’s own configuration stays entirely in GetConfig/SetConfig. A derated limit — a BMS’s present CCL, a charger derating in heat — is an ordinary signal with domain = 6 limit_upper and prov = 5 reported, because it moves. That is the whole of the separation: a parameter changes when the hardware changes, so it belongs to a descriptor under rev.
A ParamRow carries no validity, and that is deliberate. An earlier draft gave it the Sample pair v/q, which put a moving value inside the revisioned, digested plane: marking a pulled module’s parameters absent without bumping makes the digest diverge under a fixed rev and fires P-151’s controller-fault MUST against a correct controller, while bumping discards every client’s cache twice per bay flap. Here v is present when the controller has read it and absent when it has not, a change to v is a topology change and bumps rev by P-150, and whether the instrument is reachable at all is presence — a signal, on the hot plane, where it belongs. Nothing that moves lives in this row.
A parameter write therefore costs every client its cached inventory, and that is accepted rather than overlooked. A via write is a person at a laptop changing an absorption voltage, on the order of once per commissioning; a rev bump is the honest signal that a descriptor changed, and P-154 already limits how often one can land. The alternative was to carve keys 5, 11 and 12 out of P-173’s preimage, which would make the digest cover most of a row — and a hash with an exception list is a hash nobody can reproduce from the specification.
Key 6 exists because a parameter is not always a number. A charger’s battery type, its charge algorithm, a relay’s configured function — these are settings drawn from a vendor’s enumeration, and a ParamRow with no vtype and no esp can only render one as a bare integer, which is the failure P-164 exists against, one plane over. There is no shape on this row because a parameter is one value: a device that reports sixteen of something reports a series signal, not sixteen parameters.
Keys 7, 8 and 9 are what stop four rows all called DC voltage. A charger’s absorption, float, equalize and maximum-regulation voltages are one kind on one component; without a discriminator an installer reads four identical rows and picks one. P-188 makes (dev, cmp, kind, shape, vtype, domain, point, dir) unique within a rev for signals and (dev, cmp, kind, vtype, domain, point, dir) unique for parameters, refused at registration the way rollup cycles are. Note what that uniqueness does and does not do here: it is what stops one row being published twice, not what tells these four apart. Keys 7, 8 and 9 are what tell them apart, and dropping one from the tuple does not produce four indistinguishable rows — it produces one row and three refusals.
Readings — 0x0E / 0x8E
Section titled “Readings — 0x0E / 0x8E”ReadSignals 0x0E wrapper 1: rev u32 the revision the client's cache holds 2: sel [ Sel ] optional; ≤ MAX_SELECTORS. Absent means every signal 3: from u16 first `sig` of the resolved selection to include, inclusive; 0 means from the beginning
Sel 1: dev u16 optional; a device and, transitively, its sub-devices 2: cmp u16 optional; a component and, transitively, its children. 0 is not a selector — error 1; use key 1 for device scope 3: sig u16 optional exactly one of the three; none or two is error 1
Readings 0x8E wrapper under session_key 1: seq u64 log position these readings reflect 2: rev u32 the controller's current revision 3: at u64 optional; omitted when the clock has never been set 4: s [ Sample ] optional; every signal whose shape is 1 scalar 5: v [ Series ] optional; every signal whose shape is 2 series 6: next u16 the `sig` to pass as `from` for the next page; 0 when this page ends the selection 7: total u16 signals the selection resolved to 8: outcome u8 1 ok · 2 superseded · 3 unknown_selector · 4 out_of_range
Sample 1: sig u16 2: v int optional; i32 (P-185). Present iff validity is 1 or 2 3: q u8 validity<<4 | provenance 4: age u32 optional; seconds on P-004's tick. REQUIRED when validity is 2 stale
Series 1: sig u16 2: q bstr exactly `n` bytes; byte k is element k's validity<<4 | provenance 3: v [ int ] exactly as many integers as key 2 has bytes whose validity is 1 or 2, in ascending element order. Each i32 4: age u32 optional; seconds. REQUIRED when any element's validity is 2, and it is the age of the **oldest** such element (P-179)The cursor is a sig, not an ordinal. P-153 makes the controller hold zero resumption state, so it reproduces the selection from scratch on every page — and only a normative order makes that reproducible. P-183 fixes it: the resolved selection is the set union of the selectors, deduplicated by sig, in ascending sig, and a page is a prefix of that order. An ordinal into an order nobody wrote is how a client resumes at 40, receives a set that overlaps what it holds and omits signals it will never see, with next and total reconciling perfectly and the MAC verifying. A sig cursor is self-checking: a client can watch the ids ascend.
Scalars and series are two arrays on purpose. A key whose CBOR type depends on a shape that lives in a different message, fetched under a different rev, is a response a client cannot parse until its cache is warm — and this crate’s reader is typed per call, so a wrong guess discards a MAC-verified body whole. Split, a Readings decodes with no descriptor at all; only its meaning needs one.
Validity — is this number usable now?
| 1 | ok | current |
| 2 | stale | was good; v is the last known, age says how old |
| 3 | initialising | the source is up and has not produced a first reading |
| 4 | unsupported | this device does not implement it — including a vendor’s in-band absence pattern (P-176) |
| 5 | sensor_fault | the source reports it bad, or the reply did not arrive intact — open, short, reversed, bad CRC |
| 6 | out_of_range | a number arrived and was refused: impossible for this installation, or outside the i32 range at the registry’s scale (P-185) |
| 7 | absent | the component or device is not present |
| 8 | unnamed_state | the source reported an operating state we have no value for |
Provenance — where did the number come from?
| 0 | — | there is no number |
| 1 | measured | read directly from an instrument |
| 2 | counted | integrated or accumulated by the source, trustworthy as a total |
| 3 | derived | this controller computed it from other signals |
| 4 | estimated | inferred from a proxy — state of charge from terminal voltage |
| 5 | reported | the device states it — a limit, a setpoint it says it is honouring |
| 6 | commanded | what was asked for, never what was observed |
counted and stale is 0x22, which is the case the single quality byte could not express and the reason the ledger names it. estimated splits from reported, which the old word ran together at two different trust levels. Provenance rides on the sample rather than the descriptor: a BMS reports state of charge as counted after a full charge and estimated after a reset, and that is not a topology change.
4, 5 and 6 are three findings, and the driver currently has two words for them. map.rs gives a register cell an Absent pattern — AllOnes, or the one raw value the vendor documents — and driver.rs answers Reading::NotOnThisUnit when it matches, which is 4 unsupported: the ordinary shape of a model that omits a register, and not a fault. Everything else that fails becomes Reading::Unreadable, and that one word covers two different sentences. The reply was shorter than the block is a dialect or a bus somebody has to go and look at — 5 sensor_fault. The number arrived and will not fit the registry’s scale is a healthy bus carrying a value we refuse to publish — 6 out_of_range, under P-185, which forbids clamping it into range for the same reason P-176 forbids passing a sentinel through.
So Reading::Unreadable has to split before step 5 can encode the difference. That is a real edit to map.rs and driver.rs and it is named here rather than discovered there, because the two collapse into one write_absent call today and a broken bus and a scale that is one decimal place wrong currently read identically at a client.
Concerns — 0x0F / 0x8F
Section titled “Concerns — 0x0F / 0x8F”Snapshotable active state, separate from the transition events entry 10 owns.
ReadConcerns 0x0F wrapper 1: rev u32 2: from u16 cid to resume from; 0 means from the beginning
Concerns 0x8F wrapper under session_key 1: rev u32 2: seq u64 log position this list reflects 3: c [ Concern ] 4: next u16 cid to resume from; 0 when complete 5: total u16 active concerns the table holds 6: refused u16 concerns the table could not hold since boot 7: outcome u8 1 ok · 2 superseded · 3 out_of_range
Concern 1: cid u16 1..; 0 is reserved (P-174). Stable while active, released only after the 0x0502 announcing state 5 is committed (P-180) 2: dev u16 3: cmp u16 REQUIRED; 0 = the device as a whole (P-174) 4: sig u16 optional; the signal it is about 5: elem u8 optional; 1-based **position** in a series signal, 1..n. The label a client renders is `ebase` + `elem` − 1 (P-178) 6: cond u16 normalized condition registry; vendor range, skip-unknown 7: sev u8 1 info · 2 warning · 3 fault · 4 protection 8: state u8 1 active · 2 active_acked · 3 latched_cleared · 4 clearing_blocked · 5 cleared 9: age u32 seconds since first observed, on P-004's tick, carried across a restart (P-181). Always present 10: since u64 optional; controller time first observed, omitted when the clock has never been set 11: raw u32 optional; the source's own code, preserved verbatim 12: vns u16 optional; vendor namespace. REQUIRED when key 11 is present, absent otherwise 13: seq u64 the log seq of the `concern raised` record that opened itKey 9 is always present and key 10 is not, for the same reason age beats a timestamp on a Sample: a controller with no wall clock can still say how long something has been wrong. Key 5 is what makes cell 23 of pack 2 is over voltage sayable at all — the cells cost no component rows and are still addressable.
That cell is elem = 7 on the wire and cell 23 on the screen, and both numbers are correct. Pack 2 is the second half of a 32-cell string, so its series signal has ebase = 17; elem is the position — the seventh element of that signal — and the label a person reads is ebase + elem − 1. P-178 is the sentence that joins them, and until it existed this document said cell 7 in one paragraph and cell 23 in the next about the same cell, which is precisely how two implementers each render a different one and somebody drives four hours and pulls the wrong cell.
cond is skip-unknown, so a condition nobody has allocated still renders as protection, pack 2, cell 23, code 0x4A12, which is a sentence somebody can act on.
The lifecycle has a state meaning over, and it did not. A pack low-temperature protection clears at dawn, and under the earlier four states every legal value was a form of still there: one controller sent 3 latched_cleared and the panel said the latch was holding so nobody started the charger; another sent nothing and the panel showed an active protection that ended six hours ago. Both leave a screen confidently wrong about whether charging is safe, and detection-by-disappearance does not save it — concerns do not move rev, so P-152 never fires a refetch.
| 1 | active | the condition is present |
| 2 | active_acked | somebody has read it. The condition is still present |
| 3 | latched_cleared | the condition is gone; the source’s latch is not, and will clear on the source’s own terms |
| 4 | clearing_blocked | the condition is gone and the source states it cannot reset the latch yet — a BMS protection that needs a completed charge cycle. The difference between wait and drive out |
| 5 | cleared | over. Terminal, and the only state a row carries as it leaves the table |
History — 0x10 / 0x90
Section titled “History — 0x10 / 0x90”ReadHistory 0x10 wrapper 1: rev u32 2: sig u16 MUST NOT be a series signal — outcome 4 3: bucket u8 1 day · 2 hour · 3 quarter_hour 4: first u32 bucket index: whole `bucket` periods **elapsed** since this controller's first closed bucket, so a period in which no bucket closed still consumes an index. Keys 4, 10, 11 and 12 are all in the requested `bucket`'s units; the FRAM counter is kept at the signal's `hist` and divided down (P-194) 5: count u8 buckets requested; ≤ MAX_HISTORY_POINTS, or error 1
History 0x90 wrapper under session_key 1: rev u32 2: sig u16 3: bucket u8 4: first u32 the bucket index of the first bucket in this response 5: q bstr one byte per bucket **returned**: validity<<4 | provenance. Its own length is authoritative and MAY be shorter than the request's `count` (P-184). A bucket is never 2 stale or 3 initialising — both describe a live reading's relation to now, and a closed window has neither (P-184) 6: v [ int ] one i32 per bucket at validity 1 `ok`, in ascending bucket order 7: n bstr optional; one u8 per bucket, readings that went into it, saturating at 255, and at least 1 for a bucket at validity 1. Same length as key 5. REQUIRED whenever any bucket is validity 1 (P-184) 8: reset bstr optional; ceil(len(key 5) / 8) bytes. REQUIRED iff the signal's vtype is 2 counter. Bit order in P-184 9: src bstr one byte per bucket, same length as key 5: 1 device_reported · 2 controller_derived 10: next u32 optional; the bucket index to resume from. Absent when this response completed the request 11: oldest u32 the oldest bucket index the store holds for this signal, in the requested `bucket`'s units (P-194) 12: newest u32 the newest closed bucket index, same units 13: stopped u8 optional; why the response is short — 1 device_replaced · 2 component_reassigned · 3 gap_in_record · 4 end_of_record · 5 older_than_store · 6 page_full 14: outcome u8 1 ok · 2 superseded · 3 unknown_signal · 4 series_not_historable · 5 no_history · 6 out_of_range 15: at u64 optional; controller time at the start of bucket `first`Key 5’s own length is the length of the response, and every other array is sized from it. The earlier draft pinned q to the request’s count while P-156 required a short response at a device-replacement seam; the two cannot both hold, and keys 6, 7 and 8 are all sized off the same number, so one wrong sentence mis-indexes the values, the sample counts and the reset bits together. A client written to the literal words discards a MAC-verified body whole. LogPage solved this next door with next_seq, oldest_seq and complete; keys 10 to 13 are that shape.
Key 7 is what stops a mean over 3 of 92 readings rendering identically to a mean over 92 — the smooth-line lie, one aggregation layer up from a room card showing 0.0 °C for a probe that is not there. It is required whenever any bucket is usable rather than left optional, because a chart a client cannot audit is a chart it should not have been given. It is also what weights P-195’s mean, so a window where one constituent had three readings and another ninety-two aggregates honestly instead of averaging two means.
Key 8 is required for a different reason, and a sharper one: its absence has a natural reading and the natural reading is the unsafe one. A client meeting no bitmap concludes no bucket had a counter reset, when the truth may be that this store does not track them — a missing capability silently converted into a positive safety claim. That is why it is mandatory for every counter rather than offered, and why P-184 writes its bit order out instead of leaving it to be inferred.
Key 9 keeps Victron’s own day record distinguishable from what this controller integrated, per bucket, because a window can span the day the device stopped supplying one: a controller down for three days in January comes back and reads the charger’s 30-day backlog, which exists for exactly that reader. One byte for a whole response is false for part of such a window. Where the controller cannot place a device-reported bucket on its own axis — it closed before the controller’s first bucket — it MUST NOT invent an index; those buckets are dropped and oldest reflects what it can place.
The vendor documents are explicit that page 20’s day record is a different signal set from the live registers, which is why history is not modelled as “the same signals, bucketed”: the extras — Voc max, panel voltage max, time in bulk, in absorption, in float — are their own signals, each with its own kind, domain and hist, and each historable on its own. point never names a charge stage; a stage duration is a quantity and belongs in the metric-kind registry.
Command — 0x08, operation body
Section titled “Command — 0x08, operation body”operation body of Command 0x08 1: cmd_id u32 unchanged 2: kind u16 unchanged, at its existing key; entry 8 still owns the argument schema 3: dev u16 REQUIRED; 0 is the Origin 89 controller itself 4: cmp u16 REQUIRED; 0 means the device as a whole (P-174) 5: rev u32 REQUIRED; the revision keys 3 and 4 were read under 6: args map unchanged content, moved from key 3 to key 6cmd_id is u32, which is what PROTOCOL.md’s operation body, Ack 0x88 and P-120’s 25-byte FRAM dedup entry have always said. An earlier draft wrote u64 unchanged — the word unchanged being the cue not to check — and claimed in the same row that kind moved to key 2, where it already was. Only args moves.
Two new outcomes: 7 stale_topology and 8 wrong_target. Both are specified in PROTOCOL.md beside Command 0x08 rather than here, together with P-166’s refusal rule.
That is a general rule and not a one-off, because of how the gate actually works. every_live_number_is_reachable reads docs/PROTOCOL.md, docs/protocol/LINK.md and docs/CONTROLLER-V1.md and nothing else, and it only sweeps allocations whose status is live. So nothing this document allocates goes live until the rule that produces it is written into one of those three files; until then it is reserved, which the sweep skips. Outcomes 7 and 8 become live in the commit that puts P-166 in PROTOCOL.md. The four new response outcome spaces stay reserved until this design is promoted out of proposal. Adding this file to the swept list was the other option and it is the wrong one: it would make a proposal load-bearing for a gate on the settled protocol.
Two things about that gate are worth writing down while somebody is looking at it, because neither is what its name suggests. It does not sweep metrics or events at all — only messages, errors, outcome spaces and the link-local ones — so the metric kinds and event kinds below can be allocated live with no rule anywhere and nothing goes red. And DEFERRED entry 11 records the omission as enums.* and codes.*, which is a shorter list than the code’s. Neither is this design’s to fix, and both are reasons step 2 cannot lean on the gate to tell it whether it finished.
Hello — 0x81
Section titled “Hello — 0x81” 13: RETIRED (was max_channels) 18: rev u32 the topology revision 19: topo_digest bstr8 computed exactly as P-173 writes it 20: max_buses u8 21: max_devices u16 22: max_components u16 23: max_signals u16 24: max_series_elements u16 25: max_params u16 26: max_concerns u16 27: max_selectors u8 28: max_history_signals u16 29: max_topology_depth u8Keys 24 and up cost two CBOR bytes for the key itself, because a map key is an unsigned integer under the same shortest-form rule as everything else and 24 is where one byte stops holding it. That is the only place in this document where a key number crosses the boundary, and the derivation below pays for it.
Keys 18 and 19 are what let a reconnecting client know in one round trip whether its cached inventory is still good, without walking anything — and, when the rev matches but the digest does not, that the controller has a defect.
The page byte caps are not reported, and that is the rule rather than an omission. MAX_READINGS_BYTES, MAX_INVENTORY_PAGE_BYTES and MAX_CONCERN_PAGE_BYTES decide how many rows arrive; no client can predict which rows fit, and every client already learns the answer from next. Reporting one invites exactly the misuse P-171 warns about — a client reading three caps as simultaneous. So the membership rule is: a Hello key is for a capacity this controller chose and a different one may choose differently; everything a client can rely on across every conforming controller is published in the fixed table and reported nowhere. That is what puts 880 in one table under one rule instead of two tables under opposite ones.
Event bodies
Section titled “Event bodies”Every body that names a dev, cmp, sig, elem or cid carries rev as key 1, and P-152 covers event bodies and LogEntry as well as the four responses.
0x0101 RETIRED (was value changed, class B)
0x0102 signal validity changed class A (was value quality changed) 1: rev u32 2: e [ VChange ] 1..MAX_VALIDITY_SWEEP, coalesced by P-182
VChange 1: sig u16 2: q u8 the new validity<<4 | provenance 3: prev u8 the **last announced** one, not the last observed — coalescing means a signal may move twice between events, and `prev` is what the client actually holds
0x0501 concern raised class A (was alarm raised) 1: rev u32 2: c the Concern body above, keys 1–13
0x0502 concern changed class A (was alarm cleared) 1: rev u32 2: cid u16 3: dev u16 4: cond u16 5: state u8 the new state; 5 cleared is the row leaving the table 6: prev u8 the previous state
0x0901 topology changed class A (new) 1: rev u32 the new revision 2: reason u8 1 boot · 2 config write · 3 sub-device adopted · 4 sub-device removed · 5 device replaced 3: added u16 4: removed u16
0x0902 device presence changed class A (new) 1: rev u32 2: e [ PChange ] 1..MAX_PRESENCE_SWEEP, coalesced by P-182
PChange 1: dev u16 2: presence u8 3: prev u80x0101 value changed is retired rather than redefined. It was the only class B kind, its rate followed a sampling loop rather than anything happening at the site, and at fixture 5’s cardinality it overruns MAX_EVENT_QUEUE every tick — so the hint a client was supposed to lean on is the first thing dropped, and P-097’s hole accounting carries nothing. A value moving is what polling is for.
And that alone would have made the failure worse, not better. Retiring the only class B kind moves that cardinality into the class whose overflow rule is close the session, not drop and count. One RS-485 pair going intermittent at −30 °C flips every signal behind it in one pass — up to 384 at the site cap — into a 16-deep queue across 8 sessions; P-098 closes each one, the reconnect replays the burst from the log and closes again. LINK.md L-023 logs the first shed in an hour, and L-022 puts the controller on the ladder at the third: every connection and session dropped, and comms link lost (0x0801) written against a chip that answered every heartbeat. If the cable keeps flapping the ladder does the rest, and its far end is L-112 — the rail off for fifteen minutes with comms unrecoverable. A fault four hours away in a cabinet takes the radio off the air and misnames itself in the log on the way.
P-182 is the fix, and it is a coalescing rule rather than a bigger queue: at most one 0x0102 and one 0x0902 per tick, each carrying an array, each carrying what fits and leaving the rest for the next tick without dropping any of it. A 384-signal sweep is eight ticks of one event, not 384 events. The class A production ceiling per tick is then derived in Bounds and asserted against MAX_EVENT_QUEUE, which is the one existing bound this design multiplies by ten and which appeared in no bounds table, no Hello key and no build step.
Class B is now empty, and three documents say otherwise. P-096’s three causes for a seq hole become one — two of the three name class B outright, so only the failed-CRC skip survives — P-098’s drop-oldest-first path is unreachable from a queue, and 0x0701 records dropped stays allocated with nothing able to produce it. The class stays defined — a later kind may need it, and the definition costs nothing — but P-003, P-096, P-097, P-098 and REGISTRY.md’s Today class B is exactly one kind paragraph are edited in the same commit. Three documents describing a path that can no longer happen is the thing this repo does not permit.
Bounds
Section titled “Bounds”The framing correction every derivation below rests on
Section titled “The framing correction every derivation below rests on”ENVELOPE_BYTES = 11 counts [type, session_id, req_id, body] including the body’s own map header, with a one-byte type. WRAPPER_BYTES = 23 counts the wrapper map, the payload bstr header, and the 16-byte MAC — including that same map header. The two double-count one byte, and every response type is 0x8x, which costs one CBOR byte more than the constant assumes. The two errors cancel exactly, which is why 11 + 23 = 34 has been the right answer for every response in this document and one byte conservative for every request.
That is luck, and luck that will be spent the first time somebody corrects one constant without the other. Split them:
const ENVELOPE_BYTES: usize = 11; // request: array, type, session, req_id, body map headerconst WRAPPER_CONTENTS_BYTES: usize = 22; // wrapper map contents; no map headerconst RESPONSE_TYPE_EXTRA_BYTE: usize = 1;// a 0x8x type is two CBOR bytesresponse framing = 11 + 22 + 1 = 34request framing = 11 + 22 = 33inner body budget = 1024 − 34 = 990Every existing derivation in this document keeps its answer, including the two that look as though they should not: MAX_OPERATION_CEILING is untouched because a signed request has no wrapper, and MAX_EVENT_BODY is exact for a reason set out beside the event bounds below.
Every width below comes from cbor.rs’s shortest-form encoder: a value ≤ 23 costs one byte, 24–255 two, up to 65 535 three, up to 2³²−1 five, wider nine. A map or array header follows the same rule on its element count. Map keys ascend and the encoding is canonical, which is what makes P-173’s digest well defined.
What the six fixtures actually cost
Section titled “What the six fixtures actually cost”The deck says costed against all six fixtures, and until this table existed the six appeared only as arguments about shape. Cardinalities are counted from DEFERRED.md’s own fixture list and the committed catalogue.
| Fixture | Devices | Components | Signals | Params | Series elements |
|---|---|---|---|---|---|
| 1 — charger, four MPPT trackers, per-tracker day record | 1 | 7 | 37 | 7 | 0 |
| 2 — inverter/charger, two AC inputs, split phase, bypass | 1 | 13 | 47 | 9 | 0 |
| 3 — bank, two packs, 16 cells each, probes, balancing | 1 | 7 | 35 | 8 | 72 |
| 4 — dual-bank charger with an identified start battery | 1 | 4 | 17 | 4 | 0 |
| 5 — nineteen-circuit meter, merged circuits, import/export | 1 | 22 | 71 | 20 | 0 |
| 6 — modular all-in-one, three runtime sub-devices | 4 | 14 | 41 | 4 | 0 |
| controller itself (dev 0, bus counters, local I/O) | 1 | 0 | 28 | 0 | 0 |
| all six at one site | 10 | 67 | 276 | 52 | 72 |
Fixture 3’s wording had to be resolved before it could be counted, and the resolution is the vendor’s. At least two packs, sixteen cells, four temperature probes reads as a flat list, and under that reading the fixture is 16 cells total. The EG4 LifePower4 response payload is per module — cell count, 16 cell voltages, sensor count, 4 cell temps in one addressed pack, address 1–15 — so sixteen cells and four probes are per pack, and two packs is 32 cells and 8 probes. That is 2 × (16 cell voltages + 4 cell temperatures + 16 balancing bits) = 72 series elements, and it is the reading the ebase 1 and 17 example has assumed all along. The two readings differ by 16 cells, 4 probes and a series signal, which is exactly the arithmetic a fixture table exists to settle.
The six are a conformance set rather than a plausible site, but the cap has to hold for them at once or the claim is not a claim. Two things fall out and one of them changed a number:
MAX_SIGNALSwas 320 with 64 reserved, and 276 does not fit 256 writable. An installer adding a tank probe and a shunt would have met a refused config write four hours from a road. It is 384 with 64 reserved — 320 writable against 276, 44 spare, one more mid-size device. The RAM cost is 3.6 KB — 64 more rows at 56 bytes, plus the announced-validity byte each — and it is in the table below.- Everything else fits with room: 9 config-written devices of 16 writable — the tenth row is the controller’s own
dev 0, which nobody writes — 67 components of 128 writable, 52 parameters of 80 writable, and 72 series elements of 512. The 512 pool is sized for fixture 3 at fourteen packs (504), not for six fixtures.
Fixed on the wire
Section titled “Fixed on the wire”None of these is negotiable and none is reported, for one of two reasons: either a decoder sizes an array from it before a byte arrives, or it is a page stop the controller applies and a client observes only as next being non-zero. Both are facts of this protocol version rather than of this controller, which is why they are published here and not in Hello.
MAX_BUSES is deliberately not here. 1..MAX_BUSES on BusRow key 1 is a range a client checks against a capacity a controller chose, so it belongs in the reported table below, and it appears in exactly one of the two. A bound in both tables under opposite rules is the thing this section exists not to do.
| Name | Value | Behaviour when reached |
|---|---|---|
MAX_SERIES_LEN | 16 elements | A driver declaring a longer series fails to register that signal: the device attaches, the signal does not, and a Concern series_too_long names it. A 32-cell string is two 16-element signals on two half-string components, with ebase 1 and 17. Never truncated |
MAX_LABEL | 32 bytes UTF-8 | Refused at config write with SetConfigAck outcome 3 invalid, and refused again by the row encoder with error 1. Never truncated — “pump hous” is a different label and somebody acts on it wrongly |
MAX_IDENT | 24 bytes UTF-8 | Serial, hardware and firmware strings. Same rule |
MAX_ADDR | 8 bytes | A bus address longer than this cannot be expressed; the driver is refused at registration |
MAX_ROW_BYTES | 184 | The scratch a single descriptor row is encoded into, 14 over the widest row in the table below. A row that would exceed it is refused at registration — the component or signal does not attach and a Concern names it, which is a fact somebody can act on rather than a page that silently stops |
MAX_SAMPLES | 40 | The response stops; next names the first unanswered sig. Not an error |
MAX_SERIES | 7 | Same |
MAX_READINGS_BYTES | 880 | Same |
MAX_INVENTORY_PAGE_ROWS | 48 | The page stops; next names the next id. Not an error |
MAX_INVENTORY_PAGE_BYTES | 880 | Same |
MAX_CONCERN_PAGE_ROWS | 12 | Same |
MAX_CONCERN_PAGE_BYTES | 832 | Same |
MAX_HISTORY_POINTS | 96 | count above it is error 1 — a client can check its own number |
MAX_COMPONENT_CMDS | 4 | A component needing a fifth is a component that should be a parent and a child. Refused at registration |
MAX_VALIDITY_SWEEP | 48 | Entries beyond it wait for the next tick and are not dropped (P-182) |
MAX_PRESENCE_SWEEP | 24 | Same |
MAX_CONCERN_EVENTS_PER_TICK | 4 | Same |
MIN_REV_INTERVAL_MS | 30 s | A sub-device discovered inside the window is staged and adopted at the next boundary. The descriptor tables and rev move together, atomically, at most twice a minute |
MAX_READINGS_BYTES is the one spelling of that bound. It was MAX_READINGS_ROW_BYTES in one table and max_readings_bytes in another, which is one bound under two names in a section whose own COBS story is about exactly that.
Reported by the controller, Hello 0x81
Section titled “Reported by the controller, Hello 0x81”P-005 applies unchanged: the controller reports what it enforces, and a client MUST use the reported value. P-005’s key list becomes 12, 14–29.
| Name | This controller | Key | Behaviour when full |
|---|---|---|---|
MAX_BUSES | 8 | 20 | Config write refused, outcome 5 exceeds_cap |
MAX_DEVICES | 24, of which 8 reserved for runtime adoption | 21 | Config write refused, outcome 5. A sub-device discovered past the cap is not adopted, rev does not move, and a Concern inventory_full is raised against the enclosing device. Nothing evicted |
MAX_COMPONENTS | 160, of which 32 reserved | 22 | Same |
MAX_SIGNALS | 384, of which 64 reserved | 23 | Same |
MAX_SERIES_ELEMENTS | 512, a shared pool | 24 | A series signal that does not fit the remainder is refused at registration, never shortened (P-172) |
MAX_PARAMS | 96, of which 16 reserved for runtime adoption | 25 | Same. A sub-device brings parameters with it, so a params table with no reserve fails the adoption that fixture 6 exists to prove — at the table nobody was watching |
MAX_CONCERNS | 48, of which at most MAX_CONCERNS_BELOW_FAULT = 24 are info or warning | 26 | Refused and counted in Concerns key 6. Nothing evicted |
MAX_SELECTORS | 12 per ReadSignals | 27 | Error 1 |
MAX_HISTORY_SIGNALS | 24 | 28 | Config write refused, outcome 5 |
MAX_TOPOLOGY_DEPTH | 4 | 29 | A parent chain deeper than this is refused at config write and at runtime adoption (P-187) |
MAX_BUSES, MAX_SERIES_ELEMENTS and MAX_TOPOLOGY_DEPTH have keys because they are enforced against a client’s write, which is the whole membership test for this table. MAX_BUSES was previously a normative range with no value anywhere in the repo; MAX_SERIES_ELEMENTS had its key given as implied by the signal cap, which is false — 384 signals imply nothing about a 512-element pool, and a driver’s fourteenth pack lost its signal against a budget the client was never told; and MAX_TOPOLOGY_DEPTH sat in the fixed table, where a commissioning laptop writing a bank → pack → module → cell chain would meet a refusal it had no way to have predicted, four hours from a road. That is the same failure MAX_BUSES’s key was added to fix, so it gets the same answer.
The reserved block is what fixture 6 needs and what the winner’s own dynamic block did not survive: its fixture assigned five of eight reserved device ids statically and left three for the runtime adoption the fixture exists to prove. A reserved block is a subtraction from the config-writable range, not a separate space.
Readings 0x8E — the binding message
Section titled “Readings 0x8E — the binding message”map header (8 pairs) 1key 1 seq 1 + 9 (u64) 10key 2 rev 1 + 5 (u32) 6key 3 at 1 + 9 (u64) 10key 4 s 1 + 2 (array header, 24..255) 3key 5 v 1 + 1 (array header, ≤23) 2key 6 next 1 + 3 (u16) 4key 7 total 1 + 3 (u16) 4key 8 outcome 1 + 1 (u8 ≤23) 2 ────READINGS_HEADER_BYTES 42headroom = 990 − 42 948Sample at its widest map header 1 + sig 4 + v 6 (i32) + q 3 + age 6 = 20
Series at MAX_SERIES_LEN, every element present and stale map header 1 + sig 4 + q 1 + bstr(16) header 1 + 16 = 18 + v 1 + array(16) header 1 + 16 × 5 = 82 + age 6 = 111SAMPLE_CEILING = 948 / 20 = 47 MAX_SAMPLES = 40, margin 7SERIES_CEILING = 948 / 111 = 8 MAX_SERIES = 7, margin 1
MAX_READINGS_BYTES = 880 under a headroom of 948, margin 68 40 × 20 = 800 ≤ 880 the row arm binds for scalars 7 × 111 = 777 ≤ 880 the row arm binds for series both at once = 1,577 > 880 the byte arm binds
widest response = 34 + 42 + 880 = 956 ≤ 1024, 68 sparea Sample two bytes wider at the cap = 40 × 22 = 880 ≤ 948The 68 is the same margin MAX_LOG_PAGE_BYTES keeps under its 964 of headroom, for the same reason: a Sample gaining an optional key must not turn a legal response into a frame the controller builds and then refuses.
age stays one field on a Series and P-179 says which element it describes. Per-element age was the alternative and it costs 76 bytes net — one array of sixteen ages at 82, less the 6 the single field spent — so the widest Series goes 111 → 187, SERIES_CEILING 8 → 5, and MAX_SERIES below 7. The failure it was meant to fix is real — cell 3’s sense wire intermittent at two hours, fifteen cells read eight seconds ago — but per-element validity already separates them, so the only question is what one number means. It is the age of the oldest element at validity 2, and an element at validity 1 has no age at all. Two stale elements of different ages both render at the older one’s, which over-reports; PROTOCOL.md’s own re-basing rule goes the same way, because the direction that makes an operator cautious is the safe one.
The row caps and the byte cap are not jointly reachable, and that is stated rather than hidden. MAX_SAMPLES and MAX_SERIES are what a decoder sizes a fixed array at; MAX_READINGS_BYTES is what decides how many arrive. P-171 says so, because reporting a number the controller does not enforce is worse than not reporting it, and three caps a client reads as simultaneous is exactly that.
Inventory 0x8D
Section titled “Inventory 0x8D”map header 1 + rev 6 + what 2 + rows key/header 3 + next 4 + total 4 + outcome 2 + digest 10 = 32headroom = 990 − 32 958MAX_INVENTORY_PAGE_BYTES = 880, margin 78widest page = 34 + 32 + 880 = 946 ≤ 1024, 78 spareRow widths at their widest — every optional key present, every string at its cap, and every field costed at the widest its declared type permits — with rows per page as min(row cap, byte cap ÷ width), which is P-171 applied rather than quoted.
That last clause is a rule and not a detail. An earlier revision costed some fields at their space’s widest — transport at one value byte because the space has seven members — and others at their type’s, and said which nowhere. The encoder test written against this table is what surfaced it: a space can gain a member, and a width that assumed it would not is a page that overruns the day somebody allocates the twenty-fourth. Costing by type is uniform, conservative, and impossible to get wrong; it moved three of the five widths and not one rows-per-page figure.
The ceiling asks the opposite question and takes the opposite answer. A width is what a row can cost at most, so it is costed at the top of each type. INVENTORY_PAGE_ROWS_CEILING is how many rows the byte cap could ever admit, so it is costed at the bottom: a BusRow with neither optional key and both values small is five bytes, not seven. The committed vector is what settled it — the generator built the genuinely narrowest row while the encoder test had assumed required-keys-at-widest, and the two disagreed by two bytes.
| Row | Widest bytes | Byte arm | Row arm | Rows per page |
|---|---|---|---|---|
| Bus | 48 | 18 | 48 | 18 |
| Device | 170 | 5 | 48 | 5 |
| Component | 80 | 11 | 48 | 11 |
| Signal | 96 | 9 | 48 | 9 |
| Param | 101 | 8 | 48 | 8 |
narrowest legal row BusRow with neither optional key, both small map header 1 + bus 2 + transport 2 = 5INVENTORY_PAGE_ROWS_CEILING = 880 / 5 176MAX_INVENTORY_PAGE_ROWS = 48, margin 128That is the one cap in this section whose margin is large rather than tight, and the reason is worth stating: the row cap is what a decoder sizes its array at, and its ceiling is the most rows the byte cap could ever admit — which only happens at a row near the five-byte minimum. At 48 the byte arm binds for every row kind at its widest and for a typical signal, component and parameter row alike, and the row arm binds only for pathologically narrow pages. Both arms are real; P-171 is which one wins, not which one exists.
Every page count in the earlier draft divided the row cap away. It read 51 signal rows to a page where the arithmetic gives 38, and 14 pages for a six-fixture walk where it is 17 — having caught the same mistake one paragraph earlier for Bus and forgotten it immediately. The row cap moved from 16 to 48 in the same edit, because at 16 a typical row wasted most of an 880-byte page and the walk was several times longer than the number MIN_REV_INTERVAL_MS was justified against.
A typical row here is the row a real fixture produces, and the three kinds are listed key by key rather than by a rule about optionality — because they carry different optional keys as a matter of course, and one sentence covering all three reads three ways:
| Typical row | Keys | Bytes | To a page |
|---|---|---|---|
| Signal | every required key and nothing else — sig, dev, cmp, kind, shape, vtype, domain | 23 | 38 |
| Component | the required four plus parent and index, which a repeated part under an owner has | 27 | 32 |
| Param | the required six plus v, lo, hi and via | 43 | 20 |
The one that bites is ComponentRow key 9 since: it is not optional, so a component row is never below 19 bytes however bare, and a page count that forgets it overruns. The walk below uses the with-parent figure, because a page count that assumes the narrow row is a page count that overruns.
the six-fixture walk, at typical widths buses 8 rows ÷ 18 = 1 page devices 10 rows ÷ 5 = 2 pages components 67 rows ÷ 32 = 3 pages signals 276 rows ÷ 38 = 8 pages params 52 rows ÷ 20 = 3 pages ──── 17 pages
the same walk at the reported caps, every row at its widest 8 ÷ 18 + 24 ÷ 5 + 160 ÷ 11 + 384 ÷ 9 + 96 ÷ 8 = 76 pagesMIN_REV_INTERVAL_MS has to outlast a walk, and 30 s covers the first figure and not the second. Seventeen pages in 30 s is 1.7 s per page, which is the number to measure the first time a browser talks to real hardware over BLE; if a page costs more than that, MIN_REV_INTERVAL_MS moves rather than the walk. Seventy-six pages does not fit at any plausible per-page cost, which is why P-191 exists: a bay that stages and un-stages repeatedly is quarantined and named in a Concern rather than allowed to ratchet rev while a client restarts its walk forever. This project has already met that shape once, as a counter that livelocked when two clients connected.
The row-width table is a check on the encoder, not a second copy of it. A page is built by encoding each row into a MAX_ROW_BYTES scratch, measuring what came out, and appending it with CborWriter::raw only if it fits the remaining budget — then writing the array header once the rows are counted. The check is a #[test] that encodes a worst-case row of each of the five kinds through the public encoder and asserts the byte count equals the constant in this table. It cannot be a const_assert!: that macro expands to const _: () = assert!(…) in lib.rs, and CborWriter is not const fn. The earlier draft prescribed one, which is a check that cannot compile standing in for the one named defence against the COBS defect — two formulas for one bound in limits.rs, disagreeing by a byte at every multiple of 254 while both stayed safe. const_assert! keeps the arithmetic it can actually do.
Concerns 0x8F
Section titled “Concerns 0x8F”map header 1 + rev 6 + seq 10 + c key/header 2 + next 4 + total 4 + refused 4 + outcome 2 = 33headroom = 990 − 33 957
Concern at its widest map header 1 + cid 4 + dev 4 + cmp 4 + sig 4 + elem 2 + cond 4 + sev 2 + state 2 + age 6 + since 10 + raw 6 + vns 4 + seq 10 = 63
CONCERN_CEILING = 957 / 63 = 15 MAX_CONCERN_PAGE_ROWS = 12, margin 3MAX_CONCERN_PAGE_BYTES = 832, margin 125 12 × 63 = 756 ≤ 832widest page = 34 + 33 + 832 = 899 ≤ 1024, 125 spareThe row arm always binds here, and the byte cap is a backstop that is never reached. Twelve concerns at their widest are 756 bytes against 832, and at the narrowest legal Concern — no sig, no elem, no since, no raw, no vns, so 37 bytes — 832 would admit 22 where the row cap admits 12. That is stated rather than left for a client to discover, because a cap nothing can reach is a cap somebody eventually plans against. It stays in limits.rs as the assertion that keeps a future key on Concern from turning a legal page into a frame the controller builds and then refuses.
elem costs two bytes and not three, because P-178 makes it a position in 1..MAX_SERIES_LEN rather than a label that can exceed 23. The earlier draft costed it at three, which was one of the two things pointing at the other reading.
MAX_CONCERNS = 48 in the store, at most MAX_CONCERNS_BELOW_FAULT = 24 of them info or warning. Two packs of sixteen cells is thirty-two potential per-cell concerns, and under a single flat cap a cold soak fills the table before the pack-level protections arrive — allow-charge false, contactor open, the CCL collapsing to zero. Refusing beats evicting when the members of a collection are interchangeable, and here they are not: the ones that arrive first are per-cell and the ones that decide whether a charger may start arrive later. Two counters, two bands, nothing evicted, and the refused count on every page. MAX_CONCERNS_BELOW_FAULT was named in P-169’s arithmetic and defined nowhere.
History 0x90
Section titled “History 0x90”map header 1 + rev 6 + sig 4 + bucket 2 + first 6 = 19 + q 1 + bstr(96) header 2 + 96 = 99 + v 1 + array(96) header 2 + 96 × 5 = 483 + n 1 + bstr(96) header 2 + 96 = 99 + reset 1 + bstr(12) header 1 + 12 = 14 + src 1 + bstr(96) header 2 + 96 = 99 + next 6 + oldest 6 + newest 6 = 18 + stopped 2 + outcome 2 + at 10 = 14 ────widest body at MAX_HISTORY_POINTS 845spare = 990 − 845 145widest response = 34 + 845 = 879 ≤ 1024, 145 spare
per point = q 1 + v 5 + n 1 + reset 1/8 + src 1 8.125fixed = 845 − 96 × 8.125 65POINT_CEILING = (990 − 65) / 8.125 = 113MAX_HISTORY_POINTS = 96, margin 17The index counts elapsed periods, not closed buckets, and that is the difference between a chart and a lie. Under a count of closed buckets a controller down for three days in January closes nothing, index n+1 sits three days after index n, and a client dating the rest of the window from key 15 — the one anchor it has — plots a seventy-two-hour outage as one quarter-hour and mis-dates every point after it. That is the smooth-line lie key 7 exists against, arriving one layer up and immune to it. Counting elapsed periods puts the gap on the axis, where P-184 stops the response at it with stopped = 3 and next names the far side.
And every index in one exchange is in the units the request asked for. Keys 4 and 10 are positions in the response; keys 11 and 12 describe the store; P-193’s outcome 6 then compares them — first is past newest — which is not a comparison at all unless they share a unit. A client asking for the last 96 days sends first = newest − 95, and against a controller holding its axis in quarter-hours that is 24 hours, answered or refused with both sides conforming to the words. The FRAM counter is kept at the signal’s finest hist and divided down per request, which costs one division and removes the whole class.
96 is one day of quarter-hour buckets, which is the window DEFERRED entry 3’s aggregate record uses — so the client-facing page and the flash record agree about what a day is. The cursor, the two store bounds, and the move of src from one byte per response to one per bucket take the widest body from 730 to 845 — the stop reason is not in that list, having replaced truncated at the same two bytes. The 145 that remains is still a wider margin than Readings keeps at its own cap.
A series signal is refused with outcome 4 rather than answered: 96 buckets × 16 cells is about 9.2 KB against a 990-byte body, so may be a series signal was a sentence with no encoding behind it. It is numbered below no_history on purpose — a series signal never carries hist either, and P-193 returns the first outcome that applies, so the other order would make this one unreachable.
Hello 0x81
Section titled “Hello 0x81”keys 1–17, both fw strings at MAX_STRING, every reported cap at the widest its declared type permits 213 less retired key 13 −3 plus keys 18–29, key bytes included +59 plus one map-header byte: 28 pairs is past 23 +1 ──── 270widest response = 34 + 270 = 304 ≤ 1024, 720 spare
the +59, key byte + value byte, at each type's widest 18 rev 1+5 19 digest 1+1+8 20 buses 1+2 21 dev 1+3 22 cmp 1+3 23 sig 1+3 24 elem 2+3 25 par 2+3 26 con 2+3 27 sel 2+2 28 hist 2+3 29 depth 2+2Every value below is costed at the widest its declared type permits — a u8 at 2 value bytes, a u16 at 3 — and not at this controller’s value, because P-005 tells a client to use what it was told, and the budget has to hold for what a different controller might tell it.
That convention runs through the row tables too, and it is why bus costs two value bytes there. An earlier revision of this section costed BusRow key 1 and DeviceRow key 2 at one byte on the grounds that MAX_BUSES is 8 — which was true of this controller and not of the protocol, and which contradicted the same section’s decision to make MAX_BUSES a reported capacity rather than a fixed one. A controller reporting max_buses = 24 is legal under the only rule that bounds it, and its BusRow is 47 bytes rather than 46. The whole point of splitting the two tables was that a number cannot be fixed for the arithmetic and negotiable for the client, so the arithmetic follows the reported reading.
The 213 is derived here rather than carried. An earlier draft printed 207, with no derivation behind it, no map-header byte for a map that grows from 17 pairs to 27, and the two single-byte protocol_major/protocol_minor keys costed as though 255 encodes in one byte.
The class A burst one tick can produce
Section titled “The class A burst one tick can produce”0x0102 signal validity changed, coalesced body map header 1 + rev 6 + e key/array header 3 = 10 entry map header 1 + sig 4 + q 3 + prev 3 = 11 SWEEP_CEILING = (965 − 10) / 11 = 86 MAX_VALIDITY_SWEEP = 48, margin 38 at the cap 10 + 48 × 11 = 538 ≤ MAX_EVENT_BODY 965
0x0902 device presence changed, coalesced body map header 1 + rev 6 + e key/array header 3 = 10 entry map header 1 + dev 4 + presence 2 + prev 2 = 9 at MAX_PRESENCE_SWEEP = MAX_DEVICES = 24 10 + 216 = 226 ≤ 965
class A events one tick can produce, from THIS design 0x0102 1 0x0902 1 0x0501 + 0x0502 concern raised/changed 4 MAX_CONCERN_EVENTS_PER_TICK 0x0901 topology changed 1 at most one per MIN_REV_INTERVAL_MS ─── this design's contribution 7 of MAX_EVENT_QUEUE 16 left for the sixteen it does not bound 9The nine is not a margin, and calling it one would be the same mistake in a smaller font. REGISTRY.md allocates twenty event kinds and nineteen of them are class A. This design redefines and bounds three — 0x0102, 0x0501, 0x0502 — and adds two more that it bounds. The remaining sixteen have no per-tick bound anywhere in this repo: output changed, behaviour decision, generator state changed, the four 0x08xx comms records, boot, config changed, time set and the rest. Until now none needed one, because class B absorbed the pressure and class A was rare. This design empties class B and multiplies class A by ten, which makes that missing bound load-bearing.
So CLASS_A_TICK_CEILING is the total across every class A kind, this design’s and the existing ones’, and P-182’s MUST is against the total. Three of the nineteen are kinds this design redefines and bounds, and two more are new here; the remaining sixteen have no per-tick bound at all. Bounding the other sixteen — most of which are trivially bounded by physics, since a controller boots once and a clock is set by a person — is work that lands in build step 8, with the arithmetic written out the way this table is. A ceiling that counts only the kinds its author happened to be thinking about is the shape of the defect this section exists to fix.
0x0502 is counted with 0x0501 and not separately. P-180 makes an 0x0502 mandatory as each concern leaves the table, and thirty-two per-cell concerns clearing at dawn is one tick — so a cap on raises alone leaves the clears unbounded and reproduces the burst this whole section exists to stop, arriving through the requirement that gave the lifecycle a state meaning over.
MAX_EVENT_BODY is already derived in limits.rs as 1024 − 11 − 23 − 25 = 965. Without the coalescing rule this design’s own column reads 384 against 16, and P-098 turns that into eight closed sessions.
MAX_EVENT_BODY survives the split exactly, and the reason is worth writing down because it looks at first like it should not. Event 0x04’s type is below 0x17, so RESPONSE_TYPE_EXTRA_BYTE does not apply and its framing is 11 + 22 = 33, not 34 — which appears to leave the constant one byte short. It does not, because EVENT_FIXED_BYTES = 25 deliberately excludes the event record’s own map header, and says so: the map header is ENVELOPE_BYTES’. The byte that ENVELOPE_BYTES and WRAPPER_BYTES double-count is precisely that map header. Two errors and an omission cancel to zero, and 1024 − 11 − 23 − 25 = 965 is exact rather than lucky.
That is the third instance of the same pattern in this one section, and it is the argument for splitting the constants rather than against it: every one of these cancellations is correct today and none of them is written down anywhere a person correcting one constant would look.
Every widest response against MAX_PAYLOAD
Section titled “Every widest response against MAX_PAYLOAD”Readings 0x8E 34 + 42 + 880 = 956 ≤ 1024 68 spareInventory 0x8D 34 + 32 + 880 = 946 ≤ 1024 78 spareConcerns 0x8F 34 + 33 + 832 = 899 ≤ 1024 125 spareHistory 0x90 34 + 845 = 879 ≤ 1024 145 spareHello 0x81 34 + 270 = 304 ≤ 1024 720 spareEach cap sits below its own derived ceiling with a stated margin, and each derivation belongs in limits.rs beside the others with an assertion that goes red when somebody raises a cap past what carries it. The way to watch them fail is to raise one and run cargo test -p km43-proto.
Forty-three constants, not the twenty an earlier build step named, and the breakdown is printed so the count can be checked rather than believed:
18 fixed on the wire MAX_SERIES_LEN MAX_LABEL MAX_IDENT MAX_ADDR MAX_ROW_BYTES MAX_SAMPLES MAX_SERIES MAX_READINGS_BYTES MAX_INVENTORY_PAGE_ROWS MAX_INVENTORY_PAGE_BYTES MAX_CONCERN_PAGE_ROWS MAX_CONCERN_PAGE_BYTES MAX_HISTORY_POINTS MAX_COMPONENT_CMDS MAX_VALIDITY_SWEEP MAX_PRESENCE_SWEEP MAX_CONCERN_EVENTS_PER_TICK MIN_REV_INTERVAL_MS10 reported MAX_BUSES MAX_DEVICES MAX_COMPONENTS MAX_SIGNALS MAX_SERIES_ELEMENTS MAX_PARAMS MAX_CONCERNS MAX_SELECTORS MAX_HISTORY_SIGNALS MAX_TOPOLOGY_DEPTH15 derived ENVELOPE_BYTES WRAPPER_CONTENTS_BYTES RESPONSE_TYPE_EXTRA_BYTE INNER_BODY_BYTES READINGS_HEADER_BYTES INVENTORY_HEADER_BYTES CONCERNS_HEADER_BYTES SAMPLE_CEILING SERIES_CEILING CONCERN_CEILING POINT_CEILING SWEEP_CEILING CLASS_A_TICK_CEILING INVENTORY_PAGE_ROWS_CEILING MAX_CONCERNS_BELOW_FAULT──43A step that says twenty against a table that holds forty-three is a step nobody can tell they have finished. Each name appears in exactly one of the three groups, which is the same rule that moved MAX_BUSES out of the fixed table.
Controller RAM
Section titled “Controller RAM”Descriptor labels live in the configuration image in FRAM, not in the row structures, and each row holds a 2-byte offset — 32 bytes multiplied by 700 rows is 22 KB of UTF-8 that would otherwise sit in RAM for nobody. The controller does read them, at two moments: when it encodes a row into a page, and when it computes the topology digest, which is what makes a label edit visible to P-173. An earlier draft said the controller never reads them, and under that reading changing a label moves no byte the digest covers and build step 4’s test cannot go red.
| Rows | Bytes/row | ||
|---|---|---|---|
| buses | 8 | 16 | 128 |
| devices | 24 | 44 | 1,056 |
| components | 160 | 44 | 7,040 |
| signals — descriptor 35, live 21 | 384 | 56 | 21,504 |
| series element pool | 512 | 8 | 4,096 |
| parameters | 96 | 56 | 5,376 |
| concerns — including the persisted accumulated age | 48 | 68 | 3,264 |
| history open buckets | 24 | 24 | 576 |
| pending adoptions — including P-191’s flap counter | 8 | 44 | 352 |
| announced-validity byte, one per signal — what P-182 coalesces against | 384 | 1 | 384 |
| page scratch — 880 staging + 184 row | — | — | 1,064 |
| inner-body encode buffer | — | — | 990 |
| 45,830 B |
44.8 KB of 144 KB, every one a fixed array sized at compile time, nothing allocated after init, no per-session subscription state, no resumption state. MAX_SNAPSHOT_BODY (888 B) retires with Snapshot, so the net is +43.9 KB.
The element row was 6 bytes and is 8, which is this table’s one unit confusion and worth naming. Six is an element’s wire width — an i32 and a q byte, CBOR-packed. In RAM a { i32, quality } cell has i32 alignment, so five rounds to eight, and 512 of them are 4,096 bytes rather than 3,072. It was found by building it: o89-core’s store had a self-imposed 36 KB budget with 3,064 bytes free, the pool needed 3,072, and the const assertion holding that budget stopped the build eight bytes short — which is the arithmetic doing exactly what it was put there for. The packing that would buy the difference back stores values apart from the qualities that say whether to believe them, and that is the one trade this project does not make, so the budget moved instead.
The lesson generalises to every other row here: these widths were costed on the wire, and a row read in RAM pays alignment the wire does not. Nothing else in the table has been re-measured against a real struct, so treat the total as a floor rather than a figure.
The announced-validity byte is what makes P-182 bounded without a queue: the pending set is derived each tick by comparing a signal’s current q against the last one announced, so a burst of 384 costs 384 bytes of state rather than 384 queue slots, and nothing can be dropped because nothing was ever enqueued.
Row widths are honest Rust with Option-wrapped optionals rather than sentinels — a None costs a discriminant and the alternative is a default that reads as a measurement, which is the thing this project does not ship.
Controller flash, which is the budget nobody costed
Section titled “Controller flash, which is the budget nobody costed”Four message pairs on a ~256 KB dual-bank image, replacing snapshot.rs’s 1,415 lines. What keeps it affordable is that the five row kinds share one encoder driven by a static field table — (key: u8, ty: u8, optional: bool) — so adding a row kind is a table row rather than a function, and nothing monomorphises per kind. The table is exactly the keys in the five row definitions above: 4 + 13 + 9 + 18 + 17 = 61 entries at 3 bytes, 183 B. Estimate: 2.5 KB of codec, that table, and the 29 new generated enums with Display against generated.rs’s current 1,091 lines — call it 14 to 20 KB of .text.
The estimate is not the check. cargo size on o89-fw before and after each of the four pairs lands, recorded in the commit message. An estimate nobody measured is exactly the class of number this repo has already caught twice.
Requirements
Section titled “Requirements”Eighteen of these have been promoted and now live in PROTOCOL.md.
A rule that is being implemented belongs in the settled document — that is what
every_live_number_is_reachable, bodies_match_the_spec and the traceability
ratchet each said in turn — so as each message lands, its requirements move
rather than being copied. What is left here is the reasoning; the rule is there.
| Here | There | What |
|---|---|---|
| P-190 | P-145 | outcome other than 1 carries no rows, no cursor, no digest |
| P-153 | P-146 | the controller does not pin a walk |
| — | P-147 | an unallocated what is an outcome and not an error |
| P-173 | P-148 | the digest preimage, byte for byte |
| P-151 | P-149 | rev and digest are one identity; the trigger is the row write |
| P-157 | P-196 | a value key exists exactly when there is a value |
| P-158 | P-197 | a series’ q bytes and its integers, and the counting between them |
| P-183 | P-198 | the resolved selection: union, ascending sig, and a cursor that is an id |
| P-193 | P-199 | outcomes are evaluated in ascending order, and what triggers Readings’ |
| P-185 | P-185 | every value position is an i32, and one out of range is refused, not clamped |
| P-174 | P-200 | cmp = 0 is device scope, and sig/pid reserve 0 |
| P-175 | P-201 | an absent cmds is none, and [] is not a second way to write it |
| P-189 | P-202 | addr is required on an addressed bus and unique within it |
| P-162 | P-203 | a rollup is already counted, combines under nothing, and cannot cycle |
| P-160 | P-204 | unit and scale ride a vendor kind and never a standard one |
| P-156 | P-205 | since moves when the instrument or the meaning behind a row does |
| P-178 | P-206 | ebase is the label of element 0, not its position |
| P-188 | P-207 | the descriptor identity tuple, unique within a rev |
The numbers do not line up because PROTOCOL.md’s space and this document’s were allocated independently, and renumbering a settled document to make a proposal tidy is the wrong trade. The mapping is here so nobody reads the two as separate rules that happen to agree. P-185 keeping its number is a coincidence of two counters passing each other and not a convention.
P-193 moved in part. Its ascending-evaluation clause is general and went
whole; of its trigger table only the Readings 0x8E rows are there, because
Concerns and History are not. Each takes its rows over when its message
lands. A trigger promoted ahead of the message it triggers is a rule naming an
outcome nothing can produce, which is precisely what
every_live_number_is_reachable refuses.
P-156 and P-178 moved in part, for the same reason. P-205 carries P-156’s
two since fields and the condition under which each must move; the clause
stopping a History bucket at either boundary, with its stopped value of
1 device_replaced or 2 component_reassigned, stays here until the history
messages land. P-206 carries P-178’s ebase arithmetic; the clause making
Concern key 5 elem a 1-based position, and the ebase + elem − 1 a client
renders from the two together, stays here until Concerns lands. Both remainders
are the halves that name a message PROTOCOL.md does not describe.
P-188’s two statements of the parameter tuple disagreed, and the requirement
block is the one that counts. The prose at the head of this document wrote
(dev, cmp, kind, domain, point, dir) for a ParamRow while the requirement
wrote (dev, cmp, kind, vtype, domain, point, dir). The requirement is right and
the prose has been corrected: a charger’s enum-valued battery type and a gauge
sharing its kind, domain, point and dir on one component are two
parameters, and without vtype in the tuple a conforming controller refuses at
registration a pair this design explicitly wants. The tree could not settle it —
there is no parameter table in o89-core at all, so nothing was enforcing either
reading.
Amended
Section titled “Amended”P-005 — the reported-keys list becomes 12, 14–29. Key 13 is retired.
P-014 — unchanged as a rule, and its scope is now stated: it governs a discriminant in a request, where a receiver can refuse and the sender can be told. On the response path it is displaced by P-165.
P-019 — the vendor range 0xF000–0xFFFF and the skip-unknown rule extend from the metric-kind and event-kind spaces to the component-role, device-role, product, dialect, condition, measurement-point, vendor-namespace and enum-space registries. Every other new space — validity, provenance, shape, vtype, domain, direction, transport, severity, concern_state, presence, unit, bucket, history_source, topology_change_reason, history_stop_reason, inventory_kind, and every new outcome — is closed and has no vendor range.
Every name in both lists is the registry space’s name, not the field’s, and the two differ in two places that matter to whoever writes protocol.toml: the space direction is carried in a field called dir, and the space inventory_kind is carried in a field called what. An earlier revision named those two by their field names here and by their space names in the registry section, which is one space under two spellings in the two lists build step 2 works from — the same defect that left presence allocated in neither.
P-093 — the wall-clock rule stands. Its corollary is deleted: staleness no longer needs a date, because age is a duration on P-004’s tick.
P-003, P-096, P-097, P-098 — amended for an empty class B, in the same commit that retires 0x0101. P-096’s three causes for a seq hole become one — two of the three name class B in as many words, so only the failed-CRC skip survives. P-098’s drop-oldest-first branch is unreachable from a session queue and says so. 0x0701 records dropped stays allocated and becomes unreachable until a class B kind is allocated again.
Retired
Section titled “Retired”P-089 (Value key 3 in i32 range), P-090 (no paging, at most MAX_CHANNELS values), P-091 (absent omits key 3), P-092 (stale carries a timestamp). Four describe a message that no longer exists. Their arguments survive as P-157, P-159 and P-185 — P-089’s integer bound in particular is replaced rather than dropped, because five byte derivations in this document spend it and cbor.rs and limits.rs both cite it by number.
This list said six, and two of them are not retired. P-006 and P-125 were on it, the retirement commit ran into both, and 6c below records what it found.
The ratchet does object, once, and the objection is easy to answer wrongly. Deleting **P-125** from PROTOCOL.md fails the build with reading.rs cites P-125, which no document allocates — the citation check firing, because the allocation is read out of PROTOCOL.md and the citation out of a #[test] name. That message names the test, so the cheapest way back to green is to rename the test, and renaming it passes: the rule is then stated in no document, cited by nothing, and every check agrees. Watched, both halves: the deletion goes red, and the deletion plus the rename goes green with the sentence gone. The check is a tripwire on the number, not a guard on the rule, and it cannot tell a requirement that was replaced from one that was simply removed.
P-006 outlived the message because it was never only about it. It caps Hello key 13, and handshake.rs enforces it on encode and on decode — a controller that reports more than 32 is refused by the peer, and the controller is bound not to build one. What died is its argument: the snapshot it would build and then have refused with error 5. The rule goes when key 13 goes, which is the step that moves the 0x0002 channels config section to 0x0004 topology, and 6c’s closing note already defers both. Until then its justification in PROTOCOL.md names a message that is gone and is rewritten in place, not deleted.
P-125 outlived the message because its replacement is not here yet. It is the only settled sentence that forbids rendering an unnamed enum value as the nearest one a build knows. P-165 replaces it and reaches much further, and P-165 is unpromoted — it lives in this document and nowhere a client implementer reads. P-125 is retired in the commit that promotes P-165 into PROTOCOL.md, and not before: deleting it first leaves the rule stated nowhere while Meaning::Unrecognised and the test named after it still stand behind it.
P-150 — rev MUST be monotonic in FRAM, MUST be incremented on any change to a bus, device, component, signal or parameter descriptor, and MUST NOT be decremented or reset — not on reboot, and not on factory reset, where epoch moves alongside it.
A rev that can repeat is a cache that is silently wrong: a client rendering pack 2 cell 9 over bytes that are now circuit 14. Every other failure in this design is loud. This one is not, which is why the counter is in FRAM and why P-151 exists.
P-151 — rev and topo_digest are one identity. A client MUST treat a difference in either field as a different topology and MUST discard its cached descriptors. A client meeting a matching rev with a differing digest MUST refetch and MUST surface it as a controller fault. The digest a controller reports MUST equal P-173’s hash of the descriptor rows as they stand at the moment of the response; a controller MAY cache the value, and MUST invalidate that cache on any write to any descriptor row.
A digest that a client only compares when it fetches is a digest nobody ever compares, because a client whose rev matches never fetches. Putting it in Hello 0x81 puts it in front of the one party that can catch a descriptor changed without the revision moving. The trigger is the row write and not the rev bump, because a controller that recomputes on the bump satisfies the letter of the rule, then edits a ComponentRow without bumping and reports a digest that still matches — the one mechanism the design claims for its own silent failure, not compelled to exist by the requirement that claims it.
P-152 — Every Inventory, Readings, Concerns and History response, every event body that names a dev, cmp, sig, elem or cid, and every such LogEntry, MUST carry rev. A receiver MUST NOT interpret any of them under a rev other than the one its cache holds; it discards and refetches instead.
A stale cache renders the wrong component’s name against the right number, which is worse than rendering nothing — and the replay path is where it bites hardest. A client subscribes from its last accepted seq, so a record written under rev 41 arrives before the 0x0901 that would qualify it, and P-153 has already made rev 41’s descriptors unfetchable forever. ReadLog has the same hole months later. Six bytes on a class A path is cheap today and stops being cheap the moment the first event is in somebody’s NOR ring.
P-153 — The controller MUST NOT pin a walk. It reports its current rev in every page, and MUST answer a request naming a rev that is neither 0 nor current with outcome 2 superseded, the current rev in key 1, and an empty row array.
Pinning is a resumption state machine holding a consistent view across round trips, on a device with no allocator — the reason PROTOCOL-RATIONALE.md rejected paging the snapshot. rev is the pin, it costs the controller nothing, and a torn walk is not prevented but is detectable in a field that is already on every page.
P-154 — rev MUST NOT change more often than MIN_REV_INTERVAL_MS. A sub-device discovered inside the window is staged and adopted at the next boundary. A SetConfig write to 0x0004 topology inside the window MUST be accepted and staged, applied at the next boundary, and answered with SetConfigAck outcome 9 staged carrying the seconds remaining.
A modular product with a marginal bay contact — the ordinary failure of a connector at an unattended site — otherwise bumps faster than a seventeen-page walk completes, and the client never assembles an inventory. The config half is the commissioning laptop on the day the installer is writing topology in bursts: P-102 says apply on the pointer flip, P-150 says bump, P-154 said do not, and there was no outcome meaning correct, but not for another eighteen seconds. Entry 9 owns the outcome number.
P-155 — A device’s presence MUST NOT bump rev. A device that stops answering keeps its rows; its signals report validity 7 absent with no value.
A module pulled from bay 3 and pushed back in would otherwise discard every cached id at every client, twice, for a fact that changed nothing about what those ids mean. Liveness is cheap and hot; membership is rare and cold, and that split is the whole of fixture 6.
P-156 — DeviceRow.since MUST be set to the current rev whenever the physical instrument behind that row changes, and ComponentRow.since whenever that row’s meaning changes. History MUST NOT return buckets spanning either boundary: it stops there and sets stopped to 1 device_replaced or 2 component_reassigned according to which seam it hit. The two are different values because which seam it is decides whether a technician is looking for a swapped instrument or a rewired configuration.
Somebody swaps a dead charger for an identical one at the same terminals. Every id is unchanged, which is what you want for what is the power in the pump house and a trap for is this the same instrument. A series that runs straight through the seam is eight months of history plotted as one smooth line across two machines. The component half is the same failure one level down — a reassigned component plots two meanings as one line, and DEFERRED entry 1’s own worked scenario has no device in it. This makes the seam visible where it matters; it does not settle DEFERRED.md entry 1.
P-157 — Sample key 2 MUST be present exactly when validity is 1 ok or 2 stale, and absent otherwise. provenance MUST be 0 exactly when there is no value and in 1..6 exactly when there is.
0xFFFF at a scale of −2 is 655.35 V, which is a plausible-looking number on an MPPT RS, and the vendor document says in as many words that fields not present in a given unit read that way. There is no slot for a number when there is no number — including for sensor_fault, where a rule demanding a value would force an encoder to invent one under a byte that says the sensor is broken. This rule constrains the encoding after a validity has been chosen; who chooses is P-176, and the two were one rule pretending to be a fix.
P-158 — Series key 2 MUST be exactly n bytes, where n is the descriptor’s length. Key 3 MUST hold exactly as many integers as key 2 has bytes whose validity is 1 or 2, in ascending element order. A length disagreement is error 1.
One open cell-sense wire must not blank fifteen good cells, and an absent element must not be a byte that decodes to a plausible voltage. Position is identity in a series, so an element cannot simply be omitted — but an element with no reading has no integer anywhere in the message, which is the only version of this a decoder cannot get wrong. There is no CBOR null on this wire; cbor.rs refuses simple value 22 on both sides and has a test named for it.
P-159 — age is a duration in seconds on P-004’s monotonic tick, never a wall-clock instant, and is REQUIRED when validity is 2 stale. Concern key 9 age is always present.
P-092 required a timestamp and P-093 forbade inventing one, so a controller whose clock had never been set could not report stale at all — the last known value simply disappeared until somebody wrote the time. A duration since boot is a claim a controller with no date can honestly make. Seconds and not milliseconds: 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.
P-160 — unit and scale MUST be absent from a SignalRow or ParamRow whose kind is below 0xF000, and MUST be present when it is in the vendor range.
A descriptor that can disagree with the registry is a second source of truth for the number on the screen. Two controllers could report the same standard quantity at different scales and both would verify, and a client could not tell a legitimate scale choice from a driver bug. Standard kinds keep fixed semantics; vendor kinds carry their own and are namespaced by vns.
P-161 — Instance identity MUST NOT appear in the metric-kind space. A repeated part is a component with a role and an index, or an element of a series.
Tracker 2 is an instance of the same quantity, not a new quantity. pv-power-1 through pv-power-4 has no end — L1/L2/L3, bank 1/2, pack 1–16, cell 1–16, relay 1–6 and circuit 1–19 all reproduce it — and it spends the global registry on the shape of one product.
P-162 — A component whose rollup is set MUST have its values already accounted for in the named component’s values, and a client MUST NOT combine the two under any aggregation — not a sum, not a maximum, not a mean. rollup MAY name a component of an ancestor device. It MUST NOT form a cycle; cycles are refused at config write and at runtime adoption.
The charger’s combined PV figure added to its four trackers is double the sun. A merged 240 V circuit added to its two legs is double the dryer. The earlier wording said MUST NOT add*, which is false for most rows of its own flagship fixture — Victron’s day record maxes power where it sums yield — so an implementer either dropped signals the vendor reports or shipped a descriptor carrying a false MUST. Stated as* do not combine*, the rule is true for every domain, and vacuous rather than false for an enum or a flags word, where there is nothing to combine. The ancestor-device permission is fixture 6: its modules must be devices — they have serials, firmware, presence and runtime adoption — so a same-device-only rule left the product’s 4,200 W and its three modules’ 1,400 W with no expressible relationship, and a client drawing 8,400 W of sun on a 4.2 kW system through the one topology the model was extended to express. Whether the aggregate was reported or summed by us is a different question, answered by prov.*
P-163 — A repeated part that carries a label, a rollup, a command, or its own concern MUST be a component. A series element is anonymous and indexed.
Nineteen circuits modelled as a series cannot say which one is the range and which two legs merge into it. Sixteen cells modelled as components cost sixteen rows and sixteen signals for a number nobody names. The rule is what makes both fixtures fit, and a driver that reaches for the wrong one has made a decision it cannot take back without a rev bump.
P-164 — A device reporting an operating state this controller has no normalized value for MUST report validity 8 unnamed_state with no value, and MUST raise a Concern carrying the source’s own code in raw and its namespace in vns.
The Magnum BMK’s state of charge 255 = "Think'n" means still learning, not yet trustworthy — present, not absent, and not to be acted on. Xantrex’s 255 Data Not Available and Victron’s 255 Not available are the same shape. Mapping any of them onto the nearest state we know is how a behaviour acts on a figure the device has said is not ready, and rendering it as an integer puts state: 255 on somebody’s screen.
P-165 — An unrecognised value in any closed space listed in P-019, appearing in any field or any value position, in any response body or any event body, MUST surface that one row as unrecognised and MUST NOT invalidate the message. In the value position — Sample key 2, Series key 3 and ParamRow key 5 under vtype = 3 enum; History key 6 is not among them, because P-195 forbids an enum signal from carrying hist at all — the value MUST NOT be mapped onto a known member, MUST NOT be rendered as a number, and MUST NOT be replaced with a fallback. An unrecognised validity additionally means the value MUST NOT be rendered at all. An unrecognised outcome means unknown, possibly executed: it MUST NOT be rendered as either success or failure, and a client MUST NOT retry the request automatically.
Stated as a list of nine field names — which is how it was written — the one position P-125 governed is not in it, and P-125’s text is deleted in the same build step that scopes P-014 away from the response path. So a charger’s firmware adds charge stage 9, the controller knows it so P-164 does not fire, esp is recognised so nothing else fires, and the client renders stage: 9 or picks the nearest one it knows and shows float. That is exactly what P-125 was written in full to forbid, arriving through its own replacement. Stated by category it also reaches severity, concern_state, transport, unit, bucket, history_source, inventory_kind, presence and every outcome space, none of which the enumeration covered, and the five event bodies, which the phrase the response excluded. The outcome clause is the sharpest of the three: REGISTRY.md still carries a row saying an unrecognised outcome is rejected outright, and rejected and possibly executed are different things to tell somebody about a command that may have started a generator.
P-166 — A Command 0x08 MUST name dev, cmp and rev. The controller MUST refuse a rev that is not current with outcome 7 stale_topology before the request reaches a handler, and MUST refuse a kind not in the target’s cmds with outcome 8 wrong_target. At cmp = 0 the target’s cmds is DeviceRow key 13. Both checks run after PROTOCOL.md P-080’s dedup lookup, not before it: a command that already executed MUST be answered with the outcome P-120 recorded for it, because a byte-identical retry arriving after a rev bump is the ordinary shape of a lost Ack, and answering it stale_topology tells a client its command failed when it ran.
A client cached relay 3 = cmp 57*, somebody rewired and wrote configuration, cmp 57 is now the pump, and* turn on relay 3 starts a pump from four hours away. Six bytes on a message that is sent by hand, against a mistake nobody can see happen.
P-167 — A parameter is writable only through the Command kind named in its own via. There is no register address, offset or raw value on this wire, in either direction.
A compromised cloud with arbitrary write access to every attached instrument is worth more to an attacker than any single command. A vendor register nobody has normalized stays a read-only diagnostic row, and an engineer who genuinely needs to poke it uses the CLI on the bus.
P-168 — Acknowledging a concern moves state from 1 active to 2 active_acked and never to 3, 4 or 5. Only the condition going away moves it further.
A protection somebody has read is not a protection that has cleared. The two were one command called clear alarm*, and a charger started on the strength of an acknowledged over-temperature is what that word buys you. The earlier wording named a state 4 cleared that the enum called clearing_blocked, which is the one shape of error this rule exists to forbid, committed inside the rule itself.*
P-169 — The concern table MUST refuse rather than evict, MUST reserve MAX_CONCERNS − MAX_CONCERNS_BELOW_FAULT rows for fault and protection, and MUST report the refused count in Concerns key 6.
Twenty-four cell-imbalance warnings arriving first otherwise hide the pack fault that arrives twenty-fifth. Refusing beats evicting when the members of a collection are interchangeable; per-cell warnings and a pack-level protection are not, and the ones that decide whether charging is safe are the ones that arrive late.
P-170 — Every collection in this section is bounded by a named constant, and the controller MUST report the values it enforces in Hello 0x81 keys 18–29.
P-005’s argument one layer down. A parameter table with no cap cannot be compiled on a part with no allocator, and a total field wide enough to admit 65,535 rows is a client sizing an array against a page count nobody bounded.
P-171 — A page ends on its byte cap or its row cap, whichever binds first, and a row is never split. The row cap is what a decoder sizes a fixed array at; the byte cap is what decides how many arrive. A client MUST NOT assume both are reachable at once, and a page count MUST be computed as min(row cap, byte cap ÷ row width).
Reporting a number the controller does not enforce is worse than not reporting it, and three caps a client reads as simultaneous is exactly that: 40 samples and 7 full-width series are 1,577 bytes against a budget of 880. The division clause is here because this document stated the rule and then divided the row cap away in every page figure it printed.
P-172 — A series signal whose elements do not fit the remaining MAX_SERIES_ELEMENTS pool MUST be refused at registration, never shortened.
A fourteen-pack bank silently reporting twelve cells per pack is a bank whose imbalance nobody can see. The device attaches, the signal does not, and a Concern names it.
P-173 — topo_digest is the leftmost 8 bytes of SHA-256 over this preimage, and over nothing else:
"km43/v1/topo-digest" 19 bytes ASCII, no trailing NUL‖ rev 4 bytes, big-endian‖ for each descriptor row, in canonical order: the row's encoded CBOR map, byte for byte — exactly the bytes that appear inside the `rows` array of an Inventory 0x8D page
canonical order = `what` ascending (1 buses, 2 devices, 3 components, 4 signals, 5 parameters) then the row's own id ascending within each kindAn absent optional key contributes nothing, because it is not in the bytes. A label contributes the bytes of its CBOR text item, because the row encoder emits it. Neither side re-encodes: the controller hashes what its row encoder produced, and a client hashes the row bytes exactly as they arrived, which CborReader::raw hands it whole.
Key 7 fixed the order of rows and said nothing about the bytes of one — no domain label, no statement of which representation, nothing about absent optional keys, nothing about whether label is in the hash. Build step 4 calls this the only test that can see the one silent failure this design has, and under a preimage that hashes the rows the controller holds rather than the bytes it sends*, changing a label moves no offset and the test cannot go red. The domain label is P-043’s rule applied to a third kind of domain input, which that table gains a row and a sentence for. rev is in the preimage so a digest can never be lifted from one revision to another. The re-encoding question is P-017 and P-120’s, and it is answered by construction rather than by exemption: both sides hash bytes that were produced once, by an encoder, and neither decodes and re-encodes to get them. And because vectors/v1.json is generated by a tool forbidden to depend on km43-proto, the preimage has to be written precisely enough that a second implementation reproduces it — which is the whole reason to write it out.*
P-174 — cmp = 0 is reserved in every message that names a component and means the device as a whole. A ComponentRow MUST NOT be allocated cmp = 0. SignalRow key 3, ParamRow key 3, Concern key 3 and Command key 4 all use it, and none of them expresses device scope by omitting the key.
Every other id space starts at 1 for the same reason, and 0 is reserved in all of them: sig, pid and cid MUST NOT be allocated 0, because 0 is already spent as the end-of-paging sentinel in Inventory key 4, Readings key 6 and Concerns key 4. A driver that allocates sig = 0 makes next = 0 unreadable — a client cannot tell resume at signal 0 from the selection is complete, so it either stops a page early and renders a dashboard missing the well-pump circuit, or loops on page one forever.
BusRow annotates 0 and DeviceRow annotates 0; ComponentRow key 1 was bare, and nothing forbade a driver allocating component 0. On fixture 2’s inverter/charger that could be the AC transfer relay, and then stop generator, this device and stop generator, the transfer relay are the same bytes with no Ack field to separate them. Device scope was also encoded three ways across four messages — 0 in Command, absence in Concern and ParamRow, not at all in SignalRow — which is why presence, the signal the whole presence-versus-validity separation rests on, could not be described by a SignalRow at all and could not be selected by a Sel. One convention, four messages, and a client can ask whether a device is online.
P-175 — An absent cmds means the target accepts no commands. An empty array is not a legal encoding and is error 1. A DeviceRow carries cmds for its own cmp = 0 scope, including dev 0 for the controller’s own commands.
ComponentRow key 8 was the one optional key in the document with a MUST attached and no stated meaning for absence, so P-166 was either a no-op on every component a driver author left blank or optional was false for every commandable component. And 0x0203 acknowledge concern takes a cid from the controller’s own table, so it targets dev 0 / cmp 0 — which had no cmds list anywhere, making the design’s own new command unsendable on every reading of its own rule.
P-176 — A dialect MUST declare, per register cell, the in-band pattern its vendor documents for this unit does not have this, or declare none. The pattern is matched on the raw register bits at the cell’s own width, before sign extension, width conversion and scale. A driver meeting a declared pattern MUST publish validity 4 unsupported with no value and MUST NOT publish it as a reading. A reply that did not arrive intact is validity 5 sensor_fault, and a number that arrived but cannot be carried at the registry’s scale is validity 6 out_of_range (P-185). None of the three is the other, and none of them carries a value.
This is the wire half of a seam map.rs has already opened: Absent::AllOnes and Absent::Raw(u32) on a Cell, and Reading::NotOnThisUnit against Reading::Unreadable in driver.rs. Declared and never assumed, because most vendors use no sentinel and inventing one deletes the top reading of every register they really use. P-164 does the enum-shaped case and stopped there; the numeric one is what COVERAGE-RESEARCH calls the most dangerous translation on the whole path, and it had no rule, no place to declare a pattern, and no fixture. 0xFFFF at scale −2 is well-formed, MAC-verified, P-157-compliant and P-165-compliant, and it raises an over-voltage concern against a healthy array, makes the shading comparison across trackers nonsense, and sits in the history bucket’s maximum forever. Validity 6 out_of_range cannot catch it, because 655.35 V is a plausible number on a 450 V unit.
P-177 — A signal a given model or firmware does not implement MUST be published as a SignalRow at validity 4 unsupported, and MUST NOT be omitted from the inventory.
Two conforming controllers on identical hardware otherwise give a client two different answers about what the site can measure, with different cap accounting and a different digest — one publishes 320 signals and the other 316. Publishing is also the cheaper of the two on the path that actually happens: the vendor confirms three times on one page that availability is a function of model and firmware version*, so a downstream firmware update that adds* maximum allowed panel voltage would, under the omit reading, be a topology change that bumps rev and discards every client’s cache. Under this rule it is a validity transition from 4 to 1 — an 0x0102, no bump, no walk. That is also why topology_change_reason has no capability changed value: the case exists, and it is not a topology change.
P-178 — ebase is the label of element 0; element k’s label is ebase + k, and the default is 1. Concern key 5 elem is the element’s position, 1-based, in 1..n. A client renders the label as ebase + elem − 1.
SignalRow key 10 — key 12 since the shape/vtype split — said the index of element 0*, Series key 2 said* byte k is element k’s*, and Concern key 5 said* 1-based element index*, so one word was taken from each vocabulary and no sentence joined them. The bounds table’s own example is a 32-cell string as two 16-element signals with ebase 1 and 17, which is fixture 3. Pack 2’s cell 23 goes over voltage: one encoder writes elem = 7 meaning the seventh element, another writes 23 meaning the label, one client renders 7 and another renders 23. All parse, all MAC-verify, and somebody drives four hours and pulls the wrong cell. This document is origin-conscious everywhere else it cares —* 0-based on ReadSignals, 1-based instance… Never 0 on ComponentRow, in ascending bucket order on History — so the omission read as an oversight rather than an implication. Position was chosen over label because it keeps elem inside MAX_SERIES_LEN, which keeps it a u8 and costs two bytes rather than three; a concern above element 255 was inexpressible under the other reading anyway.
P-179 — Series key 4 age is the age of the oldest element at validity 2. An element at validity 1 has no age.
Per-element validity was grafted in precisely so one open sense wire does not blank fifteen good cells, and the age then stayed per-series and REQUIRED when any element is stale, with no rule saying which one it described. Cell 3’s sense wire is intermittent and its last good reading is two hours old; the other fifteen were read eight seconds ago. One controller sent 7200 and every client rendered the whole pack as unreadable, so the operator drove out; another sent 8 and a client rendered cell 3’s two-hour-old 3.9 V as current, and a balancing decision was made on it. Both conformed. Naming the oldest costs no bytes and errs toward caution, which is the direction PROTOCOL.md’s own re-basing rule takes.
P-180 — state 5 cleared is terminal. A concern row leaves the table only by an 0x0502 carrying state 5, and its cid MUST NOT be reallocated until that record is committed to the log.
Without a state meaning over*, all four legal values were forms of still-there and a protection that ended at dawn had no encoding. Without a removal rule, no sentence anywhere said a row ever leaves, so with refuse-not-evict a cold soak’s thirty-two per-cell rows sat against MAX_CONCERNS = 48 indefinitely and P-169’s fail-safe test passed over a table permanently full of last spring. Without the cid rule, 0x0203 acknowledge concern lands on a reused id. DEFERRED entry 13’s own exit condition names cleared in as many words.*
P-181 — A concern’s accumulated age MUST be persisted with the row and carried across a restart. A controller that cannot recover the accumulated age MUST NOT report a fresh one.
age runs on P-004’s boot-relative tick against a RAM table, so a low-temperature protection that has blocked charge for six hours reports age = 12 after a brown-out and the operator reads this just started*. Key 10 since re-bases the same way, being first-observation too. PROTOCOL.md already answers this exact collision elsewhere with a MUST that re-bases in the over-retaining direction; this one re-based in the under-reporting one, with no rule at all. Four bytes a row in the store.*
P-182 — The controller MUST NOT enqueue more than one 0x0102 and one 0x0902 per tick. Each carries an array of what fits its cap; entries that do not fit are carried to the next tick and MUST NOT be dropped. 0x0501 and 0x0502 together are bounded at MAX_CONCERN_EVENTS_PER_TICK per tick on the same terms. The class A events one tick can produce MUST sit below MAX_EVENT_QUEUE with a stated margin.
One RS-485 pair going intermittent flips every signal behind it in one pass — up to 384 at the site cap — and class A cannot be dropped, so P-098 closes each of eight sessions, the reconnect replays the burst from the log and closes again, and L-022 puts the controller on the ladder at the third shed in an hour: every session dropped and the log saying comms link lost about a chip that answered every heartbeat. MAX_EVENT_QUEUE is the one existing bound this design multiplies by ten and it appeared in no bounds table, no Hello key and no build step.
The clears are counted with the raises, and an earlier revision of this rule bounded only the raises. That gap is the same failure arriving through the fix for a different one: P-180 makes an 0x0502 mandatory as each row leaves the table, and a cold soak’s thirty-two per-cell concerns all clear at dawn in one tick. Thirty-two class A events, a queue of sixteen, and the controller is back on the ladder — having been put there by the requirement that gave the lifecycle a state meaning over*. Bounding raises and clears separately would have the same hole one level down, so the cap covers both kinds together.*
The coalescing is bounded without a queue because the pending set is derived from a one-byte announced-validity value per signal, so nothing is ever enqueued and therefore nothing can be dropped.
P-183 — The resolved selection of a ReadSignals is the set union of its selectors, deduplicated by sig, ordered by sig ascending. A page is a prefix of that order and from/next are sig ids. A cmp selector matches that component and, transitively, every component whose parent chain reaches it; a dev selector matches that device and, transitively, every device whose parent chain reaches it. cmp = 0 is not a legal selector — device scope is a dev selector — and a Sel carrying it is error 1.
The same ordering rule governs a ReadConcerns walk, by cid ascending. rev cannot pin that one, because concerns deliberately do not move it (P-155), so Concerns key 2 seq is the pin instead: a client MUST treat a page whose seq differs from the first page’s as a torn walk and restart it. Without that, a cid released under P-180 and reallocated mid-walk is a row a client sees twice or never, in the table that decides whether a charger may start.
P-153 makes the controller reproduce the set from scratch on every page, and only a normative order makes that reproducible. Order it by anything volatile inside one rev — poll-ring arrival, freshest first, selector order — and the indices shift between page 1 and page 2 at the same rev and the same sel: the client resumes at 40, gets a set that overlaps what it holds and omits signals it will never see, with next and total reconciling perfectly and the MAC verifying. With the push deleted the client keeps last poll’s value with its q byte still reading ok, so validity 2 and age cannot fire on a sample that never arrived — a dashboard missing the well-pump circuit at 2 a.m. Union rather than concatenation because total differed between conforming controllers for one request otherwise, and total is the field P-170’s own rationale exists to make sizable.
P-184 — In a History 0x90, key 5’s own byte length is the number of buckets returned and keys 6, 7, 8 and 9 are sized from it, never from the request’s count. A bucket’s validity MUST NOT be 2 stale or 3 initialising — both say something about a live reading’s relation to now, and a closed window is not a live reading; a bucket either holds a value or names why it does not. Key 6 therefore carries one integer per bucket at validity 1, and key 7 is REQUIRED whenever any bucket is at validity 1 and is at least 1 for each such bucket. Key 8 is REQUIRED iff the signal’s vtype is 2 counter, is exactly ceil(len(key 5) / 8) bytes, and the bucket at index k of the response is bit k mod 8 of byte k div 8, counting bit 0 as the least significant. Bits past the last bucket MUST be zero. Any length disagreement is error 1. The returned window is contiguous: an interior gap — a bucket the store does not hold, with buckets it does hold on both sides — ends the response at that point with stopped = 3 gap_in_record and next naming the first bucket after it. A window running past newest is 4 end_of_record and one starting before oldest is 5 older_than_store; stopped is evaluated in ascending order like an outcome, so a response that hits a seam and then an edge names the seam. There is no encoding for no bucket here inside a window and there deliberately is not one — absent says the device is not present and unsupported says it does not implement the signal, so a client meeting either inside a chart plots fourteen hours of unavailability that never happened.
“Exactly count bytes” and P-156’s “it stops there” cannot both hold, and every other array is sized off the same number, so one wrong sentence mis-indexes the values, the sample counts and the reset bits together. One implementer returns 40 buckets and violates key 5; another pads 96 and must pick a validity for buckets that never existed — absent says the device is not present, unsupported says it does not implement it, neither means no bucket here — so a client plots fourteen hours of unavailability that never happened. reset is the only sub-byte-packed field in this document, and P-036 states its bit order in as many words because it was previously drawn as frag: u7, last: u1 with no byte layout, which two implementers pack differently. A reset flag one bucket out puts the discontinuity in the wrong hour and sends a technician after a shunt that is fine.
P-185 — Every value position on this wire is a signed integer in the i32 range: Sample key 2, Series key 3, ParamRow keys 5, 11 and 12, and History key 6. A receiver MUST reject a wider one with error 1. A source value outside the range MUST be published at validity 6 out_of_range with no value — never clamped, never wrapped, never widened. A ParamRow has no validity, so there the key is simply omitted, which is already what never read encodes.
This is P-089 restated for the messages that replaced the one it named, and it is not optional bookkeeping: five byte derivations in this document spend it. Widen key 6 from an i32’s 5 bytes to a full-width integer’s 9 and a documented-legal count = 96 takes the History body from 845 to 1,229 and the frame to 1,263, against a MAX_PAYLOAD of 1024 — exactly the frame P-089 exists to stop the controller building and then refusing at a fully configured site. cbor.rs and limits.rs both cite P-089 by number and are corrected to cite this. Clamping was the tempting alternative and it is the same defect as the sentinel: a number at the rail is a number, and nothing downstream can tell it from a measurement.
P-186 — A vtype = 4 flags value is a bitmask in the value position. Enum-space member m occupies bit m − 1. Bits 0 to 30 are allocatable; bit 31 MUST NOT be allocated, because the value position is an i32 and a set bit 31 is a negative number. A set bit with no allocated member MUST be surfaced as unnamed bits set, naming the bit numbers, and MUST NOT be dropped.
The document never stated that a flags value is a bitmask, never mapped a member to a bit, and had no rule for an unallocated bit — where the silent default is fail-safe-inverted: a protection a newer firmware added renders as no protection active. Member 1 → bit 0 or bit 1 was a coin toss between the capability mask’s Bit 0 precedent and every enum space in this proposal starting at 1.
P-187 — Neither parent chain — DeviceRow key 7 or ComponentRow key 3 — may form a cycle, and neither may exceed MAX_TOPOLOGY_DEPTH. Both are refused at config write and at runtime adoption.
rollup had an acyclicity rule and parent had none, while parent is what P-183’s containment walk and every client’s tree reconstruction traverse, on a part with no stack guard. Runtime adoption is not a config write, so the one check P-162 relied on did not cover the path fixture 6 exists to prove.
P-188 — (dev, cmp, kind, shape, vtype, domain, point, dir) MUST be unique within a rev across all SignalRows, and (dev, cmp, kind, vtype, domain, point, dir) across all ParamRows. A collision is refused at registration.
A charger’s absorption, float, equalize and maximum-regulation voltages are otherwise four rows all called DC voltage on one component, and the installer picks the wrong one to compare against. Nothing anywhere said the tuple was unique, which also means nothing said a client could key a cache on it.
P-189 — DeviceRow.addr MUST be present when the bus transport is an addressed one (rs485, can, ip), and MUST be unique within a bus. A receiver MUST refuse an inventory page that breaks either.
Two devices on one RS-485 pair with no addr are indistinguishable to a client — it can see two chargers and cannot say which one is at which end of the wire, which is the first thing somebody walking up to the cabinet needs. Two devices with the same addr describe a bus that cannot work at all. The controller already refuses the second case at registration — Misconfigured::TwoDevicesAtOneAddress — so this is not a gap in the implementation but in what a receiver may rely on. This was recorded as a note against this document for one full revision, under a paragraph saying in as many words that the fix is a numbered requirement and not a note, because a note is read two ways.
P-190 — On any Inventory 0x8D outcome other than 1, next MUST be 0 and digest MUST be absent.
next = 0 is the natural encoding of nothing follows*, which under key 7’s original condition made the whole-topology digest mandatory on a response that answered nothing. On outcome 4 out_of_range the rev matches, so neither P-151 nor P-152 fires and a client can stamp a cache it never assembled.*
P-191 — A walk MUST terminate. A staged sub-device that stages and un-stages more than three times inside one hour on P-004’s tick MUST be quarantined: not adopted, rev does not move for it, and a Concern bay_flapping is raised against the enclosing device until it has been stable for the window.
Losing the race against MIN_REV_INTERVAL_MS costs the client its whole walk — P-153 answers superseded with an empty array, P-152 forbids partial reuse, and nothing capped consecutive supersessions or raised anything a person could see. A marginal bay contact is the ordinary failure of a connector at an unattended site, and the visible symptom is a client that renders nothing while every individual rule is satisfied.
P-192 — vendor_namespace (vns) is a registry space allocated in protocol.toml, one namespace per vendor, with the vendor range and skip-unknown rule of P-019. A vns a receiver does not recognise renders the row with the namespace unnamed under P-165; it does not invalidate the row.
vns was REQUIRED on three rows, P-160 rested on it, and it was in neither registry list, excluded by the build step’s own count of nineteen spaces, and had no allocation authority. Two drivers both pick 7 and the namespacing evaporates, which is the failure REGISTRY.md’s allocation rule exists to prevent.
P-193 — Every outcome this document allocates has exactly one trigger, and a responder MUST evaluate them in ascending order and return the first that applies. A malformed request is error 1 and is refused before any outcome is computed.
| Response | Returned when | |
|---|---|---|
| all four | 1 ok | the request was answered, in whole or in part |
| all four | 2 superseded | rev is neither 0 nor the controller’s current one (P-153) |
Inventory 0x8D | 3 unknown_kind | what is not in 1..5 |
4 out_of_range | from is greater than the largest id the request resolves to — of that kind, and of that dev when one was named | |
Readings 0x8E | 3 unknown_selector | a Sel names a dev, cmp or sig that does not exist at this rev |
4 out_of_range | from is greater than the largest sig in the resolved selection | |
Concerns 0x8F | 3 out_of_range | from is greater than the largest active cid |
History 0x90 | 3 unknown_signal | sig does not exist at this rev |
4 series_not_historable | the signal exists and its shape is 2 series | |
5 no_history | the signal exists, is a scalar, and carries no hist key | |
6 out_of_range | first is past newest, or bucket is finer than the signal’s hist |
Not one of these had a trigger. Readings outcome 3 was selection_too_large, whose only plausible cause is routed to error 1 elsewhere in the same document, and outcome 4 was attached to no condition at all — while a sig that does not exist at this rev had no answer anywhere, where History had unknown_signal for exactly that case.
The ordering clause is the half that is easy to miss, and it decided the numbering rather than merely describing it. A series signal never carries hist, so it satisfies both series_not_historable and no_history — and under ascending evaluation the lower number wins. Numbered the other way round, series_not_historable would be unreachable and ReadHistory key 2’s own sentence, MUST NOT be a series signal — outcome 4*, would name a value no controller could ever send: a client would branch on it forever while receiving* no history recorded for a cell-voltage string that is structurally unchartable. Two conforming controllers answering 4 and 5 to one request is a client branching on a number that means different things depending on who it is talking to. This is the review’s own named pattern — a fact the design clearly holds, written as a gloss in a field table instead of as a numbered requirement — and P-193 and P-194 are the last two places it was still true.
P-194 — A history bucket index is the number of whole bucket periods elapsed since this controller’s first closed bucket — not a count of buckets closed — so a period in which nothing was recorded still consumes an index. The index is held in FRAM and survives a restart. Across a restart the controller MUST advance it by the whole periods the wall clock says passed — which is P-004’s own last sentence being taken at its word, the wall clock timestamps records and decides nothing else, and a bucket index is a record’s timestamp rather than anything on the control path. Where that gap cannot be established — the clock was never set, or was not set when the controller went down, or the RTC reports its backup domain invalid (LINK.md L-143) — the controller MUST NOT invent a width: it advances by one, marks the discontinuity, and History answers stopped = 3 gap_in_record with no claim about how wide the gap was. The buckets on the far side stay servable; a response never spans a gap, so each one carries its own at and no client is ever asked to date across a hole. ReadHistory key 4 and History keys 4, 10, 11 and 12 are all expressed in the units of the bucket the request named; the three bucket sizes nest exactly — 4 quarter-hours to an hour, 24 hours to a day — so the conversion is exact and has no rounding rule to get wrong.
Two readings of one sentence, and both of them break something. As a count of closed buckets, a controller down for three days in January closes nothing: index n+1 sits three days after index n, and a client dating the window from key 15 — its only anchor — draws a seventy-two-hour outage as one quarter-hour and mis-dates every point after it. That is the failure key 7 and P-156’s truncation are both written against, arriving one layer above where either can see it. As elapsed periods, the gap is on the axis and P-184 stops the response at it, which is a mechanism that already exists.
The clock clause is the part that cannot be waved at. P-004’s tick is monotonic since boot and restarts at zero, so it cannot measure a gap that spans the boot — which is exactly the gap this requirement exists for. An earlier revision said “elapsed on P-004’s tick” and was therefore unimplementable for its own worked example. The wall clock is the only source that can measure it, and P-093 already forbids inventing one, so a controller that does not have it says there is a gap here and refuses to say how wide, which is a smaller claim than the one it cannot support and a larger one than silence.
The units half is the one that produces two conforming controllers rather than one wrong chart. Keys 4 and 10 are positions in a response and keys 11 and 12 describe a store, and P-193’s outcome 6 compares them — first is past newest — which is not a comparison unless they share a unit. A client asking for the last 96 days sends first = newest − 95; against a controller holding its axis in quarter-hours that is 24 hours, and it is either answered or refused with both sides conforming to every word written. Keeping the counter at the signal’s finest hist and dividing down costs a division per request.
P-195 — When the requested bucket is coarser than the signal’s hist, the coarse bucket is aggregated from its constituents by a rule fixed by (vtype, domain) and by nothing else:
| The signal is | Aggregated as |
|---|---|
vtype 2 counter, any domain | the last constituent’s value — a counter is a running total, not a rate, so summing four quarter-hours of energy today reports four days of it |
vtype 1 gauge, domain 8 max | the maximum |
vtype 1 gauge, domain 9 min | the minimum |
vtype 1 gauge, domain 6/7 limit | the last — a limit is a state, not a quantity to average |
vtype 1 gauge, every other domain | the mean, weighted by each constituent’s key 7 sample count |
vtype 3 enum or 4 flags | not aggregatable. A signal of either MUST NOT carry hist; ReadHistory answers outcome 5 |
The q byte aggregates too, and it is the one that sizes every other array. A coarse bucket is validity 1 ok when any constituent is; otherwise it takes the validity of its earliest constituent, so the reason a window is empty survives instead of being averaged away. Its provenance is 3 derived whenever more than one constituent went into it, because at that point the number is ours. Key 7 aggregates by sum — which is also what discloses that a coarse bucket rests on three readings of an expected ninety-two — key 8’s bit is set when any constituent had a reset, and key 9 is 2 controller_derived for every aggregated bucket, not only where the constituents disagree: the controller did the arithmetic, whatever the device supplied underneath, and key 9 exists to keep Victron’s own day record distinguishable from what we integrated.
hist says a coarser request is “aggregated from this” and said nothing about how, which is a whole chart’s meaning left to whoever implemented it first. Mean and last differ by the entire value of a lifetime counter: aggregating four quarter-hours of energy since reset by mean reports a quarter of the total, and by sum reports four times a day’s worth. Both render, neither warns, and the one that reads low is the one that makes a site look like it is producing less than it is. Weighting the mean by key 7 is the same rule key 7 exists for one layer down — an unweighted mean over a window where one constituent had three readings and another ninety-two is the smooth-line lie with extra steps. And enums are excluded rather than given a rule because there is no honest answer: the mean of two charge stages is not a charge stage, and the last one in the window is a fact about the sampling, not about the site.
What this replaces
Section titled “What this replaces”Messages
Section titled “Messages”Snapshot 0x02 / 0x82 | Retired. Both numbers held forever, never reused. There are 83 free request opcodes, so scarcity is not a reason to reuse them; a bench build still sending the old body should meet error 2 rather than a body it misparses into readings that look fine |
0x0D / 0x8D | New — ReadInventory / Inventory |
0x0E / 0x8E | New — ReadSignals / Readings |
0x0F / 0x8F | New — ReadConcerns / Concerns |
0x10 / 0x90 | New — ReadHistory / History |
Subscribe 0x03 / 0x83 | Unchanged. It stays the event stream. There is no reading push |
Command 0x08 | Operation body gains keys 3 dev, 4 cmp, 5 rev; args moves from key 3 to key 6. cmd_id stays u32 and kind stays key 2 |
Firmware 0x09 | Its target vocabulary becomes a dev from this inventory. That is entry 6’s second device-id space deleted by construction; entry 6 still owns the bodies and the manifest |
Hello 0x81 | Key 13 retired; keys 18–29 added |
All four new request numbers are at or below 0x17, so the request type stays one CBOR byte.
Structures and enums
Section titled “Structures and enums”Value | Retired in full — channel, kind, value, quality, at |
quality (5 values) | Retired. The registry row keeps the reason: it ran together whether a reading is trustworthy with where it came from, so a counted value could not also say it went stale |
Hello 0x81 key 13 max_channels | Retired under P-012. The number is not reused |
Limits
Section titled “Limits”MAX_CHANNELS | Retired. It was two things wearing one hat: the site’s semantic capacity and a response cap. It is now MAX_SIGNALS (384, RAM) and MAX_READINGS_BYTES (880, wire) |
MAX_CHANNELS_CEILING, MAX_SNAPSHOT_BODY, VALUE_MAX_BYTES, SNAPSHOT_BODY_BYTES, SNAPSHOT_VALUES_HEADER_BYTES, SNAPSHOT_FIXED_BYTES | Retired with the message they derive. Done, with P-089 — which left MAX_CHANNELS the one cap in limits.rs with no ceiling over it |
ENVELOPE_BYTES / WRAPPER_BYTES | Corrected. They double-counted the body map header and omitted the second byte a 0x8x response type costs; the two errors cancelled exactly. Split into ENVELOPE_BYTES 11, WRAPPER_CONTENTS_BYTES 22, RESPONSE_TYPE_EXTRA_BYTE 1. Every existing derivation keeps its answer, and MAX_OPERATION_CEILING is untouched |
MAX_EVENT_QUEUE | Unchanged at 16, and now load-bearing. P-182 derives what one tick can produce against it, which nothing did before |
Registry allocations
Section titled “Registry allocations”Retired metric kinds, each because it was a place smuggled into a quantity:
0x0130 start battery voltage | It is 0x0101 on a component whose role is start-battery. That row could only ever describe one second bank, and this is the fixture that proves the model removes registry rows |
0x0110 / 0x0111 / 0x0112 PV array V/I/P | The same three DC kinds at a pv-array or mppt-tracker component |
0x0120 / 0x0121 load current/power | The same kinds at a load-output component |
0x0105 battery temperature, 0x0301 ambient temperature | One temperature kind; where is the component |
Renamed, not retired — a widening, so every existing reading of them stays valid: 0x0101 battery voltage → DC voltage; 0x0102 → DC current, signed, positive into the component; 0x0103 → DC power, signed; 0x0302 probe temperature → temperature.
Split: 0x0205 AC energy becomes ac energy imported and ac energy exported, both counters, both at scale 1 so an i32 spans 21.4 GWh. A counter cannot be signed — a monotonic total that reverses is a lie — and instantaneous AC power stays one signed kind with dir stating the convention. That is what fixture 5’s signed import/export power actually needs.
New metric kinds for stage durations: time in bulk, time in absorption, time in float, counters at scale 0 in minutes. They were previously to be carried as one kind with a point naming the stage, which is a place smuggled into a quantity — the thing four rows above this one are retired for.
Retired event kinds: 0x0101 value changed (class B). It was the only class B kind; its rate followed a sampling loop rather than anything happening at the site, and at nineteen circuits it overruns MAX_EVENT_QUEUE every tick. The number is held. Class B stays defined with no members.
Redefined event kinds (all currently reserved, so nothing has exchanged them): 0x0102 → signal validity changed, class A, coalesced array body. 0x0501 alarm raised → concern raised, class A. 0x0502 alarm cleared → concern changed, class A. New: 0x0901 topology changed (A), 0x0902 device presence changed (A, coalesced array body). Those five bodies are specified here, which discharges five of entry 10’s rows.
Retired command kind: 0x0202 clear alarm. The number is held and not reused; acknowledging is new 0x0203 acknowledge concern, args {1: cid u16}, listed in DeviceRow(dev 0).cmds. Reusing 0x0202 for acknowledgement would put the two meanings on one number in the one place this design defines itself against them being the same word.
New command outcomes: 7 stale_topology, 8 wrong_target. Both are written into PROTOCOL.md beside Command 0x08, with P-166’s refusal rule, because every_live_number_is_reachable sweeps the settled documents and a number reachable only from a proposal is what it refuses.
New SetConfig outcome: 9 staged, for P-154’s config-write case. Entry 9 owns the number.
Retired config sections: 0x0002 channels and 0x0003 buses and devices. Splitting a signal’s owner from the signal is how the two disagree. New: 0x0004 topology, one section, and it is what rev versions. Entry 9 still owns its body.
New registry spaces, all declared in protocol.toml and generated:
Open, vendor range 0xF000–0xFFFF, skip-unknown under P-019: component_role, device_role, product, dialect, condition, enum_space, measurement_point, vendor_namespace.
Closed, no vendor range, P-165 on every path: transport, shape, vtype, domain, direction, validity, provenance, severity, concern_state, presence, unit, bucket, history_source, history_stop_reason, topology_change_reason, inventory_kind, plus one outcome space per new response.
presence is on that list and is reachable through esp, and both are true at once: it is the value of an ordinary signal at (dev, cmp 0), and it is also the discriminant in 0x0902 key 2, where there is no SignalRow in reach to name a space. An earlier revision said it was reachable through esp, not a discriminant field and then used it as one, which left 0x0902 key 2 with no registry behind it for a decoder to resolve against. control_owner is only ever a signal value, so it is only ever reached through esp.
Twenty-nine spaces, not the nineteen an earlier build step counted, and the count is broken out because the two ways of undercounting it are both easy:
8 open registries component_role device_role product dialect condition enum_space measurement_point vendor_namespace16 closed discriminants transport shape vtype domain direction validity provenance severity concern_state presence unit bucket history_source history_stop_reason topology_change_reason inventory_kind 4 response outcomes one space each for Inventory Readings Concerns History 1 reached only via esp control_owner──29The nineteen missed measurement_point and vendor_namespace from the lists entirely, and counted neither the outcome spaces nor the two esp targets — while topology_change_reason and history_stop_reason were discriminants on the wire with no space behind them at all. A space that exists only as a column of names in a design document is a space two drivers allocate differently.
unit is new because the registry carries units today as display strings on metric rows, and a vendor kind needs a number. topology_change_reason has no capability changed value, and P-177 says why.
Error codes: none added. An unallocated what is outcome 3 in the response; an over-long selection, a q whose length disagrees with n, an empty cmds array and a History length disagreement are all error 1. Error 15 stays withdrawn — it refused an over-cap Snapshot, and there is no longer a Snapshot to build.
Generated artefacts
Section titled “Generated artefacts”vectors/v1.json is regenerated wholesale, the Rust and TypeScript bindings with it, and snapshot.rs is deleted rather than adapted. Anybody holding a bench build against the current draft regenerates. That is only defensible because no client exists, which is true today and stops being true at the first independent one.
DEFERRED entry 13 — rows this closes
Section titled “DEFERRED entry 13 — rows this closes”- Attached-device inventory —
ReadInventory/InventorywithBusRowandDeviceRow: product, dialect, model role, serial, hardware and firmware revisions, bus address under P-189,parentfor a sub-device,presenceas a signal, andsincefor replacement. Bounded pages, stable ids, onerev, reported caps. Identity across replacement is visible here (P-156) and still owned by entry 1. - Repeated components —
ComponentRowwithparent,role,index,labelandrollup. A client with no site-specific code reconstructs the tree fromparent, the instance number fromindex, the human name fromlabelor fromrole+index, and the aggregate relationship fromrollup. There is nopv-power-2anywhere, and P-161 forbids one. - Signal description —
SignalRowcarries the owning device and component, quantity,shapeandvtypeon two axes,domain,point,dir,n/erole/ebasefor series,espfor enum and flags spaces, andhistfor what is chartable. P-160 keeps unit and scale in the registry for standard kinds and requires them on the row for vendor kinds, so a standard quantity cannot be redefined by a descriptor and a vendor quantity can still render. - Capacity and delivery —
MAX_CHANNELSretired. Inventory pages and selected reads, both bounded by two arms with derived ceilings and stated margins, and both costed against the six fixtures rather than asserted. 32 is gone as a whole-site cap; 880 bytes is the response cap and 384 signals is the site’s semantic cap, and the two are different numbers because they were always two different things. - Value shape and validity —
shapenames the container andvtypenames what one value is, which is what lets fixture 3’s per-cell balancing bitmap exist at all: a series of flags was not expressible while oneu8carried both axes, and the closure list claimed it closed as balancing as a flags series. Validity (8 values) is separated from provenance (6), socountedandstaleis0x22. Large lifetime counters are closed by the registry rather than the wire, the same way P-089 closed it:ac energy imported/exportedat scale 1 gives ani3221.4 GWh, which a 5 kW circuit reaches in 490 years. Text stays in descriptors and never on a reading. - Active concerns —
ReadConcerns/Concernswith a stablecid,dev/cmp/sig/elemsource down to one cell,sev, a five-state lifecycle ending in a terminalcleared, a monotonicagethat survives a restart, an optional wall-clocksince, a normalizedcond, and the vendor code preserved inrawwith its namespace. Acknowledgement is separated from clearing by P-168 and removal is stated by P-180. Transitions stay entry 10’s, and0x0501/0x0502now have bodies. - Operating state and control ownership —
vtype = 3 enumplusespnames the state domain in a field, which retires P-125.control owneris an allocated enum space — local panel, this controller, BMS, GX/ESS, remote client, device automation — carried as an ordinary signal withprov = reported, so the BMS is driving the charge voltage and we are not is a reading rather than an inference. P-164 gives the enum-shaped vendor fallback a home that cannot be mistaken for a normalized state and P-176 kills the numeric one before it becomes a reading at all. - Electrical topology and flow — roles for
ac-input,ac-output,phase,line-pair,pv-array,mppt-tracker,dc-output;pointfor the measurement point, now an open space so a function nobody has allocated is labelled rather than unrenderable;dirfor the sign convention; signed instantaneous power with the convention stated on the row; two counters for import and export because a counter cannot be signed. Which AC input is live is a per-component enum, not a global index. - Battery/BMS safety surface —
battery-bank,battery-pack,heater,contactor,fetroles; cells and probes as series elements under P-163, which is the rule that puts thirty-two cells in two series signals and nineteen circuits in nineteen component rows rather than the reverse; balancing as ashape 2/vtype 4series of flags under P-186; CVL/CCL/DCL asdomain = limit_upper/limit_lowerwithprov = 5 reported, so a behaviour reads the BMS’s stated limit and never infers one; active protections as concerns addressed toelemunder P-178’s stated origin. Command authority never substitutes for observing the limit, because the limit is a signal and the command is a different message. - Device parameters and capabilities —
ParamRowwith the current value, the device’s permitted floor and ceiling, thedomain/point/dirthat tell four DC voltage rows apart, andvianaming the one Command kind that writes it. Read-only on the wire (P-167), bounded byMAX_PARAMS, reported inHellokey 25, and entirely distinct fromGetConfig. A client renders 40 A of 200 with no site-specific code. - Firmware targets —
Firmware 0x09targets adevfrom this inventory. The second device-id space entry 6 was waiting on is deleted by construction; entry 6 keeps its bodies and its signing manifest. - Communication health —
presenceis a signal at(dev, cmp 0)(online / degraded / offline / never_seen), reachable by aSelbecause of P-174;last_seen_ageis a counter, and bus CRC errors and timeouts are counters on the controller’s own device row. All three are separate from a signal’svalidity. A healthy device behind a broken bus readspresence = onlinewithbus_timeoutsclimbing and its signalsstalewith an age — three different sentences the oldqualityhad one word for. - Evolution and limits —
revplustopo_digestversion the whole descriptor set, with P-173 giving the digest a preimage and P-151 giving it a defined consequent and a trigger that fires. Every new collection has a named bound, a documented behaviour when full, and aHellokey. P-019 extends the vendor range to eight open spaces; P-165 gives the closed ones a rule that reaches every field, every value position and every event body without blanking a page.
DEFERRED entry 13 — rows deliberately left open
Section titled “DEFERRED entry 13 — rows deliberately left open”- History and counters — half closed, and the half that is open is not this entry’s.
ReadHistory/Historycloses the client-facing shape: bucket size, bucket identity as elapsed periods on a monotonic FRAM axis in the requested bucket’s units (P-194) rather than the vendor’s wrappingun16— a client treating that as monotonic mis-orders a year boundary, and a controller counting closed buckets instead of elapsed ones collapses a three-day outage to nothing — an authoritative response length with a cursor and both store bounds, sample count per bucket so a mean over 3 of 92 cannot render as a mean over 92,resetfor counter wrap with its bit order written out,diron the signal for direction, per-bucketsrcseparating a device-supplied day record from one we integrated, andstoppednaming why a response is short. What stays open is DEFERRED entry 3’s packed NOR record — its field list and its partial-window rule. Nothing here forces entry 3’s hand: the wire carries no positional record and a controller may serveReadHistoryfrom any store.MAX_HISTORY_SIGNALSis 24 and entry 3’s ring is sketched at 20 channels, and those are two different numbers for two different things — what a client may chart against what the NOR record holds. They are not required to agree, but if entry 3 lands at 20 then four of the 24 are served from somewhere else or not at all, and that is entry 3’s call to make rather than this one’s to pre-empt. Two documents must not disagree about one record, so this one deliberately does not describe it. - Targeted control lifecycle — the common target is closed, the rest is not.
Commandnamesdev,cmpandrev;cmdsenumerates what a device and a component accept, with absence meaning nothing; outcomes 7 and 8 are the machine-readable refusals; desired against observed needs no new machinery because aprov = 6 commandedsignal sits beside aprov = 1 measuredone, which is how a welded relay reads observed closed against commanded open. Left open: the per-kind argument schemas (entry 8 owns them and is not touched), and lease/deadman, progress and cancellation. A lease is not a wire field on its own — it needs a renewal path and a revert rule, and a revert rule is a decision about what a controller does to an output when nobody is talking to it. That belongs with the first granted output, not here. - Config section bodies — entry 9, unchanged in scope and owed two numbers.
0x0002 channelsand0x0003 buses and devicesare retired and0x0004 topologyreplaces them, so the thing those sections configure finally has a wire shape to be configured against. The section body itself is still entry 9’s, along with thesecretrule, theshadowkey number, and nowSetConfigAckoutcome 9staged, which P-154 requires and this document does not allocate. - Event bodies — entry 10, mostly unchanged. Five bodies are specified here because this entry owns their content:
0x0102,0x0501,0x0502,0x0901,0x0902. Entry 10 keeps the other sixteen of its twenty and must be edited in the same commit, because it currently saysalarm raisedneeds a discriminator and this design answers that by retiring the kind that needed one. - Channel identity across reassignment — entry 1, not settled. P-156 makes the seam visible at both the device and the component level and refuses to plot history across either. It does not answer entry 1’s actual question, which is what identity costs in the durable aggregate record — and that cost is entry 3’s byte budget, which this design deliberately does not spend. The interim advice stands and now reads as do not reuse a component id; allocate a new one, with the reserved runtime block sized so a hot-swapping site does not run out.
- Security and conformance — entries 2, 4, 5, 7, 11 and 12, untouched. Confidentiality, rate limiting, the later Noise handshake, BLE and MQTT evidence, spec-level checks, and
req_idadmission remain exactly where they were. A complete data model still sits on all six, and this entry neither duplicates nor hides them. Entry 12’s finding in particular is unaffected: P-077 still refreshes a session on any authenticated frame, before P-022’s check, and four new request types are four more frames that can do it.
What the review changed
Section titled “What the review changed”Sixty findings were raised on four lenses — a reading that is wrong and looks
right, two implementers working from the document alone, fidelity to the vendor
PDFs, and the bounds arithmetic recomputed against cbor.rs. Each was handed to
a separate reader whose job was to refute it. Fifty-two survived, sixteen of
them blocking. The verdict was build with changes.
The shape survived and is not reopened. Two planes joined by one rev;
rollup separating containment from accounting; esp naming the enum space in a
field; every instance number living on a component’s role + index;
per-element validity; presence as a hot signal against membership as a cold
descriptor; refuse-not-evict with the refused count on the page; the
(rev, digest) pair in Hello. Every attack that reached those came back
refuted. Do not re-run the four-design comparison.
What did not survive was the document, and this revision is the answer. Each blocker below leaves the list by naming where it is answered above, which is the only form of closed this repo accepts.
| # | The blocker | Answered by |
|---|---|---|
| 1 | A loose terminal takes the radio off the air: 0x0102 is per-signal, class A, and class B is empty | P-182, coalesced 0x0102/0x0902 bodies, the class A burst, P-003/096/097/098 amended, and build step 8’s 384-in-one-tick test |
| 2 | The concern lifecycle has no state meaning over, and P-168 names a state the enum does not have | state 5 cleared, the five-row state table, P-168 corrected, P-180, and build step 7’s clear-thirty-two test |
| 3 | Nothing kills a vendor’s numeric absence sentinel | P-176, P-177, the validity 4/5/6 split, and build step 5’s test that starts before a validity is chosen |
| 4 | cmp = 0 is spent and never reserved; 0x0203 is unsendable | P-174, P-175, DeviceRow.cmds, SignalRow.dev |
| 5 | elem and ebase are two numberings with no sentence between them | P-178, elem recosted at 2 bytes, fixture 3 pinned to ebase = 17 in build step 11 |
| 6 | One age for sixteen independently-valid elements | P-179 |
| 7 | Event bodies and LogEntry carry ids under no rev | P-152 extended, rev as key 1 on five bodies, the polled-versus-pushed sentence |
| 8 | History cannot say how many buckets came back | P-184, keys 5 and 10–14, outcome 4 for a series signal, and P-194/P-195 for what a bucket index and a bucket value mean |
| 9 | n and reset are optional with no condition, and reset states no bit order | P-184 |
| 10 | P-165 lists fields only; P-125’s content is deleted; flags is unspecified | P-165 restated by category, P-186 |
| 11 | topo_digest has no preimage and its trigger is the bug it catches | P-173, P-151’s trigger, the RAM section’s label correction |
| 12 | Every page count divides the row cap away; no rule makes a walk terminate | P-171’s division clause, the recomputed page table, MAX_INVENTORY_PAGE_ROWS 16 → 48, P-191 |
| 13 | ReadSignals resumes at an ordinal into an undefined order, and two outcomes have no trigger | P-183, from/next as sig ids, P-193’s trigger table and its evaluation order |
| 14 | shape cannot be both series and flags, so fixture 3’s bitmap has no encoding | the shape/vtype split, P-186 |
| 15 | Constants with no value, with no key, in two tables, and a check that cannot compile | MAX_BUSES 8, MAX_CONCERNS_BELOW_FAULT 24, Hello keys 20/24, byte caps unreported, the #[test] replacing the const_assert!, the fixture table, 43 constants |
| 16 | Four places this document and the settled ones disagree | cmd_id u32, P-185, P-192, topology_change_reason allocated |
Two of those changed a number rather than a sentence, and both are worth naming:
MAX_SIGNALS moved from 320 to 384 because the fixture table said 276 does
not fit 256 writable, and MAX_INVENTORY_PAGE_ROWS moved from 16 to 48
because at 16 a typical row wasted two-thirds of a page and the walk was three
times the length MIN_REV_INTERVAL_MS had been justified against. Neither was
visible until the arithmetic was printed.
Checked and refuted, so they are not re-litigated: MIN_REV_INTERVAL_MS
derived against a worst-case walk driven by presence (forbidden by P-155 — the
walk arithmetic was wrong for the different reason blocker 12 names); rev in
the signed operation body making a legitimate retry undeliverable; P-019 and
P-165 giving opposite answers for a non-vendor unknown role or cond;
rev = 0 as a wildcard; MAX_HISTORY_SIGNALS = 24 against fixture 1; the
inner-body encode buffer frozen at today’s cap; the open registries’ flash cost;
Series key 3’s ordering being pinned positionally by key 2; cross-implementation
paging divergence in Readings being harmless because every row carries its own
sig; MAX_SAMPLES and MAX_SERIES being correctly fixed rather than reported;
the Command key reassignment being legitimate because Command is reserved and
nothing has exchanged the body.
What this revision does not claim. The per-page BLE round trip that
MIN_REV_INTERVAL_MS is justified against is a projection, not a measurement, and
it is the first thing to check the first time a browser talks to real hardware.
The flash estimate is likewise an estimate; cargo size is the check and it is
in the build order twice. The fixture cardinalities are counted from
DEFERRED’s prose and the committed catalogue rather than from six built
fixtures — step 11 is where they stop being arithmetic. And one code change is
named here rather than made: Reading::Unreadable in map.rs carries two
findings under one word, and step 5 splits it.
On method. Three passes were run against this document by fanning work out across parallel authors, and two of them failed the same way: 137 edits that could not be applied in any order because seven of them claimed one paragraph, then eighteen problems of which eight were regions nobody had been assigned. The design was never what failed — no round asked for a different shape. This revision was written by one editor holding the whole document front to back, and that is the finding worth keeping: a specification is a single artefact with cross-references in every direction, and parallel authorship on one puts the defects exactly where nobody is looking, which is the seams.
Build order
Section titled “Build order”Twelve commits, smallest first. Every one lands with tests, and the ones that produce a committed artefact land with the artefact and an empty diff on everything else.
This section had gone stale, and in the direction that costs the most. It was audited against the tree after two of its claims turned out to be false — the nine metric-kind retirements were eight, and step 6c was written believing
Readings 0x8Ehad no vector whenv1.jsonhas carried one, with three tests against it, all along. Both were asserted from this document rather than checked against the artefact. So the rest was checked, and three more steps were finished without their status being written down:
- Step 3’s code is landed and its spec half was not, which this note got wrong twice.
inventory.rshas the table-driven encoder and the two-arm page cap, with tests naming each arm binding first, andv1.jsoncarriesinventory_0x8D. What this note then claimed — that the promotion happened and the wall was routed around and then removed — was false in both halves. Only the two wrapper bodies were promoted; the five row bodies were not, so 61 published row keys sat inv1.jsonwith nothing in any settled document defining them. And the wall was never in the way:bodies_match_the_specreads each vector entry’sbody_readable, the row blobs carryrow_cbor/row_leninstead, so it never looked at a row and could not have blocked one. A check that cannot fail is not a wall somebody climbed. The rows are promoted now, as P-200 through P-207, andpublished_rows_match_the_specis the check that can see them — it reads the key numbers out of the published CBOR, with somebody else’s decoder, becausevectors.rswrote those bytes and a check that decodes them with the encoder under test agrees with itself no matter what either says. It was watched going red three ways: a key renumbered in the document only, a required key marked optional, and a row body deleted.bodies_match_the_specstayed green through all three, which is what it had been doing all along.- Step 4 is landed.
Hello 0x81keys 18 through 29 are allocated and read, and key 19 is the topology digest.- Step 5’s
map.rssplit is landed.SensorFaultandOutOfRangeare separate variants carrying separate validities, rather than the onewrite_absentthe note describes.This note first claimed that four rules — P-151, P-173, P-183 and P-188 — live only here and are invisible to the coverage gate. Three of the four were wrong, and the way they were wrong is worth more than the claim was. They were checked by grepping
PROTOCOL.mdfor**P-151**and finding nothing. But the two documents number independently, the promotion renumbers, and the mapping table two hundred lines above this one says so in full: P-173 is there as P-148, P-151 as P-149, P-183 as P-198. All three are promoted, implemented, and covered by tests named after their promoted numbers —p_148_,p_149_ininventory.rs. The gate has been watching them the whole time.The lesson is the one this session kept paying for: search a document by what a rule says, not by what it is numbered. A label is the one part of a requirement guaranteed not to survive being moved.
What survived was one instance, it was real, and it has now been promoted.
P-188—(dev, cmp, kind, shape, vtype, domain, point, dir)unique within arev— was in neither the mapping table norPROTOCOL.mdunder any number or any wording. It is P-207 there now, andSignal::same_identityis cited by six tests named after it.The promotion found more than it went looking for, and the pattern is worth more than any of it. Promoting one rule was supposed to be a rename. It was not, because the rule named eight fields on two row types the settled document had never defined — so the row bodies had to go first, and with them seven more rules. Then
Signal::same_identityturned out to be comparing seven fields of which three —vtype,domainanddir— nothing was watching: each could be replaced withtrueand all 453 tests stayed green. That is the same finding as theshape-versus-length leg one commit earlier, three more times in one function, and it says the method needs a test per leg rather than a test per story. The general shape: a rule the gate cannot see accumulates unguarded implementation behind it, because nothing is asking.Genuinely not started: steps 7 (
Concerns 0x0F/0x8F— no module, no spec section, no vector), 8 (class B;0x0101 value changedis stillreserved, so nothing has exchanged it) and 9 (config sections;0x0002and0x0003are stillreservedand0x0004 topologyis not allocated).
1. Correct the framing constants and add every new bound to limits.rs. No wire change at all. Split ENVELOPE_BYTES / WRAPPER_BYTES into ENVELOPE_BYTES 11, WRAPPER_CONTENTS_BYTES 22, RESPONSE_TYPE_EXTRA_BYTE 1, and prove the existing derivations keep their answers. Add the forty-three constants of the Bounds section with their ceilings and an assertion each, including CLASS_A_TICK_CEILING against MAX_EVENT_QUEUE.
Watch it fail: raise MAX_READINGS_BYTES to 949 and see the assert go red; raise MAX_SAMPLES to 48 and see the other one; raise MAX_VALIDITY_SWEEP to 87 and see the event body exceed MAX_EVENT_BODY; set MAX_CONCERN_EVENTS_PER_TICK to 14 and see CLASS_A_TICK_CEILING cross MAX_EVENT_QUEUE. Then put them back. A ceiling nobody has watched go red is an assertion about the author’s intent.
2a. Give the registry somewhere to put an open u16 space. This is a prerequisite nobody costed, and step 2 cannot be written without it. The registry has exactly three shapes: enums.* is u8 and closed, codes.* is u16 and closed, and metrics/events are u16 and open. Open is not a property a space can declare — is_open is called twice in bindings.rs, with the literal strings "metric_kind" and "event_kind", and nothing else in the tree reads meta.skip_unknown at all. So the eight open registries this design allocates have no home: put them in enums.* and they are u8 when they need u16; put them in codes.* and the generated binding is a closed enum for a space whose whole point is that a vendor value must skip rather than fail, which is P-019 inverted in the generated code.
Adding their names to skip_unknown would be worse than useless — it is a list checked against nothing, so the eight names would sit there being inert while the bindings stayed closed, and the file would read as though the job were done.
The fix, and it is small: let a space declare open = true on itself rather than be named in a list somewhere else, generalise open_newtype from two hardcoded calls to a loop over the open spaces, and make an open space refuse a u8 width. A space that is open in one file and closed in another is the same defect class as a bound under two spellings, and the list form cannot go red when a name is misspelled.
Watch it fail: mark one space open, regenerate, and confirm its binding is a newtype rather than an enum; then misspell it and confirm the build refuses rather than silently emitting a closed enum.
2b. Allocate the registry spaces in protocol.toml and regenerate. Twenty-nine new spaces, the retirements, the renames, the metric split, the three stage-duration kinds. vendor_namespace and topology_change_reason are in this commit or P-192 and P-165 have nothing behind them. Every new allocation lands with status = "reserved", not live — the rules that produce them are in this document, which every_live_number_is_reachable does not read, and reserved is what that sweep skips. The new spaces implement nothing yet, and the renames are a widening — a metric’s generated constant is screaming-cased from its name, so battery voltage → DC voltage renames MetricKind::BATTERY_VOLTAGE at every call site, but the number and the meaning are untouched.
The retirements are a different thing, and none of them lands here. A retirement can only go in the commit that dismantles what depends on it, and three separate attempts to land them in this step were reverted by the tree:
-
The eight metric kinds cannot be retired until a behaviour selects on a component.
0x0110 PV array voltage,0x0130 start battery voltageand0x0101 battery voltageare published at the same unit and the same scale, so under this design what tells them apart is the component — and until then, the kind is the only guard there is. Collapsing them intoDC voltagemakes a generator start on the solar array’s voltage at noon, andthe_solar_array_at_noon_is_not_the_bank_and_does_not_start_an_enginegoes red saying so. That step is 5b below — and 5b has landed without unblocking them, which is worth writing down rather than discovering twice.Why not. 5b moved the selection into the seam: a season names a
Binding, resolves it, and reads thesigit got. But the guard is still a kind check inside the behaviour —Autostart::bandcallsnumber(stored, MetricKind::DC_VOLTAGE, …)and refuses anything else, which is whatthe_solar_array_at_noon_is_not_the_bank_and_does_not_start_an_engineactually exercises. A behaviour is handed a barekm43_proto::Readingcarrying no evidence of which binding produced it, so the kind is the only thing it can check. Collapse the three and that check evaporates.What unblocks it, now built and now plumbed:
Observed— a reading that cannot be built except by reading through aWatch— the seam’s answer carrying the binding that produced it, sobandtrusts its provenance instead of re-deriving it from the kind.Store::observeis the only constructor outside a test, both seasons read through it, andAutostart::bandandInputsnow take anObservedrather than a bareReading. The kind check insidebandis redundant rather than load-bearing, which was the whole precondition — it goes in the same commit as the kinds, and nothing else is holding them. Three things were settled getting here, and the first two did not go the way this document said they would:How
band’s own tests build one — the question dissolved rather than being answered, and this document had it wrong. The prescription here was afixturesfeature the firmware never enables and the simulator does, on the reasoning thato89-sim’s band witness must build anObservedwithout asking the store ando89-corecompiled as a dependency is not in test mode. The reasoning about test mode was right and the premise about the witness was wrong: a band is a number against two thresholds, so the witness never needed a reading at all and therefore never needed to build one.Observed::builtis#[cfg(test)]andpub(crate), there is nofixturesfeature, andStore::observeis the only constructor in any build the firmware can produce. That is a stronger guarantee than a feature flag staying off — it is not a thingcargo treehas to be asked about — and it deleted a feature rather than adding one. The general lesson is worth more than the specific fix: a witness that needs the same fixtures as the thing it witnesses is usually witnessing at the wrong level.And it is
Inputs, not justband— done.Inputscarries the four readings, so it carriesObserved, andInputs::numberreads the binding’s kind too. The thirty-odd fixtures acrossmachine.rsandreading.rswent through oneseen(kind, reading)helper written once, which is what kept it a mechanical change; the alternative was a substitution run across multi-line call sites, and this repo has already paid for that lesson three times.What is actually lost. Today
bandrefuses a foreign reading at runtime: hand it the array’s voltage and it answersNone, which is whatthe_solar_array_at_noon_is_not_the_bank_and_does_not_start_an_engineasserts. Afterwards a wrong binding is refused at resolution, at commissioning, bySignals::resolve. That is a better guard — a component tells an array from a bank and a kind soon will not — but it is a different one, and the defence-in-depth of having both goes away. The test asserting the runtime refusal has to become a test asserting the resolution refusal, and somebody should say out loud that a behaviour handed the wrong signal by a correct-looking config now has nothing left to catch it. -
Snapshot 0x02/0x82cannot be retired untilsnapshot.rsis deleted, which is step 6. Retiring the message removesMessageType::Snapshot, and the file that implements it is still in the tree. -
Event
0x0101 value changedcannot be retired until class B is dealt with, which is step 8. It is the only class B kind, so retiring it leavesoutbound.rs’s drop-oldest-first tests with nothing to test the mechanism with.
What this step does land: the twenty-nine spaces, the four renames, the AC energy split as two new numbers beside a still-live 0x0205, the three stage-duration kinds, 0x0203 acknowledge concern, the two command outcomes, SetConfig outcome 9, and the four new message pairs. Every one of them is additive.
Two smaller things the tree decided rather than the plan. The four new message types make empty.rs, signed.rs and wrapper.rs non-exhaustive, which is the compiler asking whether each is signed, wrapper-authenticated and empty-bodied — it is the exhaustive-matching rule doing review, and the answers go in this commit. And the space carrying SignalRow.domain is allocated as signal_domain, because Domain is already P-043’s MAC label; the field keeps its short name the way dir does over direction.
The acceptance test is an empty diff: the generated Markdown and bindings for every space that did not change must be byte-identical. cargo xtask registry && git diff --stat names exactly the tables that moved or it does not go in. REGISTRY.md’s hand-written prose — the class A/B paragraph and the unrecognised-outcome row — moves in this same commit, because cargo xtask check gates on the generated tables beside it and a document nobody edited by hand must not be the thing that fails.
What the gate will not tell you: it does not sweep metric or event kinds, so every new kind here is unreachable-by-construction and silently green. Count them by hand against the table above, because nothing else will.
3. Inventory 0x0D / 0x8D — the cold plane. All five row kinds behind one table-driven encoder, the scratch-and-raw() page builder, the two-arm page cap with min(row cap, byte cap ÷ width). Vectors: one page of each kind with every row at its widest, and one with every optional key absent.
The vectors are blocked, and on the thing this document has been deferring. Resolved — see the audit note at the head of this section. What follows is why it was blocked and what unblocked it, kept because the reasoning is the general one and it applies again to Concerns 0x0F/0x8F. bodies_match_the_spec refuses a published body whose message PROTOCOL.md does not define, so vectors/v1.json cannot carry an Inventory 0x8D while Inventory 0x8D exists only here. That is the second gate to say it: every_live_number_is_reachable said the same at step 2, and both were treated as walls to route around — reserved rather than live, vectors held back — when they were saying something simpler. A message being implemented belongs in the settled document. The proposal status was true when nothing was built against it and stopped being true at step 1. Promoting ReadInventory 0x0D / Inventory 0x8D into PROTOCOL.md — its two bodies, and the requirements that produce its numbers — is what unblocks the vectors, and it is a decision about this design’s status rather than a step in building it.
Watch it fail: add one byte to a DeviceRow’s worst case in the row-width table and see the #[test] that encodes a worst-case row through the public encoder go red. It is a #[test] and not a const_assert! because CborWriter is not const fn — the earlier draft prescribed a check that cannot compile. This is the check that stops the row-width table becoming a second copy of the encoder, which is what happened to COBS in this crate already.
4. Hello 0x81 keys 18–29, and the digest. Key 13 and MAX_CHANNELS stay for now — Snapshot still uses them. Implement P-173’s preimage exactly, and ship its vector in vectors/v1.json before any controller computes one, generated by the tool that may not depend on km43-proto.
Watch it fail: change one label in a ComponentRow without bumping rev, and see the digest go red. Then edit a ComponentRow without bumping and confirm the controller’s cached digest was invalidated by the row write rather than by the bump — a controller that recomputes only on the bump passes the first half of this test and is exactly the defect P-151 exists to catch. If either stays green, P-151 is prose.
5. Readings 0x0E / 0x8E. The message landed in km43-proto some time ago; the store-to-wire path landed with Store::readings, which walks in ascending sig order and pages on next. What is still owed here is the map.rs split below and the two vectors. Scalars first, then series. The q-byte packing, P-157’s present-iff invariant, P-183’s ordering, and the sig cursor. Split Reading::Unreadable in map.rs in this commit: a short reply and a value that will not fit the scale are 5 sensor_fault and 6 out_of_range, and they collapse into one write_absent today.
Watch it fail: two tests, and the first one is the one that was missing. Feed 0xFFFF through a dialect cell that declares Absent::AllOnes, starting before any validity has been chosen, and grep the encoded bytes for any integer that could be read as that signal at its scale — the earlier draft’s version started from an already-chosen sensor_fault, which is precisely why it could not see the failure. Then a vector with elements 3 and 9 at sensor_fault: flip one q byte to ok without adding an integer and see P-158’s length check refuse it with error 1.
5b. The store on the signal plane, and a behaviour that names what it watches. This is the step this document has been referring to as missing, in three separate places, and it is one step rather than three. Step 6 waits on it because snapshot.rs holds the store’s value model. The eight metric-kind retirements wait on it because a behaviour cannot select on a component until the store holds one. And the capability audit in CONTROLLER-V1.md cannot be generated until a behaviour’s needs are a thing the compiler can read.
The failure it prevents, stated once: 0x0110 PV array voltage, 0x0130 start battery voltage and 0x0101 battery voltage are one unit at one scale. Today the kind is the only thing keeping them apart, and it is doing a job it was not designed for — which is why the design wants to collapse all three into DC voltage. Do that first and a generator starts on the solar array at noon. So the component has to become the discriminator before the kind stops being one, and never the other way round.
A behaviour names a binding, and never a sig. A sig is allocated by config or by runtime adoption and is only meaningful within a rev. A behaviour holding one across a revision bump reads whatever now occupies that id — which is P-152’s failure exactly, arriving inside the controller where no rev check is watching for it. The frost behaviour that was heating a cabin would be reading the battery pack’s probe, with a perfectly current q byte on it.
What a binding is: the tuple P-188 already declares unique — (cmp, kind, domain, point, dir), with dev implied by cmp and shape/vtype implied by what the behaviour can consume. Not (cmp, kind) on its own: a charger’s absorption, float and equalize voltages are one kind on one component and differ only in point, which is the four-identical-rows failure keys 7 to 9 exist against. A binding that omits the discriminators resolves to whichever row the table happens to hold first.
A binding resolves once per rev, not once per tick. Not for speed — 384 rows at 1 Hz on an M0+ is nothing — but because a resolution that fails must be visible. Resolved per tick, a binding naming a component nobody configured is an absent reading forever, which is the same shape a dead probe has, and the one thing this store has just learned to tell apart. Resolved at the bump, it is a commissioning error with a name: config says the cabin’s air temperature and the topology has no such signal, raised as a concern and surfaced, once.
The quality floor is a set and not a threshold, and CONTROLLER-V1.md currently says otherwise. Its audit reads quality ≥ counted, which was defensible when quality had five values in a rough order of trust. It has stopped being true: provenance allocates 1 measured, 2 counted, 3 derived, 4 estimated, 5 reported, 6 commanded, and those numbers are an allocation order rather than a trust order. 5 reported is not worse than 4 estimated — a BMS stating its own charge-current limit is the most authoritative number on the bus, and a behaviour comparing >= would refuse it and accept an inference instead. A behaviour declares the set of provenances it will act on. Fix that line in CONTROLLER-V1.md in the same commit, because a >= written from it compiles and is silently wrong.
Bounds: one binding table, MAX_WATCHES entries, refused rather than evicted. Every binding carries the sig it resolved to at the current rev and the reason it did not, and both are readable — the second is what a commissioning laptop needs.
This document called that constant MAX_BINDINGS and the tree calls it MAX_WATCHES, because session::Binding is already a different sense of the word in the same crate — a session bound to a transport — and session.rs’s own prose says one of eight bindings about a table that is not this one. Two eight-entry caps called bindings, meaning different things, is the kind of collision somebody reads past. The table holds Watches, so the cap is named after what it counts, the way Signals holds Signals.
What lands with it: the store keyed by sig, Freshness moving from a channel to a signal, series signals holding n elements against MAX_SERIES_ELEMENTS, frost::Reading::celsius’s unit-and-scale filter becoming a binding on the cabin’s component, and the metric-kind retirements this unblocks.
Landed so far: Signal, Signals, Binding and Watch; then the store keyed by sig, with the metric kind and Freshness read off the Signal rather than copied into the slot — Store is [Option<Slot>; MAX_SIGNALS], 384 slots of 48 bytes, 18 KB against the 15 KB the signal table costs, and Device names sigs rather than channels. Scalars only: Store::register refuses a Shape::Series row, because a slot holds one number and publishing the first element of a cell-voltage series as the pack is worse than refusing to hold it at all. Both seasons hold Watches now rather than Sig constants. Frost names the cabin’s air temperature on the probe’s component; the generator names four, three of which share a component and are separated by kind alone — which is the first time that discriminator carries weight outside a unit test, and 15 season tests go red if two of the bindings are made to name one signal. Then Observed, and Autostart::band and Inputs taking one rather than a bare Reading, which is what makes the kind check in band redundant and discharges the last precondition the retirements were waiting on.
Still ahead. Three things, in this order, and the second is no longer a guess:
-
The element pool againstLanded. A series declares its length throughMAX_SERIES_ELEMENTS.Extent { Scalar, Series(SeriesLen) }—SignalRowkeys 5 and 10 fused, so the wire’snis required iffshapeis series cannot be violated by construction rather than by an encoder remembering.Storeholds one shared[Element; 512]run,registerallocates from it by bump and refuses a series that will not fit whole (P-172), and the refusal names the shortfall because this pack wants sixteen and four are left is what somebody at the cabinet can act on. Elements are read and written one at a time, each carrying its own three-state validity, so one dead cell does not blank a pack.Three things the tree decided rather than the plan. The store’s budget went 36 KB → 42 KB, for the reason recorded in the bounds table above plus 3 KB that
Extentcost by pushingSignalover an alignment boundary.Held’s two ticks became aRun, because a series ages as one thing under P-179 and there was no value to hang the question on — the alternative was a fakeHeldcarrying a zero, which is the invented default this project does not ship. And P-188’s identity compares the shape and not the length:Extentfuses them, so a derivedPartialEqwould call a fourteen-cell and a sixteen-cell row on one component two signals and register both. That last one was written as a comment first, and the comment was wrong — changing the comparison on purpose left all 443 tests green. The test that now catches it was written afterwards, which is the fourth time in this repo a guard has turned out to be prose. -
The binding table andLanded, asMAX_BINDINGS.MAX_WATCHES = 8. The demand was counted rather than estimated: two behaviours naming five watches between them — frost one, the generator four. Eight is that plus room for one more frost-sized behaviour, and it is deliberately not the 16 or 32 earlier proposals floated, because neither number appeared anywhere in the repo and both were room-to-grow with nothing under them. The cap is small on purpose: the table refuses rather than evicts, so it has to be a number a real site can reach and a person can then be told about — a cap nobody can hit is a cap nobody has tested.the_behaviours_in_the_tree_fit_the_watch_tablegoes red when a third behaviour is added and nobody thought about the number, which is what makes the derivation a live check rather than a paragraph.What the table was actually for, and it was not the cap.
Watch::resolvethrew the reason away: a binding that failed to resolve went back to holding no id, so a behaviour with no reading looked exactly like a behaviour whose probe had died. One sends somebody to the config file and the other sends somebody to the cabin, and nothing told them apart.Resolutionis three states now — never resolved, resolved at a revision, refused at a revision and here is why — andStore::unwatchedwalks every binding the controller holds and names the ones that answer nothing. That is the commissioning read this bound was written for, and it did not exist.The store owns the table, for the same reason it owns
Signals: a binding resolves at a revision, and handingobservea watch table per query would let it answer against whichever one it was given. That is the hole that closed when the topology moved inside. A behaviour holds aWatchIdand never aWatch, so there is no second table to hand over. -
The nine kind retirements.Landed, and there were eight.0x0105,0x0110–0x0112,0x0120,0x0121,0x0130and0x0301, markedretiredinprotocol.toml, which stops a constant being generated and made the compiler name every remaining use — five, all in tests, becauseObservedhad already done the plumbing. This document said nine in four places and its own table lists eight; the likely miscount is the temperature row, whose two retirements collapse into0x0302, a rename that got counted as a third retirement.frost::Reading::celsiuslanded in the same commit, as predicted, and it takes anObservednow. It still filters on unit-and-scale, and that turned out to be the right call for a reason the plan had wrong: the filter is not doing selection, it is guarding the scaleDeciCelsiusclaims. Selection is the binding’s job and always was. What changed is the kind it reads — the binding’s, not the reading’s.The argument, made out loud, and the plan was too kind to itself. This document said the guard moves from runtime to commissioning: caught by
bandansweringNonebefore, caught bySignals::resolveafter. That is true only for a binding naming something that does not exist. A binding naming the wrong but real component — the array’s, at the bank’s kind — resolves cleanly, bands cleanly, and nothing in the tree refuses it. The defence-in-depth is not relocated, it is gone. What replaces it is weaker in kind: the binding is explicit, held in one table, and readable at commissioning byStore::unwatched. A person, not a check. That is a defensible trade — a component tells an array from a bank and a kind soon will not — but it is a trade, andthe_solar_array_at_noon_is_told_from_the_bank_by_its_component_alonenow pins the hole open with a comment saying so, rather than the design claiming a guard it does not have.One more guard was found to be prose.
celsiuswas changed to read the reading’s kind instead of the binding’s, on purpose, and all 447 tests stayed green — the tank test used the same kind for both, so it could not tell them apart. That is the fifth time in this repo.frost_reads_the_kind_its_binding_named_and_not_the_one_that_arrivedcloses it, and the generator’s equivalent was checked the same way and already had two tests that fire.
The store belongs to a revision. It holds the rev its slots were registered against and refuses every read and every write against a table carrying a different one. Without that it answers whatever table it is handed: a cabin probe registered at one revision and read against the next, where the same id is now the pack’s voltage, publishes −5.0 °C as −0.050 V under a q byte reading measured and ok. Both halves are plausible alone, which is what makes it undetectable downstream — and it is the failure Watch was built to stop for behaviours, arriving one level below it, because nothing outside signals.rs holds a Watch yet.
Two answers to one question, on purpose. A sig the store holds and a same-revision topology does not describe — two pieces of configuration that disagree — reads as no reading at all, because there is no kind to publish it under and no window to age it against, and a behaviour handed nothing already fails safe — the store says which of the three it was, so a season does not have to guess. The same slot refuses the whole Snapshot, because a client watching a signal quietly vanish from the picture has nothing to point at, and that is not a fail-safe. MAX_SIGNALS is twelve times MAX_CHANNELS, so a full store cannot be published in one frame either way, and the refusal is now a path with a test on it rather than a comment saying it cannot happen.
Watch it fail: bind a frost behaviour to the cabin’s air temperature, bump rev with a topology where the battery pack’s probe now holds the old sig, and see the behaviour refuse to decide rather than heat on the pack’s temperature. Then delete the re-resolution and watch it heat. Second: give a charger an absorption and a float voltage on one component, bind on (cmp, kind) alone, and see the binding refuse as ambiguous rather than take the first row.
5c. The value model on the signal plane, and the enum space that is missing from it. This is the step 6c says does not exist. Its own words: snapshot.rs “defines Meaning, Reading and SnapshotError, which are the state store’s value model … and the thing that replaces it is the signal plane arriving in o89-core, which no step here describes.” Everything still owed by step 6 is owed to this, so it is written down before somebody else rediscovers the hole.
The failure it prevents. Meaning decides whether a number is a measurement or an enumerated state by matching MetricKind against a hardcoded list of three — GENERATOR_STATE, GENERATOR_SELECTOR_POSITION, LAST_BOOT_REASON. That is precisely the hardcoded map the opening of this document says esp exists to delete, and it is sitting inside the controller rather than inside a client. A charger’s firmware adds charge stage 9 and Meaning has no way to know the signal was ever an enum, because the only thing that told it was a kind somebody wrote out by hand.
What is missing, measured rather than assumed. EnumSpace(u16) is generated into km43-proto, and esp appears as an optional key in the SignalRow and ParamRow shape tables — key 13 and key 10. Nothing produces it and nothing consumes it, and o89-core names neither esp nor EnumSpace anywhere. So Signal carries vtype and knows a value is an enum, and cannot say which space it is drawn from — which is the exact state this document’s second paragraph calls “straight back on the hardcoded map”, arriving one layer lower than the client it was written about.
The order, and each step is blocked by the one above it:
-
Landed, asSignalgains the enum space.Nature. NotOption<EnumSpace>besideVtype— the wire’s rule is required iffvtypeis 3 or 4, and two independent fields can express the pair the rule forbids. Fused the wayExtentfused shape and length:Gauge,Counter,Enum(EnumSpace),Flags(EnumSpace), so the illegal combination is not constructible rather than merely rejected, andspace()answersNonefor a number rather thanEnumSpace(0).The blast radius was six lines, and the identity was the whole job. Every construction site in the tree was
Vtype::Gauge, so the field rename cost nothing. What it did cost is a second leg of P-207’s identity:same_identitycomparesnature.vtype()and not the wholeNature, exactly as it comparesextent.shape()and not the length. CompareNaturewhole and two enum rows of one kind on one component — one drawn fromgenerator_state, one fromcharge_stage— both register, and a binding resolves to whichever the table held first. A behaviour reading generator state is then handed a charge stage whose 3 meansabsorptionwhere it meansrunning: both numbers small, both legal, nothing downstream able to tell them apart. Watched: replacing the leg withself.nature == other.natureturnsp_207_two_enum_signals_drawn_from_different_spaces_are_one_signal_declared_twicered and nothing else, which is what says the test guards that leg and not the neighbourhood.Fusing a pair of wire keys into one type is now the second time a derived comparison would have been quietly wrong. That is worth stating as a rule rather than as two incidents: a fused type needs its identity leg written by hand, and the leg needs a test that goes red when it is deleted.
-
Meaningreads the signal, not the kind. The discrimination becomes a function of(Sample, Signal), and the three hardcoded kinds stop being special. This is where P-165’s rule actually lands in code, and it is what makes P-125 retirable rather than merely stale. -
Behaviours read a
Sample.Observedcarries one instead of aReading,Slot::reading’s wall-clock fallback goes — that is 6b’s owed half, and its precondition was met the momentStore::readingspublishedSample— and the five store tests naming P-091 and P-092 repoint to P-196, which states both rules for the message that replaced the one they were written about. -
Then step 6 finishes: P-091 and P-092 retire,
reading.rsgoes, and the### Snapshotsection leavesPROTOCOL.md.
What this is not. It is not a mechanical substitution. Quality and Reading are named about 250 times across eleven files in o89-core and o89-sim, and the lesson this document has already paid for three times is that a scripted edit across a region being restructured costs more than writing the region. Step 3 above is the one that touches every file, and it is the one that wants a single seen(...)-shaped helper written once — the same thing that kept Inputs mechanical in 5b.
Unsure, and recorded rather than decided: whether Meaning survives at all after step 2, or whether a behaviour asking is this a number wants a method on the signal instead. The smaller option is to keep the enum and change what feeds it, and that is what is written above — but it is written by somebody who has not yet tried the other one.
6. Retire Snapshot, and everything that hangs off it. 0x02/0x82, Value, quality, MAX_CHANNELS and its five derived constants, Hello key 13, P-089 through P-092. Delete snapshot.rs. Regenerate vectors/v1.json wholesale. The tree never carries two ways to read state, because a shim for a client that does not exist is the thing this project state forbids. Blocked on 5c, which is where its remaining half actually lives.
This step named P-006 and P-125 too, and both are live rules that survive the message — the Retired section above says which condition each one waits on. P-093 was never on this list and must not be added to it. It is amended above, and it is the only sentence in the settled spec forbidding a zero that means unknown — but its text was about Snapshot key 2 and Value key 5, so it read like part of the message and would have gone out with the section.
It has been moved. P-093 is now stated by category — every field carrying a wall-clock time — in the ## State and events preamble, above the subsection that dies. Measured rather than assumed: delete the ### Snapshot section from PROTOCOL.md, run cargo xtask check, and the citation check names P-125 once and P-091/P-092 seven times, and does not name P-093. That list is the actual remaining cost of the section, and it is the two requirements whose blockers are recorded above — not five.
It is not one commit, and the reason is the fourth instance of this document’s recurring mistake. snapshot.rs does not only define the wire message. It defines Meaning, Reading and SnapshotError, which are the state store’s value model — o89-core and o89-sim name them in seventy-two places — and MAX_CHANNELS is the store’s array length. Deleting the file deletes what the controller holds, not merely what it publishes, and the thing that replaces it is the signal plane arriving in o89-core. A retirement can only land in the commit that dismantles what depends on it, and this one depended on a step that did not exist. It does now — 5c above, written once the hole had been walked into a second time from the other end: chasing what still blocked P-091 and P-092 arrives at the same missing piece as chasing what snapshot.rs left behind.
That step is 5b: the store keyed by sig rather than by channel, with behaviours naming a binding rather than an id. It is the same step the eight metric-kind retirements are waiting on — a behaviour cannot select on a component until the store holds one — so the two are one piece of work and not two.
6a. Give the store the reasons it was throwing away. Landed. write_absent(channel) took no argument at all and set the slot to None, so driver.rs’s three findings — a register the model lacks, a reply that did not arrive, a number that will not fit — arrived as one fact. Worse, a channel registered and never polled was the same None as a dead probe, so the first poll cycle after a boot read as a cabinet of broken instruments. Last is three states now, write_absent carries a Validity, and Store::quality produces the q byte a Readings will publish.
The five-value quality split with nothing left over, and the store had already done the splitting. The three a driver may claim — measured, counted, estimated — are provenances. The two Sample::read refuses — stale, absent — are validities. That is why Sample::read’s constructor rule reads as arbitrary and is not: it was separating validity from provenance a year before there were two words for it. So counted and stale — 0x22, the case one word could not express — needs no new information from anywhere.
Still owed by 5b, and found by reviewing its own commits. The topology and the store are one thing and the tree says so three times without saying it once: store.rs, driver.rs and bus/season.rs each invent a private struct holding a Signals beside a Store, two of them near-identical down to the comment. Meanwhile Bus::poll and Bus::file take the pair loose and are six-parameter functions, which is the lint naming its own fix.
And it does not want a new type at all. The obvious answer is a third struct owning both, which needs a name — Site already means three different things here, one of them public in o89-sim. The better answer is that Store owns its Signals. A store is built for one revision and can only ever be read against the topology it was registered under, so handing it a table per query is what created the hole in the first place. Owning it deletes the argument from five signatures, collapses the three fixtures, and makes the guard structural: there is no second table to hand over.
Landed. WrongRevision is gone — there is no second table to hand over, so the check it performed has nothing to check. NotInTopology and TooManySignals survive as answers the compiler cannot prove unreachable, each documented as such rather than unwrapped. StoreError::AlreadyRegistered is deleted: asked second, it could only ever agree with the topology’s refusal above it. The two tests that built a store disagreeing with its table by swapping a field cannot be written any more, which is the guarantee. Bus::poll and Bus::file are back to five parameters and every { signals, store } fixture is gone.
What 6a did not do: the store still publishes a Snapshot and still holds Reading. The array length was MAX_CHANNELS and is MAX_SIGNALS since 5b. Store::quality is the second reader of the same state rather than the only one, which is the shape this document forbids for a wire and tolerates for one commit inside a crate — on the condition that the next commit removes the first.
6b. A stale reading no longer needs the clock. Half landed. Store::readings ages a stale sample in whole seconds on the monotonic tick, so the new plane never asks for a date — which was the precondition. The fallback in Slot::reading is still there, because Snapshot is still the other output and producing stale without a timestamp would only build a body its encoder throws away. It goes with the file, in 6c. It is a behaviour change rather than a deletion, so it does not ride along with one. Snapshot’s stale carries a wall-clock timestamp (P-092), so Slot::reading reports absent for an aged-out value when the clock has never been set — P-093 forbids inventing the date. Readings key 4 age is a duration on P-004’s tick, which is monotonic since boot and which a controller always has. Under the new plane the fallback is unnecessary: a controller that has never been told the date still knows the value is two hours old, which is the part a behaviour needs. Delete the fallback in the commit that publishes Sample from the store, and not before — while Snapshot is the output, producing stale without a timestamp only builds a body the encoder throws away.
6c. The retirement itself. Landed in two commits. The first split the value model out of snapshot.rs — Meaning, Reading and its constructors into reading.rs — which is what turned this from a rewrite into a deletion. The second retired Snapshot 0x02/0x82 in protocol.toml, deleted snapshot.rs, Store::snapshot and StoreError::Snapshot, took the snapshot entries out of the vectors generator, and regenerated v1.json.
The compiler named the blast radius, which is what retiring a message type is for: empty.rs, signed.rs and wrapper.rs each carry an exhaustive list of every MessageType, so removing one made all three fail to compile rather than silently leaving Snapshot classified.
What the deletion cost in coverage, and what was done about it. Removing the snapshot tests took three requirements from covered to uncovered — P-019, P-090 and P-125 — and cargo xtask check’s traceability ratchet refused the build, which is exactly the job it was built for. Only one of them was actually retired. P-090 bounded values at MAX_CHANNELS and said there was no paging: both are facts about a message that carried a whole site in one frame, and Readings pages, so it is retired and its number held. P-019 and P-125 outlived the message — a vendor kind is still surfaced rather than rejected, and an unnamed state is still never rendered as the nearest one this build knows, both in Meaning, which is now in reading.rs. They had been covered only by the Snapshot decoder’s tests. They have their own now, named after them, in the module the behaviour lives in. The ratchet’s number did not move.
Still owed, and deliberately not in these commits: MAX_CHANNELS and Hello key 13. config.rs still sizes its channel array on MAX_CHANNELS, and that array belongs to the 0x0002 channels config section — which entry 9 replaces with 0x0004 topology. Retiring the constant before that section moves would leave the config plane with no cap at all. Key 13 rides with it, because it is what the controller reports the constant as. Repoint the Value-specific P-089 citations in limits.rs in whichever commit takes those; the four general ones in cbor.rs and handshake.rs have already moved to P-185, because they were never about a snapshot.
The gap this opened, and it was smaller than the commit that opened it said. p_092_a_store_encodes_the_snapshot_the_published_vectors_carry drove the store and compared its bytes to v1.json — the one check that an independent generator and this crate agreed about what the store publishes. It died with the message, and that commit claimed Readings 0x8E had no vector to replace it because the vectors were blocked on promotion into PROTOCOL.md. Both halves of that were wrong. Readings 0x0E/0x8E has been a settled section of PROTOCOL.md for some time — step 3’s blocker is Inventory 0x0D/0x8D and only that — and v1.json has carried a readings_0x8E body, with three tests in km43-proto against it, all along.
What was actually missing was one test, and it is written now. Nothing drove the store into those published bytes; km43-proto’s tests check the encoder against the vector, not the plane that fills it. a_store_encodes_the_readings_the_published_vectors_carry registers the two signals the vector describes, writes what each instrument said, pages the store, encodes the body and compares. It reads the hex out of the artefact rather than transcribing it into a const, which is the discipline this repo has had to relearn in four modules. Watched fail twice: once by moving the generator’s number and regenerating, and once by making the store emit a zero for the faulted cell instead of omitting it — the counting rule that turns three cells into three shifted ones and sixteen into sixteen.
7. Concerns 0x0F / 0x8F, including the severity-band admission, the refused counter, the five-state lifecycle and P-181’s persisted age.
Watch it fail: three, and the second is the one an earlier draft was missing. Raise thirty info concerns and then one protection, and assert the protection is admitted and the twenty-fifth info is not; delete the band check and see the protection get refused. Then raise thirty-two per-cell concerns, clear every condition, and assert the table is empty and thirty-two 0x0502s carrying state 5 are in the log — delete P-180’s removal rule and watch the table stay full and the next protection get refused, which is the failure the first test cannot see because it runs against a freshly built table. Then raise a protection, restart the controller, and assert the reported age is the accumulated one and not twelve seconds.
8. The five event bodies — 0x0102, 0x0501, 0x0502, 0x0901, 0x0902 — and P-182’s coalescing. rev as key 1 on every one of them, the two array bodies, the announced-validity byte per signal, and the carry-to-next-tick rule. Amend P-003, P-096, P-097, P-098 and REGISTRY.md’s class A/B prose for an empty class B in this commit.
Watch it fail: flip all 384 signals in one tick and assert eight ticks of one 0x0102 each, that every one of the 384 changes appears exactly once across them, and that the queue never exceeds CLASS_A_TICK_CEILING. Then delete the carry-over clause so an over-cap burst is dropped rather than deferred, and see the count come back 48 instead of 384 — a const_assert! over four constants cannot see that difference, which is why this step exists and why step 1’s assertion is not enough on its own. Then clear thirty-two concerns in one tick and assert 0x0501 and 0x0502 are bounded together: bound only the raises and the clears take the radio off the air by the path P-182’s rationale describes.
9. ReadHistory 0x10 / 0x90, with the bucket counter in FRAM, the per-bucket sample count and src, the cursor, and P-156’s truncation at both since boundaries.
Watch it fail: four. Ask for a day of a signal stored at quarter-hours and assert the aggregate matches P-195’s rule for its (vtype, domain) — then change a lifetime counter’s rule from last to mean and watch a day’s energy come back at a quarter of itself, which is the one wrong number on this path that renders cleanly and makes a site look like it is producing less than it is. Take the controller off for three days across the window and assert the response stops at the gap with stopped = 3, that next names the far side, and that at + k × bucket dates every returned point correctly — under a closed-bucket index it will not, and nothing else in the suite can see that. Then set DeviceRow.since mid-window and assert the response stops there with stopped = 1 and a next that resumes past the seam; then set ComponentRow.since mid-window on a different signal and assert stopped = 2, because one stop reason covering both seams was what an earlier draft had and it cannot tell a swapped charger from a rewired circuit — an assertion that could not be written while key 5 pinned the length to the request’s count. Then delete the truncation and watch a series run straight across two instruments. Add a vector with a reset in bucket 3 of 20, which is asymmetric under a reversed bit order and is the only kind of bucket that catches P-184 being read backwards.
10. Command 0x08 target, cmds enforcement, outcomes 7 and 8. Nothing actuates — commands are still reserved — but the refusal path is real and testable. PROTOCOL.md gains the two outcomes and P-166 in this commit, which is what keeps every_live_number_is_reachable green.
Watch it fail: compose a command under rev = N, bump rev, send it, and assert outcome 7 before any handler runs. Aim a start generator at a tank component and assert outcome 8. Send 0x0203 acknowledge concern at dev 0 / cmp 0 and assert it is accepted — under the earlier rules it was refused on every reading, which is the shape of a design refusing its own new command.
11. The six fixtures as integration tests. Each one include_str!s vectors/v1.json and drives the public API — never a hex string retyped into the module it is meant to check. Fixture 1 asserts the aggregate is not double-counted; 2 that L-N and L-L are distinguishable and control owner is read rather than inferred; 3 that pack 2’s half-string is pinned to ebase = 17, that the concern on its cell 23 carries elem = 7 and renders as 23, and that one bad cell does not blank the other fifteen; 4 that the start battery is a typed role and not a label string; 5 that the merged circuit’s two legs roll up exactly once; 6 that a module going offline does not move rev and a module arriving does.
Watch it fail: move vectors/v1.json. Every one of these must go red. Fixture 3’s ebase is the load-bearing detail — at the default ebase = 1 the two readings of P-178 both return 7 and the committed vector cannot diverge, so the fix would be untested and would look tested.
Between step 3 and step 4, and again after step 10: cargo size on o89-fw, recorded in the commit message. Flash is the budget on this part, three of four candidate designs reported RAM and called it the budget, and an estimate nobody measured is exactly the class of number this repo has caught twice.