Farm stack foundation layer: spec v0.1#
Oct 7, 2026 · @anun333
Status: draft. Revises v0, which is kept unchanged as the record of the original draft. Changes and their rationale are listed in Changes from v0 at the end. Options for every undecided item are in open-questions.md.
Purpose and scope#
Layer zero is the data and device contract every farm app builds on: identity, time, records, observations, devices, messaging and commands. It ships no end-user features. Its job is to make sensor data trustworthy, farmer-owned and portable.
It is for small and mid-size diversified farms, DIY ag builders and researchers, and for developers building apps on their behalf.
In scope:
- A canonical data model for records and sensor observations
- Device, calibration and health tracking
- A message format, link drivers and store-and-forward delivery
- A command contract with acknowledgements and node-side limits
- Local-first storage, export and revocable sharing
- A driver SDK, conformance tests and import/export adapters
Out of scope (built on top): rules and alerts, irrigation logic, dashboards and UI, robots and guidance, ML.
Done means three different apps (an irrigation controller, a livestock water monitor and a research data logger) can each be built without changing layer zero. This is an acceptance test, not a slogan: §15 records what each of the three needs and which clause provides it.
What v0 actually ships#
The full scope above is the contract. The first release implements one vertical slice of it, end to end:
| In the first release | Deferred | |
|---|---|---|
| Links | One (chosen in open-questions.md Q6) | The other four |
| Device drivers | One | The rest of the catalogue |
| Direction | Observations only | Commands |
| APIs | SensorThings out | farmOS-compatible in and out |
| Records backend | Native log | farmOS projection |
| Sharing | Export | Grants |
Since the table was written, sharing grants were built after all (sharing.py, Q16), with tokens for the data service (§8.2). The rest of the deferred column stands.
The substrate is a simulated farm, not hardware. The contract is built and exercised against a deterministic fictional farm first, because layer zero's hardest clauses are all about failure — reboots, backlog drains, drift, lost acknowledgements — and waiting for a real farm to supply one of each is a multi-year sampling problem. This moves the pilot farm off the development critical path and leaves it on the claims path: §9.2's growing-season requirement is unchanged, and a simulator never satisfies it. See VIRTUAL_FARM.md.
Commands are deferred on purpose. They carry the liability, they are the hardest part of the contract to change once hardware is in the field, and they benefit most from a season of evidence on the observation path first. Nothing in this spec is weakened by shipping them second; the contract is written now so node firmware can be built against it, and frozen after the first season.
Design principles#
Every rule below traces to a pain point farmers or field deployments report.
| Principle | What it means in layer zero | Pain point it answers |
|---|---|---|
| Auditable | Every reading, command and decision lands in an append-only log | Silent failures, distrust of black boxes |
| Farmer-owned | Local-first, full export in open formats, revocable sharing grants | Fear of losing data ownership, vendor lock-in |
| Honest data | Raw and calibrated values are separate, immutable series; quality is an assertion with provenance, not a mutable column; nothing silently cleaned | Drift, stuck sensors, misleading recommendations |
| Works offline | Store-and-forward delivery; nodes keep local schedules and limits | Poor rural connectivity |
| Modular | Every driver and adapter is optional; removing one breaks nothing | Too many disconnected tools |
| Standards first | farmOS conventions, OGC SensorThings, ISOBUS, Modbus, MQTT, LoRaWAN | Interoperability, the top barrier after cost |
| Safety below software | Hardwired interlocks and fail-safe power-off for anything that moves or pumps | Liability, machinery and water risk |
| Reimplement, don't copy | Build from published specs and documented models | GPL entanglement |
Each principle has an enforcement point, and a principle with no enforcement point is a wish. §16 maps each one to the clause that makes it true.
1. Identity#
Every entity gets a UUIDv7, generated offline with no coordination.
- Labels are not keys. Human names ("north bed") are separate, editable labels. Renaming breaks nothing. A label is a log record (sdk/model.py
Label), so names are exported, replayed and audited like everything else. - A rename appends; it never overwrites. The new label supersedes the old one, which stays in history, because names outlive themselves in other documents: a certifier's report from last spring says "north bed", and after the bed is renamed the question "which entity was that?" MUST still be answerable. A rename MUST NOT supersede another entity's label, or a name could be silently re-pointed.
- One label per entity per language (BCP 47). Layer 4 is bilingual (§11). A missing translation falls back — a regional variant to its primary language, then to the farm's default — and the caller MUST be able to see which language it actually received, so a UI can mark the gap rather than pass English off as the Spanish it asked for.
- IDs do not collide because they are UUIDs. Every entity also belongs to a site, but the site is for scoping, authorization and sharing — not for uniqueness. Site MUST NOT be part of any key, or renaming or merging a site would break references.
- UUIDv7 order is ingest order, not measurement order. Observation IDs are minted by the gateway at ingest, so a reading that spent three days in a node's buffer sorts after readings taken later. Implementations MUST NOT order observations by
id. Ordering rules are in §2. - Radio nodes send short IDs. A 16-byte UUID in every LoRa packet wastes airtime. Nodes send their native short ID (LoRaWAN DevEUI or Meshtastic node number) plus a channel index; the registry maps that pair to the full point ID.
- Node message identity is
(site, node, boot, seq).seqalone is not enough: a solar node that browns out nightly restarts its counter, and a gateway deduplicating on(node, seq)would silently discard every reading after the first reboot.bootis a counter the node increments once per cold start and holds in non-volatile memory — a few bytes of EEPROM or flash, and the only non-volatile state a node is required to keep. seqis 16-bit, wraps to zero, and resets to zero on each boot. Wrap is unambiguous becausebootadvances on any power cycle and the gateway tracks the highestseqseen per(node, boot); a decrease of more than half the range within one boot is a wrap, not a replay.
Wire identity for a reading is (node, boot, seq, channel, index) — see §5. channel is one byte, assigned by the device driver and local to the node, so a node can be swapped without reprovisioning a shared index space. A registry-assigned 16-bit point index is an optional extension for nodes with more than 255 points.
2. Time#
Every observation carries five time fields, so ordering survives outages and bad clocks.
| Field | Type | Meaning |
|---|---|---|
| measured_at | timestamp (UTC) | When the sensor took the reading |
| measured_at_uncertainty | seconds | Half-width of the interval measured_at is known to lie in |
| received_at | timestamp (UTC) | When the gateway received it |
| seq | uint16 | The node's sequence number, with boot (§1) |
| clock_quality | enum | gps, network, rtc_synced, rtc_unsynced, none |
- Nodes with no clock send an age, stamped at transmit time. The node reports "measured N seconds ago" and the gateway computes
measured_at = received_at − N. A node draining a store-and-forward buffer MUST recompute the age for each message as it transmits. Stamping the age at sample time and then buffering for three days yields ameasured_atwrong by the dwell time — the single easiest way to corrupt a dataset silently, and the one a driver is most likely to get wrong. measured_at_uncertaintyis mandatory and never zero. It is the honest width of the estimate: sub-second forgps, the sync error plus link latency fornetwork, the drift budget since last sync forrtc_unsynced, and at least the node's sample interval plus transmit latency fornone. A research logger cannot use a timestamp it cannot bound, andclock_qualityis a category, not a number.- Everything is stored in UTC. The farm's timezone is a property of the site and is used only for display.
- Ordering. Across nodes, order by
measured_at, and treat two observations whose uncertainty intervals overlap as unordered. Within one node,(boot, seq)is authoritative and MUST be preferred overmeasured_at, because a node's own counter is reliable even when its clock is not. - SensorThings mapping.
measured_atisphenomenonTime. The other four have no equivalent and travel in the observation's extension object —parametersin SensorThings 1.1, renamedpropertiesin the 2.0 draft. See notes/sensorthings-2.0.md.
3. Core data model#
Layer zero has two models joined by shared IDs: records follow farmOS conventions, sensing follows OGC SensorThings, and both write to one log.
A sensor asset and its Thing share one ID, as do a field or bed asset and the feature of interest it represents. Each observation belongs to a datastream, which ties one sensor, one observed property and one unit to its Thing.
Externally, records are served through a farmOS-compatible API and sensor data through a SensorThings-compatible API, so existing tools in both ecosystems work on day one.
3.1 Datastreams#
A datastream ties one sensor, one observed property and one unit to its Thing. The SensorThings 2.0 draft permits several observed properties per datastream; one property per datastream is a subset of that, so this model maps unchanged to 1.1 and to 2.0.
Sampling and latency are different numbers, and the second one spends the battery. A soil-moisture series sampled hourly and a trough float sampled every five minutes can share a node, but what the radio costs is set by how quickly each series must arrive, not by how often it is read. Reading a sensor is a rounding error — measured at under 2% of a node's consumption in every realistic configuration — while one transmit at the slowest data rate costs more than a day of sampling. A series that is merely informative declares a loose max_latency and rides along in whatever message is next; a series someone will act on declares a tight one and pays for it.
Feature lives on the datastream, not on each observation. For a fixed installed sensor the observed feature never changes, so repeating its UUID on every row wastes storage and airtime for no information. An observation MAY carry its own feature, and does so only when it genuinely varies: a handheld meter, a sample, a sensor on a moving thing.
| Field | Type | Notes |
|---|---|---|
| id | UUIDv7 | |
| thing, sensor, observed_property | UUIDv7 | |
| unit | UCUM code | Declared once here; see §3.4 |
| feature | UUIDv7 | The field, bed, tank or animal this series observes |
| kind | enum | raw, derived, forecast (Q26) or aggregate (Q20) |
| derived_from | UUIDv7 | The raw datastream this one is computed from; null when kind = raw, forecast or aggregate |
| window_s | seconds | The window each observation summarises, ending at its measured_at: set on an aggregate datastream and on a derived one computed from it, absent on every other (Q20) |
| probability_of | object | On a forecast of a probability only: the event it is the probability of — comparison, threshold in the observed property's unit, and period — with the series' own unit % or 1 (Q26) |
| expected_interval | seconds | How often this series is sampled. Drives staleness (§4.2) |
| max_latency | seconds | How stale a reading of this series may be when it reaches the gateway. Drives the node's transmit schedule, and therefore its battery (§5.2) |
| thresholds | object | Health-check thresholds; see §4.2 |
| buffer_policy | object | Node backlog behaviour; see §5.3 |
3.2 Observations are immutable, and calibration is a separate series#
An observation is written once and never altered. There is no mutable field on it — not the result, not the quality flag. Two consequences, and they are the heart of Honest data:
Raw and calibrated values are separate datastreams.
- The raw datastream holds exactly what the node sent. It is never superseded, never recalculated, and is the archival truth. If everything else is lost or wrong, this is what a farmer still owns.
- The calibrated datastream is derived from it, one per raw datastream, carrying
derived_fromand, on each observation, the calibration record that produced that value.
A client that wants raw values reads one datastream; a client that wants usable values reads the other. Both are ordinary SensorThings Datastreams, so this is honest and standard, rather than honest by convention.
Recalibration appends; it never rewrites and never duplicates a timestamp.
A new calibration produces new observations in the calibrated datastream, each carrying supersedes pointing at the row it replaces. The superseded row stays in the log forever. Exactly one live observation exists per (datastream, measured_at), where live means nothing supersedes it.
This is the clause that makes the SensorThings promise survive the first recalibration. Keeping two results in one series — the v0 design — would have put two observations at the same phenomenonTime in one Datastream, so every existing client would double its data points or show a discontinuity, which is exactly the "works on day one" claim failing on the most ordinary maintenance event there is.
| Field | Type | Notes |
|---|---|---|
| id | UUIDv7 | Minted at ingest. Not a sort key (§1) |
| datastream | UUIDv7 | |
| feature | UUIDv7 | Optional override; null means the datastream's feature |
| measured_at, measured_at_uncertainty, received_at, seq, clock_quality | see §2 | boot travels with seq |
| result | number or string | Raw as sent in a raw datastream; calibrated in a derived one |
| calibration | UUIDv7 | The calibration applied; null in a raw datastream |
| supersedes | UUIDv7 | The observation this replaces; null if none |
| provenance | object | Firmware version, driver and version, gateway and version |
| issued_at | timestamp | When the result was produced — SensorThings resultTime. Required on a forecast observation, absent on every other (Q26) |
- Calibration is selected by
measured_at, never by ingest time. The gateway MUST apply the calibration that was in force when the reading was taken. Applying whatever is current at ingest would silently give wrong coefficients to every reading that arrived late from a buffer — a violation of Honest data produced entirely by an unstated default. - Derivation is reproducible.
(raw value, calibration record, driver version)MUST be sufficient to recompute a derived result exactly. - A calibration MAY declare an output resolution.
0.001 x 86is exactly0.08600000000000001in IEEE 754 — correct, reproducible, and a claim of seventeen significant figures that no probe can support. Rounding to a declared resolution is not cleaning the data; it is declining to assert precision the instrument does not have. Because the resolution is part of the calibration record, the derivation stays reproducible, and a client is not handed false precision to display. A derivation that depends on anything else — ambient state, wall-clock time, another series — MUST be recorded inprovenance.
A forecast is not a measurement, and is not filed as one (Q26). It has two times —
the hour it is valid for (measured_at, served as phenomenonTime) and the run that
produced it (issued_at, served as resultTime, the case O&M defines that field for) —
and it is issued again and again: every run predicts tomorrow afternoon anew. So it lives
on a datastream of kind = forecast, whose sensor is the forecast model and which derives
from nothing on the farm. Several observations there share a measured_at, one per issue,
and the live one is the latest issued_at: no supersedes is written, because a newer
run replaces an older one as the best guess rather than correcting it, and a run that
arrives late, after a newer one, must not take over. Every run stays in the log, which is
what lets a forecast be scored against the farm's own sensors. A forecast may be of a
probability — the chance of rain — and then it declares the event it is the chance of
(probability_of: "≥ 0.254 mm within the hour"), so the number cannot be read as a depth. A measurement carries no
issued_at: its result time is not recorded, and received_at is not it. A forecast
datastream's staleness (§4.2) is judged from its latest issue, since its newest
measured_at lies in the future. Of §4.2's checks only the range check runs on a forecast:
a value outside what the variable can be is a broken feed or a unit confused upstream, and
saying so is honest, but a forecast holding one value for a day is not a stuck probe, a
front is not a spike, and a forecast has no redundant neighbour to cross-check.
An aggregate is a node's summary of a window, not a reading, and is labelled as one (Q20). A node that samples often and reports seldom may send, for a low-urgency series, the window's sample count, minimum, maximum, and how long its value has been unchanged — the length of the current run of identical samples, carried across windows. Those four numbers are what the flatline and range checks need, so a stuck probe is seen at the sampling resolution for the airtime of the reporting one. The node has computed something, so the series is not raw: it lives on a datastream of kind = aggregate with a declared window_s, its observations carry the four numbers as an object, and measured_at is the window's end (served as a phenomenonTime interval). It is never superseded, like raw. Its calibrated series is derived, carries the same window_s, and moves the extremes through the calibration — exact for a monotonic calibration, which a probe's is — leaving the count and the run alone. Only range and flatline run on an aggregate: rate, fall and spike need the order of readings inside the window, which the node did not send, and that is the price of the airtime saved.
3.3 Quality is an assertion with provenance#
Quality is not a column on the observation. It is its own append-only record, because a quality judgement is a claim made by a particular check, under a particular threshold, at a particular time — and all three change.
| Field | Type | Notes |
|---|---|---|
| id | UUIDv7 | |
| observation | UUIDv7 | What is being judged |
| flags | set of enum | suspect, stuck, out_of_range, rate_exceeded; empty set means no objection |
| usable | enum | good, questionable, bad — the one-value summary |
| check, check_version | string | Which check produced this |
| threshold_version | UUIDv7 | The threshold record in force |
| asserted_at | timestamp | |
| assertor | enum + UUIDv7 | foundation or person, and which |
| supersedes | UUIDv7 | The assertion this replaces |
| evidence | object | Which individual checks ran, which fired, and why. On a foundation assertion it MUST carry checks_run, the checks that looked, and fired, each check that objected with its reason — a subset of checks_run; anything else is extension (Q19). A person's assertion needs none |
Why this shape:
- A single enum cannot express reality. A sensor can be stuck and out of range at once.
flagsis a set;usableis the one value an app that does not care about detail can read. Two conforming implementations now agree on the same input, which a single enum did not guarantee — fatal for a suite meant to certify conformance. - Changing a threshold does not rewrite history. Every assertion names the threshold version it used. Retuning a threshold produces new assertions that supersede the old ones, and the old ones remain visible, so a false alarm can be traced to the threshold that caused it and the fix can be audited.
- A person's override is a new assertion, not an edit.
assertor = personwith a higher precedence thanfoundation. The original machine assertion stays. - Staleness is not here. See §4.2.
- Device warnings are not here. Battery and signal are properties of a device, not of a reading; see §4.3.
One assertion per assessment run. check names an assessment, not a single check, and flags carries the union of everything that fired in it. §4.2 lists four checks that each produce one flag, and the precedence rule below selects exactly one live assertion — so if each check wrote its own assertion, two firing together would mean one wins and the other's flag silently disappears. That is the loss this model exists to prevent, so the foundation runs the suite, unions the flags, and records per-check detail in evidence. A person's override is still a separate assertion, which is what precedence is for.
Precedence, so implementations agree: the live assertion for an observation is the non-superseded one with the highest precedence, where person outranks foundation; ties break by asserted_at, then by id. usable is derived as bad if stuck or out_of_range is set, questionable if only suspect or rate_exceeded is set, good if flags is empty — unless a person asserts otherwise.
SensorThings mapping. 1.1 carries resultQuality on Observation, typed DQ_Element with multiplicity zero-to-many, which fits this model natively. The 2.0 draft has no resultQuality attribute at all — Table 17 lists only id, phenomenonTime, result, resultTime, validTime and properties. Under 2.0 the live assertion travels in properties. This is worth raising with OGC while 2.0 is unpublished; see notes/sensorthings-2.0.md.
3.4 Units and observed properties#
- Units use UCUM codes, declared once on the datastream. SensorThings 1.1 already says
unitOfMeasurementvalues SHOULD follow UCUM, and the 2.0 draft moves the unit inside a SWE-CommonresultType, which also carries UCUM codes — so this is the standards-aligned choice in both versions rather than a preference. - Observed properties reference a shared vocabulary by URI, never free text. Decided 2026-10-07 (open-questions.md Q4), layered by what each vocabulary is for rather than forcing one to cover everything:
- CF Standard Names for physical measurands, served as resolvable URIs under
http://vocab.nerc.ac.uk/standard_name/. - ODM2 / CUAHSI (
http://vocabulary.odm2.org/variablename/) for the instrument and water terms CF does not carry — battery voltage, signal strength. CF describes the environment, not the device, and §4.3 needs both. - AGROVOC for the records side — crops, treatments, practices — where its multilingualism serves layer 4. It is not a vocabulary of measurands and MUST NOT be used to declare what a sensor reports.
- A local term only for a genuine gap in all of the above, which MUST be declared in one place with its reason and MUST be published upstream rather than accumulated. The durable namespace for local terms is open as Q4c.
- CF Standard Names for physical measurands, served as resolvable URIs under
- A declared URI MUST resolve. This is the clause that makes the previous one mean anything: an unresolvable identifier is free text with extra syntax, and it fails silently, because nothing about
.../cf/soil_moisture_contentlooks wrong until someone tries to fetch it. Resolution is checked against a registry, not at declaration time: a farm gateway may not see the internet for days, so the check at declaration is offline — the term must be registered, and the unit must be one of that term's — and the registry is what gets fetched, when a connection exists. Implemented asvocab.check, run byState.validateon every datastream, andmake vocab-resolve. - The unit a term is declared in MUST be consistent with the term's own definition. A fill fraction declared under a term defined as a length is a unit error that passes every syntactic check, and it is the specific mistake this project made and corrected — see the Q4 entry.
- A datastream with no meaningful unit (a boolean, a state) sets the unit to null rather than inventing one.
- A raw datastream declares the unit its values are in (Q21). A node that sends scaled integers — a probe's
280for 0.280 cm³/cm³ — sends numbers, so its raw series declares UCUM's dimensionless1, and the scale lives in the calibration, the record that is allowed to change. A node that sends a value already in a unit (a digital thermometer's 21.5 °C) declares that unit. A raw series that a calibration scales by a power of ten while declaring the calibrated series' unit is reported by validation: it is the plausible-looking declaration no other check falsifies.
4. Devices, calibration and health#
Sensors are maintained assets with their own history, because drift and hardware failure are the most common reasons field data goes wrong.
4.1 Device record#
The list below describes a view, not a record. Read literally it asks one record to hold "the current boot counter", and a record in an append-only log cannot hold current anything without being mutated — which §3.2 forbids, for reasons that apply here unchanged. So the device is three records and a projection:
| Record | Holds | Shape |
|---|---|---|
Device |
identity, hardware, firmware, declared capabilities | one per device, replaceable by id |
Installation |
feature, depth, orientation, installed_at, removed_at |
one per siting, closed by re-append |
MaintenanceEvent |
action, when, by whom, note | accumulates, never replaced |
| the device view | last seen, current boot, current installation, full history |
derived from the readings |
Last seen and the current boot counter are derived because they are observations about the device, not claims the device makes about itself. Deriving them also means nobody can edit them, which is the right property for a field a certifier may rely on.
What each part MUST carry:
- Install location, depth and orientation, with dates. On the
Installation, never on the device. The question is "how deep was this probe when it took that reading", and a field overwritten on every move cannot answer it.depth_mis positive downwards from the surface — a sign convention nobody states is one somebody gets wrong — andorientation_degis a compass azimuth. Both are numbers rather than prose, so a wrong one can be caught. - Installation intervals MUST be half-open and MUST NOT overlap.
[installed_at, removed_at), so the instant of a move belongs to exactly one siting. Two open installations make the depth question answer twice, which is worse than not answering: nothing looks wrong. Moving a sensor therefore has two halves, and closing the old installation is the half that gets forgotten — implementations MUST report an overlap rather than picking one. - Firmware version, also stamped into each reading's
provenance. - Calibration history: method, reference used, coefficients, date, who did it, and the interval it is in force for. A calibration record carries a validity interval, because §3.2 selects calibration by
measured_atand that requires knowing what was in force when. - A maintenance log: cleaned, moved, replaced, recalibrated, serviced. The action is an enumeration, not prose — "when was this probe last cleaned" is a question a certifier asks and it cannot be answered by grepping notes. The vocabulary is closed by this clause, so a missing action is a spec change rather than a local convention.
servicedis that change once made (Q22): routine work on a device that is neither a replacement nor a recalibration — a battery changed, a panel cleaned, a desiccant pack swapped — with the detail in the note. - Declared capabilities from the driver manifest (§8): dedup persistence, delivery confirmation, safe states. An undeclared capability MUST read as unknown, never as a default. §6.3 branches on precisely this: a node that cannot persist its dedup table across a reboot has to say so, and silence is not a denial.
Datastream.sensornames aDevice. It was a bare identifier with no record behind it in v0, which is how a probe could be moved between beds with nothing recording that it had happened.- A calibration is scoped to the raw channel it converts. One device can report several measurands — a soil probe gives moisture and temperature; a multi-depth probe gives six moistures — so a calibration selected by sensor alone competes with its siblings. Implementations MUST select by sensor and raw datastream, preferring a channel-specific calibration to a sensor-wide one and never applying one that names a different channel. Overlap is checked per channel.
- An installation is a claim, and SHOULD be checked against physics where the physics allows. Soil damps and delays the daily temperature wave with depth, so a probe that reports soil temperature carries its own depth in its record: each damping depth down the swing shrinks by e and the peak arrives about 3.8 hours later. A check that infers depth this way and compares it with the installation record finds the move nobody logged — frost heave, a cultivator catching a cable, a probe pulled to look at and pushed back shallower — which no amount of correct record-keeping can catch. One window is noisy and a real move is permanent, so a displacement MUST persist before it is acted on — five of the last seven daily assessments — and a displacement seen today but not yet persistent is reported as unconfirmed, not as consistent. Where a profile's deeper probes carry too small a wave to calibrate on over two days, the check SHOULD also run over a longer window in which they do, so that the fit is robust: a profile calibrated on two peers cannot outvote a displaced one, and blames the probe extrapolated from it. A persistent displacement from a two-peer fit MUST therefore be reported as concerning the profile, and SHOULD name the probe whose own records — an installation, a maintenance event, a recalibration, or a long silence in its readings — changed before the alarm began, saying so plainly when none or several did. It produces
suspecton every reading the device took while displaced — moisture as well as temperature, because the moisture was attributed to a depth it was not measured at — with the implied and recorded depths as evidence. The flag is added to each reading's live judgement rather than replacing it, so a reading alreadystuckstaysbad; it declines where the live judgement is a person's, because a machine flag superseding a human override would erase the override from the live view; and it is idempotent across the overlapping daily windows it runs over. It MUST calibrate against peers rather than a constant, because diffusivity varies with water content, and it MUST use a robust fit, because the peers may be displaced too. It MUST reportindeterminaterather thanconsistentwhere it cannot see: below the reach of the daily wave, and for a move between beds at one depth, which leaves no thermal signature. That blind spot is covered by a second signal: a farm's own irrigation timers wet each bed at its own time, so a probe's moisture record says which bed's water it answers to. A lateral check correlates each probe's wetting events with every other probe's, by recorded feature, tolerating the lag of a deeper probe. It MUST require a discriminating match before confirming a siting — rain wets every bed at once, so a probe moved out of its bed still matches the bed it left in a rainy window, and a confirmation earned by the weather is not one. Both checks MUST judge only readings taken under the current installation: a window reaching back past a recorded move judges the new record by the old bed's readings. - A node that keeps GNSS time SHOULD report its position, and its installation SHOULD be checked against it. The fix is computed anyway for timing; discarding it throws away the one signal that sees a lateral move — a node carried to another bed or paddock — which the temperature check cannot. Use medians, so a multipath bounce of a kilometre changes nothing, and a tolerance wider than the fix scatter, so a node a few metres off its post is never flagged for something GNSS cannot resolve. Geometry stays out of the log (§10.1) and is passed to the check.
- Battery, signal and position are properties of the node, and the node is a
Device. v0's harness modelled each node's battery and signal as two separate sensor devices — a node's battery named as though it were a probe in the ground.
4.2 Health checks produce quality assertions#
Checks run in the foundation and write assertions (§3.3), never mutations:
| Check | Flags | Example |
|---|---|---|
| Flatline | stuck |
Identical values for longer than physically plausible |
| Range | out_of_range |
Soil moisture above saturation |
| Clear sky | out_of_range |
More sunshine than a clear sky gives at the site and hour: a station clock in the wrong time zone, light in lux sent as W/m² |
| Rate of change | rate_exceeded |
A jump no real process could produce |
| Spike, by shape | rate_exceeded |
Readings that leave their level and come straight back — judged one reading late |
| Fall | suspect |
Soil water falling faster than soil drains |
| Cross-check | suspect |
Disagreement with a redundant sensor on the same feature |
A feature with one sensor has no drift detection, and drift is the commonest real failure. Measured on the virtual farm, scoring each fault against ground truth: a single probe catches a disconnected sensor (100% recall) and a frozen one (87%), and catches drift, bias and intermittent noise not at all — 0% recall on all three. (Since the shape checks below, noise is caught alone 40% of the time with every flag correct; drift and bias remain invisible to a single probe.) Every reading is individually plausible and the series looks healthy, so no check that looks at one series has anything to go on. Adding a second probe on the same feature takes those to 79%, 100% and 19%, entirely through cross-check. So redundancy is not a refinement: on a series anyone will act on, it is the difference between seeing three fault modes and seeing none, and that belongs in an installation decision rather than a wish list.
For soil water, a rate limit MUST NOT stand in for a spike check. A rate allowance grows with the gap between readings: 0.00015/s, derived from drip irrigation at fifteen-minute sampling, permits 0.54 m³/m³ in an hour — nearly the whole physical range — and over ISMN's hourly Tennessee record it caught none of 1,849 spikes and jumps. What tells a glitch from the weather is its shape: a spike leaves its level and comes straight back, which rain does not do. Readings that leave their level by at least a threshold, stay out no longer than a short duration, and return MUST be flagged rate_exceeded — judged one reading late, by appending an assertion when the reading that shows the return arrives, under the same rules as any late flag (added to the live judgement, never over a person's, idempotent). And soil fills fast but empties slowly, so a cap on falls is meaningful where a cap on rises is not: a fall faster than soil drains is suspect — not worse, because a wetting front draining at shallow depth can occasionally exceed it. A rise has no cap from rate at all, because without a rain gauge a fault and an irrigation event rise alike. Measured on 1,797 SCAN probes: at 0.05 m³/m³ the shape check raised 0.46 flags per probe-year on readings ISMN rates good, never on the median probe, and most of those on sensors that are plainly broken; at 3-hourly sampling it fires less, not more. A fall cap of 0.05 per hour caught 61% of ISMN's negative jumps. max_rate_per_s remains for measurands where a rate really is the physics.
A step the maintenance log explains is not a fault (Q22). A new battery lifts a node's voltage in one reading; a probe lifted out to clean reads air and comes back. Before the rate, fall or spike check flags a step, it MUST consult the maintenance log of the device the series comes from — that device only, so a node's battery change explains nothing about the soil probe it carries — for an event between the two readings, or within the spike. An explained step raises no flag, and what the check would have said is kept in the assertion's evidence as explained, with the event, so the judgement stays auditable.
Cross-check says suspect and never worse. When two probes disagree it cannot say which is wrong, so calling either one bad would be a guess dressed as a measurement. A farmer deciding which probe to pull needs that distinction.
Flatline latency depends on when the fault begins, not only on the threshold. Part of an identical run can precede the fault, because a healthy probe already repeats overnight when evapotranspiration is near zero. A probe that freezes at dawn is caught sooner than one that freezes at midday, through no merit of the detector — so a latency figure needs the onset time beside it to mean anything.
Flatline MUST NOT be applied to a regulated quantity. A float-controlled trough level, a thermostat-held temperature, a pressure held by a regulator — these are constant by design, and constant is the healthy state. Measured on the virtual farm: a working float holds a trough at one reported value for 226 consecutive samples, nearly nineteen hours. Flatline on such a series is a category error that reports a fault every day. The check that catches a stuck float is range.
A flatline threshold MUST exceed the quantisation dwell. A sensor reports in steps, and when the true value moves more slowly than one step the readings are identical and correct. Measured: a soil probe reporting three decimals, sampled every fifteen minutes, repeats a value for up to eight hours overnight when evapotranspiration is near zero. A six-hour threshold therefore fires nightly on a healthy probe. The floor for flat_seconds is how long the true process takes to move by more than one reported step, which is a property of the sensor's resolution and the process's rate — not a number to pick by feel.
A series MAY learn its flatline from its own history, inside bounds its soil sets (Q24). The default comes from the probe's depth, the soil texture at that depth and the climate — twice the healthy 99.9th-percentile dwell measured for that combination. After a season, a series may replace it with twice its own 99.9th-percentile run of one value, learned only from readings not already flagged stuck, and appended as a new threshold version that records what it was learned from. It MUST NOT learn below the default nor above twice it. Below, because one quiet year is not the soil's range: measured on 30 Tennessee probes learning from their first year, thresholds allowed to fall to half the default raised ten times the false stuck runs in the years after. Above twice, so a probe stuck since installation cannot teach itself that stuck is normal.
A threshold lives on the datastream whose values it bounds. max: 0.55 is soil moisture, so it belongs on the derived series; a limit on the integer a probe reports belongs on the raw one. They are different numbers in different units and putting one on the wrong series makes it silently inert — the check runs, finds nothing to compare against, and reports no objection.
Thresholds are expressed in physical units, not in samples. "Identical values for longer than physically plausible" is a duration, and how long a frozen reading is implausible is a property of the measurand rather than of how often a node samples. A count-based threshold means different things on two farms: twelve samples is twelve hours at hourly sampling and one hour at five-minute, so the "same" threshold catches a fault on one farm and misses it on the other. Flatline therefore takes flat_seconds, with a sample count kept only as a confidence guard — two equal readings an hour apart prove little however long the span between them.
These four contribute to one assertion per run, not one each (§3.3). Cross-check flags only suspect, never worse: when two probes disagree it cannot say which is wrong, and calling either one bad would be a guess dressed as a measurement.
Thresholds live on the datastream with sensible defaults, so apps can tune them without code. Every threshold is a versioned record, and every assertion names the version it used (§3.3). Defaults are deliberately loose — a default that fires often costs the farmer's trust in every flag, which is more expensive than a missed detection.
Staleness is a datastream state, not a reading flag. "No reading for 3× expected_interval" is a statement about a series at a moment in time, not about any particular observation. Flagging the last good reading as stale would mean reaching back and mutating a record that was correct when written, because a later reading failed to arrive — an append-only violation that also destroys the meaning of the value. Staleness is exposed as current datastream health, and the absence itself is already recorded honestly as a gap (§5.4).
4.3 Device health is observations, not side-channel state#
Battery voltage, RSSI and SNR are measurements of the device, so they are datastreams on it like any other. Trends, thresholds, quality assertions, export and audit then work on them for free, instead of needing a parallel mechanism. The SensorThings 2.0 draft explicitly blesses this case: an observation whose target is a sub-part of the Thing itself, battery level being its own example.
A low-battery warning is therefore a threshold on a device datastream, surfacing as device health — not as a flag on an unrelated soil-moisture reading, which is what a device warning value in a reading-level quality enum would have meant.
4.4 Resources#
Pumps, wells and power sources are resource assets with declared capacities (flow, pressure, watts). Bookings against them are enforced in layer zero's command path (§6.5), not only by the layer-1 scheduler. Tasks for people are pending logs, following farmOS, rather than a separate entity.
5. Messaging and link layer#
One compact message format rides over any link; the gateway expands it into full observations.
5.1 A reading's path#
- The node samples, applies nothing, and packs a compact binary message.
- The node buffers it until delivery is confirmed by whatever means its link offers (§5.2).
- A link driver carries it: Meshtastic, LoRaWAN via ChirpStack, wifi, cellular or wired.
- The gateway resolves IDs through the registry, computes times (§2), drops duplicates on
(site, node, boot, seq), writes the raw observation, then derives the calibrated one (§3.2) and runs health checks (§4.2). - Raw and derived observations are written to the append-only log and the time-series store, then published on the bus (MQTT for edge devices; bridged to ROS 2 for robots and machinery).
5.2 Messages and batching#
A message carries a format version, (node, boot, seq), and one or more readings. A reading's value is the integer the node sampled or, on a channel bound to an aggregate datastream, the four integers [n, min, max, unchanged] of a window (Q20); a value whose shape is not its channel's is recorded as an event and written to no series. Batching is not an optimisation to add later: one reading per message cannot fit a useful sample rate inside a 1% duty cycle, and retrofitting it would change the dedup contract and the wire format.
message := version, node_short_id, boot, seq, base_age, dropped, reading+
reading := channel, age_delta, value
dropped is the count of readings this node evicted from its buffer since its last
message, which §5.3 requires it to report so a loss is recorded as a gap rather than
as readings that quietly never existed.
Ages are whole seconds, and that bounds the sample interval. Two readings on one
channel that compute the same measured_at would be two live observations at one
instant, which §3.2 forbids and which breaks every SensorThings client. So a node
MUST NOT sample one channel faster than the age field can distinguish: a driver
declaring a sub-second interval is a configuration error, not a runtime condition.
A gateway that receives such a collision anyway records it and keeps the first
reading, because writing both would violate the invariant and discarding silently
would lose a reading without saying so.
A reading's identity is (node, boot, seq, index) where index is its position in the message. The dedup key stays (site, node, boot, seq) — one key per message, not per reading — so deduplication, gap detection and batching do not interfere.
base_age is stamped at transmit time (§2); each reading's age_delta is relative to it.
- Link drivers are interchangeable and share one interface: send, receive, link health, and a declared delivery-confirmation capability:
confirmed,gateway_receiptornone. - "Buffer until the link confirms delivery" is not available on the primary target links. Meshtastic and unconfirmed LoRaWAN uplinks return no receipt, and confirmed LoRaWAN uplinks spend a downlink per message, which exhausts the downlink budget and the battery. For a
nonelink, the gateway periodically sends a cumulative acknowledgement — the highest contiguousseqit holds for that(node, boot)— and the node releases its buffer up to that point. One small downlink retires many uplinks. - Messages must fit the smallest link's payload. In the EU 868 MHz bands a 1% duty-cycle sub-band allows about 36 seconds of transmit time per hour, per sub-band, per device; EU868 also defines 0.1% and 10% sub-bands, and LoRaWAN deployments add a network fair-use policy on top. Treat 36 s/hour as the common case, not as the limit, and take the real budget from the link driver.
- Encoding: CBOR packed arrays with a CDDL schema, versioned with the spec and carrying its version in every packet. Decided on measurement; see notes/q5-encoding-measurement.md. A hand-packed layout is documented as an escape hatch for a node draining a very long backlog at the slowest data rate, distinguished by the version byte.
node_short_idis optional on the wire, present per link driver. On LoRaWAN the session already identifies the device, so the bytes are waste; on Meshtastic they are required. Measured at four bytes a message, which is a reading every other message at DR0.- Batch size is computed from the current data rate, never fixed at build time. Sizing a batch to exactly the payload limit is a trap: LoRaWAN MAC commands piggyback in
FOptsand shrink the availableFRMPayload, and ADR moves the data rate under a running node. The encoder takes a maximum-payload argument and packs what fits, with headroom. - A cumulative acknowledgement MUST be able to abandon a hole. It cannot pass a frame that no longer exists anywhere, and without a way to give up it never tries: one unrecoverable gap blocks release permanently, including for every reading that arrived perfectly well after it. Measured on a three-day outage — a node holding 800 readings, saturated and evicting, because the gateway's contiguous mark was stuck behind frames that were gone for good. So a hole below the highest sequence seen, which nothing has filled for a stated grace period, is declared permanent and the mark moves past it. The gap was already logged when it was noticed (§5.4); declaring it permanent hides nothing and only admits it is not coming. The grace is measured from when the hole appeared, not from the last frame received: on a busy node the high-water mark keeps advancing while the hole sits untouched, so a grace keyed to the mark never elapses.
- A node's give-up bound MUST be expressed in time, not in attempts. A node holds a reading until it is acknowledged or until a stated horizon passes, after which the reading becomes a recorded gap. An attempt count looks equivalent and is not: four attempts at a half-hour deadline is two hours, so a node configured that way silently loses everything in a three-day outage while appearing to work. The horizon is the farmer-facing number — how long this node holds a reading — and it must exceed the longest outage the deployment intends to survive. Some bound is required, because a resend goes first by design and a frame that can never be acknowledged otherwise blocks every reading behind it.
- A resend MUST reuse its sequence number. This is the same rule §6.3 states for commands, and it holds here for the same reason: the dedup key is
(site, node, boot, seq), so a retry under a fresh number is a new message to the gateway. It then writes the same readings again at timestamps that differ by the rounding of a recomputed age — phantom readings a second away from the real ones, in a series nobody can later untangle. Measured on a 6%-loss link: 2498 observations where 768 were expected. - An acknowledgement deadline is mandatory wherever cumulative acknowledgement is used. A node MUST give up waiting and resend after a stated interval. Without one, a single lost frame stalls the gateway's highest contiguous sequence at the hole below it permanently, and the node holds every later reading until it begins evicting — a node that looks healthy and delivers nothing.
- A node has one clock and MUST use it at both ends of an age. The cancellation that makes §2 work depends on it: subtracting two readings of the same wrong clock removes the error exactly, so a node six hours wrong still reports correct times. Taking the sample time from one source and the transmit time from another destroys the property silently, and nothing downstream can detect it.
5.3 Backlog policy#
A node with a five-minute interval accumulates about 288 readings a day. After a three-day outage it holds ~900 readings and can spend 36 seconds an hour transmitting. What it discards, and in what order, is a contract decision, because the target apps want opposite answers: a livestock water monitor needs the newest reading first, a research logger needs the series unbroken.
buffer_policy on the datastream declares:
| Field | Meaning |
|---|---|
| buffer_max | Readings the node retains before evicting |
| eviction | oldest or newest — which is dropped when full |
| drain_order | fresh_first or chronological |
| downsample_on_backlog | Optional: aggregate buffered readings rather than drop them |
A node that evicts MUST report the count, so the loss is recorded as a gap rather than disappearing.
- Under
fresh_first, a reading never sent that is newer than every frame awaiting resend goes before the resends, and the resends go newest-first. Through a long outage every frame goes unacknowledged, so when the link returns everything awaiting resend is old. A node that resends first delivers the trough's midnight level at noon, which is the one thingfresh_firstexists to prevent. A resend still keeps its sequence number (§5.2), and a node MUST keep draining while anything awaits resend, or the resends wait behind every new sample until the horizon turns them into a gap. - Only the gateway's highest-contiguous acknowledgement is cumulative. A link that confirms each frame says that frame arrived and nothing about the frames before it; a node that reads it as cumulative releases readings that never arrived. The livestock water monitor's acceptance run measured it: 21 of an outage's 144 trough readings lost, before any reordering, and nearly all of them once the newest frame went first.
5.3.1 The transmit schedule, and where a sensor is installed#
A node's transmit interval is the tightest max_latency among the series it carries. A trough float that must be known within five minutes forces the radio awake every five minutes however sleepy everything else on that node is, so latency is not a per-series cost — it is imposed on every series sharing the radio.
Two consequences, both measured (notes/power-budget.md):
- Slowing a sensor down to spare the sensor saves nothing. Sampling is under 2% of a node's energy. What a higher sample rate costs is the airtime to send what it produced: twelve readings an hour instead of one nearly triples a node's consumption, and almost none of that is the probe.
- Which node a sensor sits on is the power decision, and it outweighs every configuration choice. The same hourly soil probe lasts around 1500 days on its own node and around 130 days on a node a five-minute trough float keeps awake. Nothing in the sample-rate or encoding space comes within an order of magnitude of that.
A latency requirement is a ceiling, not a schedule. max_latency is the longest a reading may wait, not how often the radio should speak. A node sampling twelve readings an hour and sending one message an hour that holds five is permanently behind and will lose data forever while appearing healthy — inside its latency ceiling the whole time. So a node MUST also drain whenever it holds more than one frame's worth and its duty cycle affords the airtime.
An infeasible configuration MUST be refused at registration. Compare what a node produces — the sum of 3600 / sample_interval across its channels — against what it can deliver, which is the readings that fit one frame at the current data rate multiplied by the frames the duty budget affords. If production exceeds delivery, no buffer policy saves it: the node will lose data permanently, and the farmer will discover it as unexplained gaps in month three. The foundation says so when the device is registered, with the three things that fix it: sample less often, raise the data rate, or split the channels across nodes.
So the installation rule: do not co-locate a series with a loose latency on a node whose tightest latency is short, unless the wiring makes it unavoidable. An extra reading that fits a message the node was already sending is close to free; an extra transmission is not. A driver manifest declares both intervals per point (§9.1) so the foundation can tell a farmer which of their nodes is short-lived and why.
5.4 Gaps#
Gaps in (boot, seq) are logged as gaps and never filled with guesses. A boot increment is a reboot, not a gap, and is logged as such. Modules watch for new observations and events through a stable subscription API (§7.5); nothing above layer zero polls.
6. Command contract#
Commands state a desired outcome, carry their own safety bounds, and are safe to send twice, so a lost or repeated message can never leave a pump or valve in a bad state.
6.1 What a command carries#
- A UUIDv7 command ID, held server-side
- A short command sequence, node-scoped, which is what actually travels on a constrained link (§6.2)
- The target point and the desired state, not a toggle: "valve 3 open", never "flip valve 3"
- A maximum duration or expiry, after which the node reverts to its safe state on its own
- Who issued it and why — a user, a regimen, a rule — for the log
- An optional
dry_runflag
6.2 Identity on constrained links#
A 16-byte UUID does not belong in a downlink. §1 bans it for uplinks on airtime grounds and the same arithmetic is worse downward: LoRaWAN Class A offers two short receive windows after an uplink, and DR0 gives about 51 bytes of payload for the whole command.
So the UUIDv7 stays server-side, and the wire carries a short command sequence scoped to (node, session) — the same registry-mapping pattern already used for point IDs. Issuer and reason are stamped by the gateway and never transmitted. The registry mapping is part of the audit chain: "which command did the node actually run" resolves through it, so the mapping is itself logged.
6.3 Idempotency has a stated bound#
"Repeats with the same ID are ignored" is only true while the node remembers the ID, and a node's memory is small and clears on power loss. Unbounded, the claim fails in exactly the case the contract exists to prevent: a retry across a brownout runs the pump twice.
- A node MUST ignore a repeated command sequence until that command's expiry has passed, plus a margin declared in its driver manifest.
- A node MUST either persist that set across cold starts, or declare
dedup_volatilein its manifest. For adedup_volatilenode, the foundation MUST refuse to issue any command whose double execution is not bounded by a node-side maximum runtime — the remaining protection being the max-runtime limit and the safe state on power loss, which is then stated rather than assumed. - A retry MUST reuse the same command ID and sequence. Minting a new ID for a retry is a conformance violation, because it defeats every deduplication guarantee above.
- How a node persists it (Q13): FRAM, or a lazily written ring in flash, where the hardware allows; where it does not, the node declares
dedup_volatileand the rule above applies. A node never silently has volatile dedup.
6.4 Acknowledgement, and the honest absence of one#
The node answers accepted, rejected with a reason, or completed.
No acknowledgement within the link timeout means unknown, not "not delivered." A command lost on the way out and an acknowledgement lost on the way back are indistinguishable from the server, and the node may well have acted. Recording that state as "not delivered" invites a reissue as a fresh command, which double-executes. The contract is: the state is unknown, the resolution is a retry with the same ID (§6.3), and the backstop is the node's own expiry and maximum runtime.
A dry_run acknowledgement MUST name the limit that would have been hit, if any. A bare rejection teaches a backtest nothing.
6.5 Limits and shared resources#
Node-side limits are configured on the node, not sent with each command: maximum runtime, minimum off-time, interlocks with other outputs, and the safe state on power loss or link loss. The server cannot override them.
Shared-resource capacity is checked in layer zero, beside per-point authorization. v0 put the resource registry in layer zero but the booking in the layer-1 scheduler, while also declaring layer 1 non-safety-critical and expected to be down sometimes — so "two modules can never overdraw one well" rested on a component designated optional, and per-node interlocks cannot see another node's valve. Capacity is the same kind of check as permission, on a path that already exists, so it belongs here. The layer-1 scheduler still books ahead; layer zero refuses a command that would exceed a declared capacity regardless of who issued it.
Hardwired interlocks for machinery and high-energy loads sit below all of this and are outside software's reach.
6.6 Standards alignment#
Tasking has never been part of SensorThings Part 1, in either version — it lives in a separate document, Part 2: Tasking Core (OGC 17-079r1). So the 1.1-versus-2.0 choice in §10 does not bear on this one. The choice here is Part 2 Tasking Core as it stands, or OGC API — Connected Systems Part 2, whose ControlStreams are published and purpose-built for commands flowing to and from systems. Decided (Q3b): our own contract — this section's safety properties are more than either standard offers — with a Connected Systems adapter, and Connected Systems Part 2 read properly before this section is frozen.
7. Storage, ownership and sharing#
The farm's own machine holds the authoritative copy; the cloud is optional and never required to operate.
7.1 The log is authoritative#
Every change to records, every observation, every command and every decision is an event. Current state is derived from the log, so history can always be replayed and audited.
farmOS is a projection of the log, not the backend. v0 listed farmOS as the records backend and as an adapter at once, which cannot hold: if a mutable Drupal database owns records, then the authoritative copy is not the log, state is not derived from the log, removing an "adapter" destroys the records, and there are two sources of truth with no reconciliation. Instead:
- Writes append to the log, always.
- A projector materializes farmOS one-way, so existing farmOS clients keep working.
- Writes arriving through the farmOS-compatible API are translated into appended events, not applied to Drupal directly.
Every principle survives this; the alternative split is recorded in open-questions.md Q8, where this choice was confirmed, with serving the farmOS API directly and retiring the projector as the end state.
7.2 Two stores#
Derived state is indexed, not recomputed. The promise below is not free. A reference implementation that rebuilt the live set for every quality check, re-sorted the whole event log on every append, and located a row in its series by value equality took a minute to ingest one simulated day of a five-node farm, and got worse with every day — all three quadratic, against the guarantee in the next paragraph. A conformance suite should include a season-scale ingest, because nothing smaller reveals it.
High-frequency sensor data goes to a time-series store with downsampling and retention rules. Records — assets, logs, plans — go to the main store. Years of readings never slow everyday records. Both are projections of the log; neither is authoritative.
No retention rule deletes a raw observation (Q15). Raw is kept at full resolution for as long as the farm keeps its log; downsampled series are derived views that can be dropped and rebuilt. Only the farmer deletes raw, by an explicit act the log records. Where a hub's disk is the limit, old raw moves to farmer-owned cold storage in the archive format of §7.6, compressed and checksummed, rather than being deleted.
7.3 Corrections and erasure#
- A correction is a compensating event. Nothing is ever updated in place. The farmOS-conventions side of the model is CRUD-shaped and will tempt an implementer to mutate a row; the conformance suite tests that they did not.
- Erasure, when it is genuinely required — a worker's personal data, for instance — is performed by destroying the payload's encryption key and retaining the event skeleton, so the log stays continuous and auditable while the content becomes unrecoverable. An append-only log with no erasure story is a liability the first time someone has a legal right to removal.
- The keys (Q17). Personal text is sealed to a key belonging to that one person, with the person bound into the seal. The persons' keys are wrapped by a farm key and kept in a key ring that may be backed up with the log. The farm key MUST NOT be backed up with the data; it lives on the gateway with one offline recovery copy. Erasing a person destroys their key and rotates the farm key: every other key is re-wrapped under a new one, and the old farm key and its recovery copy are destroyed. That is what reaches a backup taken before the erasure: it still holds the person's wrapped key, and nothing that can unwrap it exists. The erasure itself is a log event naming the person's id and not the content.
7.4 Offline edits#
Devices that edit while disconnected sync later. Conflict resolution happens at projection time, deterministically, and never by discarding an event.
Ordering uses hybrid logical clocks, not wall clocks. Last-writer-wins on wall-clock time is unsafe in this system by its own admission: §2 records clock_quality precisely because farm devices' clocks cannot be trusted, and a node reporting clock_quality = none must never win a last-writer contest against a GPS-synced one on the strength of a bad timestamp. Every event carries an HLC stamp; LWW-per-field applies to HLC order.
Where two writers touched the same field concurrently and the field is one where that matters, both versions are retained and a conflict event is emitted for a person to resolve. The conflict event is itself in the log. The fields where it matters (Q14): inputs applied, animal treatments and their withdrawal periods, harvest lots, field and bed boundaries, and anything an interlock or a node-side limit reads. Set-like fields — tags, animal groups — merge by union rather than conflicting.
Concurrency is decided by what each writer had seen, not by the stamps. An HLC stamp puts every event in one order, but two stamps cannot say whether one writer knew of the other. So every edit MUST name the versions of the record its writer had seen (its base). An edit with another in its history follows it, whatever either clock said; two edits neither of which had seen the other are concurrent. A conflict is concurrent versions of a protected field that disagree. A person resolves it by editing the field having seen every candidate — an ordinary edit, so the resolution is history like everything else.
- A void concurrent with any protected edit is a conflict. Field by field the two never meet, and the void would quietly take a withdrawal or a spray off a record while another device was correcting it.
- A removal from a set removes only what its writer had seen added, so an addition concurrent with a removal survives it.
- Checks read every candidate. A treated animal's withdrawal and a harvest inside a pre-harvest interval are judged on the cautious candidate, so a disagreement between two devices can never shorten a withdrawal or hide a breach.
Implemented as sdk/records.py (land, animals, inputs applied, animal treatments, harvest lots) and sdk/edits.py.
7.5 Subscriptions#
"Nothing above layer zero polls" implies delivery guarantees, so they are stated: at-least-once, with a durable cursor each subscriber can replay from. A module that restarts, silently misses the events it was down for, and cannot replay them will make wrong decisions while its decision events claim full provenance. Implemented as sdk/subscribe.py.
- The cursor MUST be an offset into arrival order, not a position in HLC order. §7.4 merges events from other replicas by stamp, so an edit made offline yesterday and synced today lands in the middle of history. A cursor that is an index, the last stamp seen, or the last event id is already past it, and the subscriber never sees it. This is a silent miss that happens only in the offline case — the one this spec was written for and the one least likely to be tested. Arrival order only ever grows at the end, and is already durable because the log file is appended in the order events arrive.
- Act first, then save the cursor. A crash in between costs a redelivered batch; the opposite order costs a lost one. Handlers MUST therefore be idempotent on the event id.
- Delivery is in arrival order. A subscriber that needs causal order sorts by stamp itself. Completeness and causal order are different promises, and only completeness can be made without buffering forever.
- A cursor belongs to one replica, and MUST refuse to run against another. Arrival order differs between replicas, so a borrowed offset delivers the wrong events. Each cursor carries the id of the last event it covered, and a mismatch is an error rather than a guess — resetting to zero redelivers a season, and trusting the offset delivers the wrong things.
- A filtered subscriber's cursor advances over what it skips, or it re-scans every non-matching event forever.
- Wakeups are a convenience; the cursor is the guarantee. A subscriber that is down when it would have been woken misses the wakeup, not the event.
7.6 Export and sharing#
- Full export. Everything exports in open formats (CSV, JSON, GeoJSON for geometry) at any time, with no fee and no support ticket. Raw and calibrated series, quality assertions and their threshold versions are all included; an export that carried only calibrated values would be the silent cleaning this spec exists to prevent. Implemented as sdk/export.py.
- The unit exported is the event, not the record. §7.3's compensating events carry a reason and no record at all, so an export assembled from records drops every correction — the audit trail minus the part that says something was wrong. That is the easier mistake to make, because the record interface is the more convenient one.
- Exactly one file is the archive, and the others MUST say they are not. The archive MUST be byte-compatible with the log's own on-disk form, so that importing it is a replay rather than a second decoding that could disagree with the first. CSV is for a spreadsheet and GeoJSON is a view for GIS clients; a GeoJSON export carries current values and summaries, not history, and MUST declare itself lossy. An export that claims to lose nothing and quietly does is worse than one that declares its losses, because the loss is discovered years later by whoever needed the missing field.
- Losslessness MUST be verified rather than asserted. Each value is decoded back from what was written and compared before the export claims the format kept it. Reasoning about which values are safe is not sufficient: the first implementation reasoned, and was wrong about a
check_versionof"1", which a CSV cell returns as the integer1. - An export MUST be verifiable as a copy. It carries a manifest with per-file checksums and counts, and a
verifyoperation that re-checks them. Without that, the manifest is a claim about files rather than a check on them, and the export is not evidence. - A feature with no geometry MUST be reported, never silently omitted. A GIS client discards a geometryless feature without a word, which is how a missing bed becomes invisible instead of noticeable.
- Size is part of the promise. A simulated week measures 35.1 MB, or 3.4 MB gzipped — 10.4x — putting a 180-day season near 900 MB against 87 MB. A farmer who cannot move the file has not been given their data, so compression MUST be available; the uncompressed form stays canonical because that is the one the log itself reads.
- Revocable sharing. A grant states what data, to whom, for what purpose and until when. A grant MUST also state whether it covers observations that arrive after the grant was made, and whether the grantee may re-share. Both matter immediately for research and co-op use, and leaving them unstated means two implementations will behave differently on the farmer's most sensitive operation. By default a grant covers only what has arrived when it is made (Q16); one that also covers later arrivals MUST carry an end date, so an open-ended stream of a farm's data is never made by accident, and a standing arrangement renews by a new grant. "Arrived" is
received_at: a backlog that reaches the gateway after the grant is new to the farm too. Grants and revocations are log records. - Revocation stops future access and does not retract what was already delivered. Say this plainly to the farmer at the moment they grant, because "revocable" reads as "I can take it back".
7.7 Decisions and required records#
Automated actions are logged as decision events: the observations used as inputs, the rule or model and its version, the output, and the trust level it ran under. Any action can be traced back to why it happened.
A decision says which question it answers and how to say it, so that anything that carries it to a person — a notifier, a page — needs nothing from the module that made it. Its topic is the question ("is the north trough empty?"; by default its rule and subject), and the latest decision on a topic is the standing answer; valid_until, when given, is when that answer goes stale (tonight's frost watch, at dawn). Its message names a key, its parameters, and the parameter its subject's label fills in the reader's language, the key chosen when the rule is judged rather than when it is read. A module that writes only when an alert is raised or cleared and one that writes a fresh report every morning then read the same way.
The log model must be able to produce certification and compliance records — organic, produce-safety water testing, pesticide application — without custom code. Exact requirements vary by country and program. Note that compliance records depend on quality assertions, which depend on thresholds: anyone who can edit a threshold can change what a compliance report says, which is why threshold edits are privileged and logged (§8.2).
8. Security#
Secure by default, because no farm will harden its network later.
8.1 Devices and links#
- Device onboarding. Each device gets its own key when it is registered. Unregistered devices are heard but not trusted, and their data is quarantined until someone approves them.
- Signed firmware. Updates are signed and the version recorded. LoRa links are too slow for over-the-air updates, so the spec plans for physical update visits on those nodes.
- Encrypted links where the link allows it. LoRaWAN and Meshtastic both offer link encryption; wired and wifi links use TLS to the gateway.
- No default passwords, no open admin ports, and the log records every login and permission change.
8.2 Users, roles and privileged operations#
Roles: owner, manager, worker, viewer, service account. Commands are authorized per role and per point. Three operations are privileged beyond ordinary writes and were unassigned in v0:
| Operation | Minimum role | Why |
|---|---|---|
| Issue or revoke a sharing grant | owner | It moves the farm's data off the farm |
| Grant or revoke a module's access | owner | It hands the farm's data to software (§8.3) |
| Issue or revoke a token for the data service | owner | It decides who can read over the network |
| Edit a datastream threshold | manager | It changes quality assertions, which change compliance records (§7.7) |
| Override a quality assertion | manager | Same, for one reading |
Node-side limits are not in this table because no role can change them remotely; they are configured on the node (§6.5).
8.3 Modules#
Modules are permissioned like users. Each declares what it may read, write and command, scoped per point; the farmer grants it at install and the foundation enforces it, which is what makes third-party modules safe to run. Packaging and isolation (Q11): for v0, each module is a process under systemd holding a capability token for exactly what it was granted, and it reaches the foundation over a unix socket that checks the token on every call; WASI's capability sandbox is the stated target, adopted when the Python toolchain is ready.
Built as sdk/modhost.py and deploy/systemd/:
- A manifest is a ceiling; a grant is the farmer's. A module's
module.jsonsays what it may ever ask for. A grant names the points, within that, and is a log record like a sharing grant. The log holds the token's hash, never the token. - No module writes the foundation's records. Observations, quality assertions, thresholds, calibrations, corrections, labels and every kind of grant are refused whatever a manifest declares, and commands are deferred with §6.
- Writes carry the module's name and grant, so every decision is traceable to the module that made it, and a module reads back only what it wrote: modules never reach into each other (§11). The one exception is granted, read-only and named in the grant record: an owner may let a module read every module's decisions (§7.7) and what was done about them — notifications sent, acknowledgements — and the names of what those decisions are about, followed by arrival cursor (§7.5). It is what a notifier and a page need, and it reads a layer-zero record, not another module: neither imports the module whose decision it carries. A decision carries what it was judged on, so the grant says so plainly; readings, records and grants are never readable this way.
- A refusal is logged, once per module, operation and reason, and counted after that, so a module looping on a refused call cannot fill the farm's disk with the record of it.
- The module's view is as of a moment. A module reads a reading's quality as asserted by the time it asks about (
State.quality_as_of), the same rule a backtest needs.
9. Driver SDK and conformance tests#
Anyone can add hardware without touching the core, and a passing conformance suite is what makes a driver or implementation official.
Two driver kinds, one plugin interface each:
- Device drivers turn a protocol into raw readings and accept commands: Modbus RTU/TCP, SDI-12, I2C, 1-Wire, 4–20 mA and 0–10 V analog, BLE, local HTTP APIs, CAN/J1939/ISOBUS, NMEA and NTRIP.
- Link drivers move messages: Meshtastic, LoRaWAN via ChirpStack, MQTT, wifi, cellular, wired.
9.1 Driver manifest#
A driver declares, and the foundation handles the rest:
| Declaration | Used by |
|---|---|
| Points, with channel indices | §1 wire identity |
| Units (UCUM) and observed-property URIs — CF, ODM2, AGROVOC by role | §3.4 |
| Sample interval, and maximum latency, per point | §3.1, §4.2 staleness, §5.3.1 transmit schedule |
| Safe states | §6.5 |
| Clock source, and timestamp uncertainty budget | §2 |
| Delivery-confirmation capability | §5.2 |
| Backlog policy defaults | §5.3 |
Dedup persistence, or dedup_volatile |
§6.3 |
Python is the first SDK language, with Rust for the edge daemon.
9.2 Conformance suite#
- Simulated devices for every driver kind — these are not fixtures written per test but the devices of one canonical deterministic farm, which the suite also uses as a completeness gate: a clause of this spec that nothing on that farm exercises is a clause nothing tests. See VIRTUAL_FARM.md
- Replay of real recorded field data. The usable source is the International Soil Moisture Network: 71 networks, 2,842 stations, data from 1952, harmonised to hourly UTC in m³/m³ — our
cm3/cm3exactly — and, decisively, flagged values are retained rather than deleted, which is the difference between a labelled fault dataset and a list of holes. ItsCflags are geophysical-range violations, which isout_of_range; itsDflags come from consistency and spectrum checks, the family flatline and rate belong to. SCAN's own API is live and free but deletes suspect values, so it supplies labelled gaps and regional threshold evidence rather than labelled bad values. See notes/validation-data.md - Public data cannot test the transport contract, and the suite should not pretend otherwise. Every archive has already discarded the layer §5 exists to solve: no frames, no sequence numbers, no ages, no duty cycle. A harmonised hourly series is what survives after those problems were handled by somebody else. The transport half is tested by the simulator and then by a season
- Scored detection, not asserted detection. A health check's claim to catch drift or a stuck probe is measured against ground truth — precision, recall, false-alarm rate on clean rows, detection latency from onset, and whether each fault episode was caught at all. "It flags the obvious case" is not a measurement. The scorer must not be able to see what was injected; a detector scored against the fixture that produced it is scored against itself
- A control drill, every time. A run with nothing wrong must flag nothing. This is not a formality: the first scored run on a clean farm reported a 99.8% false-alarm rate, because the cross-check was comparing a derived series in physical units against a neighbour's raw series in wire units — 0.280 against 280. No amount of fault-detection recall would have revealed that
- Fault injection: drift, stuck values, dead batteries, dropped and repeated messages, clock jumps, node reboots with counter resets, and buffer drains after multi-day outages
- Command safety tests: lost acks, duplicates, link loss mid-command, retry across a node reboot
- Append-only tests: that a correction appended rather than mutated, that recalibration superseded rather than overwrote, that a threshold change did not alter historical assertions
Reliability claims require at least one full growing season of field data, not a few weeks in the lab.
The suite cannot certify anything until the blocking open questions are closed. A conformance test needs a decided wire format, a decided unit system and a decided point-index scheme. "A passing suite is what makes an implementation official" is a claim the suite cannot yet support; open-questions.md marks which questions block.
10. Interoperability adapters#
| Adapter | Direction | When | Purpose |
|---|---|---|---|
| CSV and spreadsheets | In and out | v0 | Bring in existing records; nothing re-entered by hand. In: sdk/csv_import.py — a declared timezone is required, ambiguous and nonexistent local times are reported rather than guessed, and values land unaltered on a raw datastream. Out: sdk/export.py |
| SensorThings API 1.1 | Out | v0 | Standard sensor-data endpoint for GIS and research tools |
| Local weather stations | In | v0 | An Ecowitt gateway's customized upload, WeatherFlow Tempest's UDP broadcast and Davis WeatherLink Live's local API, read by sdk/station_adapters.py and written by the MQTT bridge's bindings and rules; candidates and how each reaches the log in notes/weather-stations.md |
| Weather forecasts | In | v0 | Onto forecast datastreams (§3.2; Q26) by sdk/forecast.py; sources in tools/forecast_sources.py: NWS for the United States (public domain), MET Norway worldwide (CC BY 4.0; its terms require the caller's contact) |
| MQTT device families | In | v0 | ESPHome, Tasmota, Shelly, Zigbee2MQTT, Home Assistant, OpenSprinkler, rtl_433's weather stations and gauges heard off the air, and ChirpStack v4 LoRaWAN uplinks — sdk/mqtt_bridge.py. Bindings are the allowlist; a timestamp with no zone is never read in the host's zone |
| Modbus RTU devices | In | v0 | Polled over RS-485 by sdk/modbus_collector.py from a register map written from the maker's guide, one burst read a poll; the first map is METER's ATMOS 41 Gen 2 weather station. Read function codes only (§6) |
| farmOS API | In and out | v0.2 | Existing farmOS clients keep working, over a projection (§7.1) |
| SensorThings API 2.0 | Out | When published | Migration; see notes/sensorthings-2.0.md |
| ISOBUS task data (ISOXML) | In and out | Later | Prescriptions and as-applied data from machinery |
Ship on 1.1: the 2.0 draft is unpublished, and its serialization differences are confined to one layer if the adapter is written with that in mind.
10.1 Query options, and the rule about refusing#
The SensorThings endpoint MUST support $filter, $orderby, $top, $skip, $count, $select and $expand, with server-driven paging via @iot.nextLink.
An unsupported query option MUST be an error, never silence. A client that sends $filter=result gt 0.4 and receives every reading has been told something false: it cannot distinguish "no filtering happened" from "nothing matched the filter", and a map or a chart built on that shows wrong data with no indication of it. Anything the service cannot evaluate MUST return 400 (malformed) or 501 (valid OData, not implemented), with the offending text identified. $top beyond the server's cap MUST be refused rather than clamped, for the same reason.
The service root MUST carry serverSettings.conformance, and MUST NOT claim a requirements class unless every requirement in it is met (1.1 §9.2.1). A client reads it to decide what it may rely on, so overclaiming is worse than underclaiming; this implementation declares the nine data-model requirements and not request-data (no geospatial $filter), resource-path (no $ref), create-update-delete, batch, data-array, multi-datastream or MQTT. Every link the service emits MUST resolve. A self link or navigation link that answers 404 is worse than an absent one, because a client learns only after following it. A service MUST NOT advertise a navigation link to an entity set it does not serve — and the fix for that is to serve the set, not to drop the link: a Thing's Locations link answers with the Locations derived from its feature's geometry, and its HistoricalLocations link answers with an empty collection, truthfully, because Things here do not move and devices that do are recorded as §4.1 Installations, which 1.1 has no entity for. This clause exists because two such links shipped — see notes/qgis-validation.md — and both were invisible to tests written from the inside.
An entity key that is not a UUID MUST be percent-encoded in a link, and decoded only after the path is split. §3.4 makes an observed property's identity its vocabulary URI, whose slashes are otherwise indistinguishable from path separators.
observationType MUST be derived from the datastream's declaration, not from the type of a result. A declared unit means a measured quantity; that is what a unit is for. Typing a series from a sample's runtime type made a scaled-integer raw series an OM_CountObservation carrying a unit, which is self-contradictory, and left the type unknowable until the first reading arrived. Only an undeclared unit lets the values decide.
Geometry is served, but is not a log record. A feature of interest's geometry reaches the serialization through the same side table as human names (§1), because a farm's field boundaries are the farmer's material and belong in site-private storage, not in an append-only log that may be published. A GIS client silently drops a feature with no geometry, so a service that has geometry available MUST serve it.
Both versions are served side by side once 2.0 is published, from one internal model, at separate base paths. This is the ecosystem's expected pattern rather than an accommodation: FROST-Server, the most widely deployed implementation, ships 1.1 and 2.0 API plugins that can be enabled simultaneously over a single data model. The internal model is deliberately richer than either serialization, so neither version's Observation shape may leak into it — that constraint is what keeps the migration a day's work. Validate both serializations against FROST-Server from the first release, since in practice it is what defines conformance in the field.
11. Layers above#
Everything above layer zero is optional and depends only on the layers below it.
Where each layer stands (2026-10-11):
- Layer 1. modules/rules/ judges CEL conditions and writes each judgement as a §7.7 decision; its frost watch reproduces on a real season the backtest that chose its threshold. Starlark regimens of steps, waits and checks run in regimen.py (Q10). modules/notify/ carries any module's alerts to people by push or SMS, in their language, escalating through a route's tiers until someone acknowledges by reply or button. Not built: a scheduler that acts, resource conflicts (both need §6), and analytics.
- Layer 2. modules/water/, the livestock water monitor (Q12), passes its §15 acceptance test on the virtual farm. modules/soilwater/, the soil-moisture advisory, is backtested on recorded summers, and its watering check, the first regimen, says when a watering never reached the bed. modules/zoneflow/ sets each irrigation zone's runs beside its usual flow and its heads, and says when a head is clogged or broken; it is drilled on simulated seasons. modules/sowing/ says each morning whether a bed's soil at seed depth is warm enough to sow each crop meant for it, from Harrington's germination tables. modules/salinity/ sets a bed's salt, from its probe's bulk conductivity, beside its crop's tolerance. Not built: irrigation control, which needs §6, and the modules the farm's records were built for (Q8, Q27).
- Layer 3. modules/advise/ answers what grows where and when. modules/twin/ runs GreenLight as an unheated tunnel (Q28): checked against the virtual farm's tunnel, as a covered-crop frost watch each evening, and against a tunnel's own sensor. Not built: cost per field or crop.
- Layer 4. modules/board/, the thinnest slice: one read-only page per language of the alerts that stand, who was told and who is on it, the latest readings, and a plain warning when it has stopped updating. Not built: a field app, voice, an assistant.
- Layer 5. Nothing yet.
CEL and Starlark both refuse I/O, which is what makes a decision event's provenance claim true. Conditions are expressions ("soil moisture below 18% for 6 hours"), cheap to evaluate, store and display; regimens are sequences that need control flow.
| Layer | What lives there | Must get right |
|---|---|---|
| 1. Core services | Rules and alerts, scheduler for sequences and regimens, notifications, query and analytics | Rules backtested on past seasons before going live; resource conflicts resolved; alerts escalate and need acknowledgement |
| 2. Domain modules | Irrigation, greenhouse climate, livestock, storage and cold chain, field operations, robotics, inputs and inventory, harvest and traceability, compliance reports | Built only on layers 0 and 1; each one removable |
| 3. Decision support | Agronomic models, forecasting and ML, cost per field or crop, simulation | Models versioned and calibrated per site; confidence shown; readings whose live assertion is not good excluded, with the exclusion recorded |
| 4. Apps and interfaces | Phone field app, desktop planning, SMS and voice, a plain-language assistant | Offline, glove- and sunlight-friendly, multilingual through language packs (LANGUAGE_PACKS.md) — records store codes and message keys, never translated text; the assistant cites readings and never commands without approval |
| 5. Ecosystem | Module registry, multi-farm and co-op use, research sharing, buyer and certification exports | Signed modules with declared permissions; sharing only by consent |
Rules for building upward:
- Dependencies point down only. A module never reaches into another module, only into the layers below, so any module can be removed.
- Trust ramp per module. Every automation runs as advisory, approval or autonomous. Farms move up one step at a time, and the level is recorded on every decision event.
- Nothing above layer zero is safety-critical. If layers 1 to 4 go down, nodes keep running their local schedules and limits, and layer zero still refuses commands that exceed a declared capacity or a per-point permission.
- Shared limits are enforced, not hoped for — in layer zero (§6.5), not only in the layer-1 scheduler.
- Cost visibility early. Showing cost and return per field answers the barrier farmers name most often: unclear value.
12. Versioning and governance#
The spec is published separately from the code, so others can build on it or write their own implementation.
- Semantic versions. Breaking changes only in major versions, each with a written migration. The message format carries its version in every packet.
- Spec, reference implementation, conformance suite are three versioned artifacts released together.
- Change process. Public proposals with a comment period before a breaking change lands.
- Liability, sustainability, legal entity. A fiscal host now and an own entity later; no commands in the first release, hardwired interlocks below everything, insurance once an entity can buy it (open-questions.md Q9). The license is decided — spec CC-BY-4.0, SDK and conformance suite Apache-2.0, reference implementation AGPL-3.0-or-later; see LICENSING.md.
- Partners. University extension services already have farmer trust and run research plots, which makes them natural pilot partners.
13. Open questions#
All questions, with every option and a recommendation, are in open-questions.md. Those that block the first release, because a conformance test cannot be written around them:
| # | Question | Why it blocks |
|---|---|---|
| Q4 | Unit system and observed-property vocabulary | Datastreams cannot be declared |
| Q6 | Which link ships first | Decided 2026-10-09: Meshtastic, with a wired Modbus control in the test suite |
| Q8 | farmOS's role | Decided 2026-10-09: a one-way projection (§7.1) |
| Q1 | Pilot farm | No longer gates the first release — the simulated farm substitutes for development. It still gates every reliability claim, and seasons do not wait, so deciding it late costs a year |
14. Changes from v0#
| § | Change | Why |
|---|---|---|
| §7.1 | farmOS becomes a one-way projection; the log is authoritative | v0 called farmOS both the records backend and an optional adapter, which contradicted Modular, Auditable and "state is derived from the log" |
| §3.2 | Raw and calibrated values become separate datastreams; observations immutable; recalibration appends with supersedes |
v0 kept two results in one row with no link between re-derivations, which put two observations at one phenomenonTime and broke the SensorThings-compatibility promise on first recalibration |
| §3.2 | Calibration selected by measured_at, not ingest time |
Buffered readings would otherwise get coefficients that were not in force when they were taken — silent, and a Honest data violation |
| §3.3 | Quality becomes append-only assertions with threshold and check versions, a flag set, and a usable summary |
A mutable column on an immutable record; threshold edits silently re-flagged or mis-flagged history; the single enum could not express concurrent conditions, so two implementations could disagree |
| §4.2 | stale removed from reading flags, becomes datastream health |
Staleness is a property of a series at a moment; flagging a reading means mutating a correct record because a later one never arrived |
| §4.3 | Battery and signal become device datastreams | v0's device warning was a device state in a reading-level enum, and it was not in the enum's value list |
| §1 | boot counter added; seq width and wrap defined |
A node that resets seq on reboot had all later readings dropped as duplicates — common on solar nodes |
| §1 | id declared not a sort key; site declared not part of any key |
UUIDv7 order is ingest order, so backfilled data sorts wrong; v0 credited the site for collision-freedom that UUIDv7 provides |
| §2 | measured_at_uncertainty added |
clock_quality is a category; a research logger needs a bound |
| §2 | Age stamped at transmit, not sample | Otherwise a buffered reading's measured_at is wrong by its dwell time |
| §3.1 | Feature moves to the datastream, overridable per observation | It is fixed for an installed sensor; repeating it costs storage and airtime for no information. Matches 2.0's Datastream feature links |
| §5.2 | Batching in the wire format; dedup key per message | A 1% duty cycle cannot carry one message per reading; retrofitting would change the dedup contract |
| §5.2 | Link drivers declare a delivery-confirmation capability; cumulative ack for unconfirmed links | "Buffer until the link confirms" is unavailable on Meshtastic and unconfirmed LoRaWAN, the two primary targets |
| §5.3 | Backlog policy added | Undefined eviction after an outage, and the target apps want opposite answers |
| §6.2 | Short command sequence on the wire, UUIDv7 server-side | A 16-byte ID contradicted §1's own airtime rule and will not fit a DR0 downlink |
| §6.3 | Dedup window bounded by expiry; reboot persistence required or declared | Unbounded, idempotency fails exactly where it matters — a retry across a brownout |
| §6.4 | No ack means unknown, not "not delivered"; retries reuse the ID |
A lost ack is indistinguishable from a lost command; "not delivered" invites a fresh ID and a double execution |
| §6.5 | Capacity enforcement moves into layer zero | v0's strongest resource guarantee rested on the layer-1 scheduler, which v0 also declared non-safety-critical |
| §7.3 | Corrections are compensating events; erasure by key destruction | Append-only had no story for a wrong value or a legal erasure obligation |
| §7.4 | Hybrid logical clocks instead of wall-clock LWW | §2 already says farm clocks are untrustworthy; a clock_quality = none writer must not win on a bad timestamp |
| §7.5 | Subscriptions: at-least-once with a durable cursor | A module that misses events while down would make decisions whose provenance claims are false |
| §7.6 | Grants state future-data scope and re-sharing; revocation semantics made explicit | Unstated, implementations differ on the farmer's most sensitive operation |
| §8.2 | Privileged operations named | Nobody could issue a grant; threshold edits silently affected compliance records |
| §3.4, §10, §13 | UCUM confirmed from the standard; ship on SensorThings 1.1; 2.0 has no tasking and no resultQuality |
Verified against both specification documents |
| Scope | First release cut to one vertical, observations only | Full v0 is multi-year, and commands carry the liability that most benefits from a season of evidence first |
15. The three-apps acceptance test#
| App | What it needs beyond v0 | Clause that provides it |
|---|---|---|
| Irrigation controller | Idempotency that survives a node reboot; no overdrawing one well from two modules | §6.3, §6.5 |
| Livestock water monitor | Newest reading first after an outage | §5.3 drain_order: fresh_first |
| Research data logger | A bound on timestamps; re-deriving a season without destroying the original | §2 measured_at_uncertainty, §3.2 |
Each of these required a layer-zero change under v0, so v0 did not yet meet its own done criterion. Re-run this test against any future revision.
The livestock water monitor passes (modules/water/, 2026-10-10), after its first run found that the clause it rests on did not hold in practice: resends went ahead of fresh readings, and per-frame confirmations were read as cumulative (§17). The irrigation controller waits on §6; the research logger is not built.
The test runs against the simulated farm, where each app has a feature built to host it — beds with drip for the controller, a paddock with troughs for the monitor, a plot with duplicated probes for the logger. Passing it there proves the contract is sufficient; it does not prove the thresholds are right, which is what the first season is for.
16. Principle enforcement map#
| Principle | Enforced by |
|---|---|
| Auditable | §7.1 log authoritative; §3.3 assertions; §7.3 compensating events; conformance tests in §9.2 |
| Farmer-owned | §7.1 local authority; §7.6 export includes raw and assertions; §7.6 grant scope |
| Honest data | §3.2 separate raw series, immutable, superseding; §3.2 calibration by measured_at; §5.3 dropped readings reported; §5.4 gaps never filled |
| Works offline | §5.2 cumulative ack; §5.3 backlog policy; §6.5 node-side limits |
| Modular | §7.1 farmOS demoted to a projection; §9.1 manifests; §8.3 module permissions |
| Standards first | §3.4 UCUM; §2 and §3.3 SensorThings mappings; §10 adapters |
| Safety below software | §6.3 bounded dedup; §6.4 unknown; §6.5 node-side limits and layer-zero capacity; hardwired interlocks below all of it |
| Reimplement, don't copy | §7.1 no farmOS code in the critical path; Q2 license decision |
17. Amendments after v0.1#
Recorded here rather than folded in silently, because §12 makes the spec a versioned artifact and a change with no stated reason is the thing this project exists not to do. None of these is breaking: each adds an optional field or settles an ambiguity in the only direction that was coherent.
| Date | § | Amendment | Why |
|---|---|---|---|
| 2026-10-12 | §3.4 | Water pressure in a line is a registered term: ODM2 pressureGauge, in [psi], kPa or bar |
An irrigation zone's flow goes as the square root of its pressure; a gauge beside the meter takes each run to one pressure, and a clogged quarter-circle head shows (modules/zoneflow) |
| 2026-10-12 | §3.4 | Soil water potential (ODM2 waterPotential, in kPa, cbar, hPa or MPa, never above zero) and the bulk soil's electrical conductivity (ODM2 bulkElectricalConductivity, in dS/m, mS/cm, uS/cm or S/m) are registered terms |
A tension sensor beside a moisture probe measures the soil's own field capacity, and a probe's conductivity is how salt is watched under tunnels. CF has neither; ODM2's definitions are exactly these |
| 2026-10-11 | §3.4 | A water flow is a registered term: ODM2 discharge, in gal/min, L/min, m3/h or m3/s |
An irrigation zone's flow on each run, from a meter on the line, is how a clogged or broken head shows (modules/zoneflow). ODM2's generic flow-rate term covers it, so no local term was needed |
| 2026-10-11 | §3.4, §4.2 | Sunshine is a registered term (CF surface_downwelling_shortwave_flux_in_air, W/m2), and a sunshine series whose site is given is checked against FAO-56's clear sky at the reading's hour: above 1.7 times it, plus 25 W/m2, is out_of_range; a series declaring its readings hourly means may be held to 1.25 times the hour's mean |
A station's sunshine is the largest term in reference ET and the one a farm's station most often gets wrong: a clock in the wrong zone puts the sun up at night, a light sensor's lux arrive as watts. On three years of NOAA's reference station at Crossville, Tennessee, no healthy hour's mean passed 1.15 times the clear sky and no moment 1.66 times; with the faults put in, a clock on local time read as UTC was caught on 363 days of 2024, lux every day (tools/clear_sky_scan.py) |
| 2026-10-11 | §10 | The Davis adapter is WeatherLink Live, Davis's weather unit, whose local HTTP API and UDP broadcast are documented | The row named AirLink, which is Davis's air-quality monitor: an adapter built to it would have read particulates, not weather |
| 2026-10-09 | §6.3, §6.6, §7.1, §7.2, §7.4, §8.3, §11, §12, §13 | Decisions recorded where the spec had said "open": dedup persistence (Q13), the command standard (Q3b), farmOS confirmed as a projection (Q8), raw never lost to retention (Q15), the fields whose conflicts are never silent (Q7, Q14), module isolation (Q11), the rule language (Q10), the legal path (Q9), the first link (Q6) | The maintainer adopted the recommendations in open-questions.md as written. None changes a clause already built; each closes one that said "open" |
| 2026-10-11 | §4.1, §4.2 | An installation may record the cover over the device (surface: bare, organic mulch, inorganic mulch, plastic mulch, turf); the depth check reads a probe only against peers under the same cover, and says it cannot tell when too few share it |
Mulch, sod and plastic damp and delay the daily wave as extra soil does. On the simulator a probe 10 cm down under 5 cm of mulch read as 19.3 cm against bare peers and was flagged moved when nothing had moved. A cover laid later is a new installation, as a move is |
| 2026-10-11 | §3.4 | A registered term may be a truth with no unit (valve_open, the second local term); the vocabulary gate refuses any unit for it, a raw count included |
An irrigation controller reports its valves opening and shutting, and CF and ODM2 have no term for an actuator's state. The gate assumed every term a measured quantity and refused a datastream with no unit, which §3.4 gives a boolean |
| 2026-10-11 | §3.1 | A forecast datastream may be one member of an ensemble, declaring ensemble: {member, members}; the members of one ensemble share its sensor, observed property and feature |
An ensemble has several values per valid time per issue, which a forecast series may not hold. One series per member keeps the latest-issue rule and each member's whole run, which a window's event needs (Q26) |
| 2026-10-11 | §7.7, §11 | A regimen's run is decisions on one topic: a start decision carrying the plan its Starlark computed, the regimen's version and the SHA-256 of its source, then one decision per step as it falls due | A runner that restarts must carry on from the plan it made, not plan again on data that has changed since; and a decision about a step must name exactly the code that planned it, as a rule's names its version |
| 2026-10-11 | §7.1 | One log file has several writers on one machine — the ingest, the grant and token tools, the module host — and a replica reads back what the others appended without writing it again (Log.refresh); the first write after a line a power cut left half-written starts a fresh line |
The module host never read the file after starting, so a revocation made at the command line was not seen until a restart, and §8.3's "the next call is refused" was false between processes. Testing the fix found that the line after a cut one was glued to it and lost on the next replay, though the log's own notes call a cut line the expected case |
| 2026-10-11 | §7.7, §8.3, §11 | A decision may name its topic (the question it answers; by default its rule and subject) and when its answer goes stale (valid_until); its message names the sentence, chosen when judged, and the parameter its subject's label fills. An owner may grant a module every module's decisions, notifications and acknowledgements, read-only, followed by arrival cursor |
Carrying alerts to people (modules/notify) and onto a page (modules/board) needed both without importing the module that made them. Before, the water monitor chose its sentence and named its trough when rendering, in its own code, and a module could read only its own writes. Building the notifier showed why a topic is needed: the frost watch writes every evening and the trough monitor only on change, and "the standing answer" means the same for both only once a decision says which question it answers |
| 2026-10-11 | §7.6, §8.2, §10 | The data service asks every request for a token, issued by the owner as a log record holding only its hash, standing for a farm user or a grantee; a grantee is answered from a view of what their grants cover, so anything not shared is a 404 by every path; it binds beyond loopback only with TLS | §7.6 said what a grant covers and nothing about how a service holds a grantee to it. Filtering each answer would leave whichever path nobody thought of open; serving from the view leaves nothing to forget |
| 2026-10-11 | §8.2, §8.3 | Granting a module its access is an owner's act and a log record; no module writes the foundation's records; writes carry the module's name; a module reads back only its own writes; refusals are logged once and counted (Q11, built) | §8.3 said the foundation enforces a module's permissions and nothing about how. Built and run: two modules in their own processes, each deciding exactly as it does in-process, under a systemd sandbox that systemd-analyze security scores 0.4 |
| 2026-10-10 | §5.3 | Under fresh_first a reading never sent that is newer than everything awaiting resend goes first, and resends go newest-first; a node keeps draining while resends wait; a frame's own confirmation releases only that frame |
The livestock water monitor's acceptance run (§15) failed its clause: after a twelve-hour outage the first readings to arrive were eight to twelve hours old, because resends went before everything, and 21 of 144 readings were lost because a per-frame confirmation was read as cumulative |
| 2026-10-10 | §3, §7.4 | The records Q14 protects exist (land with boundaries, animals and groups, inputs applied, animal treatments with withdrawal, harvest lots), and concurrency is decided by each edit's base, the versions its writer had seen, not by HLC stamps; a void concurrent with a protected edit conflicts | §7.4 said "concurrently" without saying how to tell. Stamps cannot: they order everything, including two writes made in ignorance of each other. And field-by-field resolution let a void silently remove a withdrawal while another phone corrected the dose, which the first test of it caught |
| 2026-10-09 | §4.2 | A series may learn its flatline after a season — twice its own healthy 99.9th-percentile dwell — never below its soil's default nor above twice it (Q24, decided) | Scored on real probes, learning from each one's first year and judged on the years after: across 528 SCAN probes in the Southeast, false stuck runs on readings ISMN rates good fell from 0.75 to 0.42 per probe-year (0.58 to 0.22 at 1 m), with detection time unchanged on 404 of them. The recommendation had allowed learning down to half the default; on 30 Tennessee probes that raised false runs tenfold at the surface, because one quiet year understates a drought — so the floor became the default |
| 2026-10-09 | §7.6 | A grant covers only what had arrived when it was made, unless it says otherwise and says when that ends; grants and revocations are log records (Q16, decided) | The safer default, made workable for research by an explicit end date rather than an open-ended stream |
| 2026-10-09 | §7.3 | Per-person keys wrapped by a farm key kept out of backups; erasure destroys the person's key and rotates the farm key (Q17, decided) | "Destroy the key" said nothing about where keys live or what a backup taken before the erasure still holds. Rotation is the answer to the backup: the old wrapped key survives in it, and nothing that unwraps it does |
| 2026-10-09 | §3.1, §3.2, §4.2, §5.2 | An aggregate datastream kind with a declared window_s; a node may send a window's count, extremes and unchanged run for a low-urgency series; only range and flatline run on it (Q20, decided) |
§4.2 runs checks in the foundation and §5.1 has the node apply nothing, so detection resolution equalled transmitted resolution and a farmer paid five-minute airtime to catch a stuck probe within the hour. Built and run through the gateway: twelve five-minute samples a report, hourly reports, a probe frozen at the end of one hour flagged stuck in the next report with its run known to the sample, in a message under a third the size of twelve readings. Labelled as its own kind so §3.2's "raw is what the node sent" stays literally true |
| 2026-10-09 | §3.3 | A foundation assertion's evidence MUST carry checks_run and fired, fired a subset; the rest is extension (Q19, decided) |
The floor answers what Auditable promises — what looked at this reading and what objected — and lets a conformance suite compare two gateways' reasoning. Decided now because the checks it waited on exist: range, flatline, rate, fall, cross, frozen, spike and siting all write it. Making it normative found the siting check writing evidence with neither field on a reading no other check had judged |
| 2026-10-09 | §3.4 | A raw series of scaled integers declares 1; the scale is the calibration's; a raw series scaled by a power of ten while declaring the calibrated unit is reported (Q21, decided) |
Harrow Bend's raw soil series declared cm3/cm3 while carrying 280. 1 is true of the values, parses as UCUM and round-trips through FROST, which null does not |
| 2026-10-09 | §4.1, §4.2 | Maintenance gains serviced; the rate, fall and spike checks consult the device's maintenance log before flagging a step, and record an explained step in the evidence (Q22, decided) |
A battery change is a real field event on every node this project targets, and its step in the §4.3 voltage series was either a false alarm or an alarm nobody could account for a season later. A vocabulary term is only worth adding if a check reads it |
| 2026-10-09 | §3.1, §3.2, §10 | Forecasts are input data on a datastream of their own kind; an observation may carry issued_at, required on a forecast and absent on a measurement; the live forecast for a valid time is the latest issue (Q26) |
A forecast has two times and is re-issued every few hours, so as a measurement it would either lose its issue time or put several values at one phenomenonTime, which §3.2 forbids. Built and run: on the virtual farm living through McAllister Farm's real spring of 2007, 172 runs went into one log; scoring every stored run against the farm's station gave back the error the forecaster had been given at every lead day, and a frost watch reading only the live forecast warned 44–47 hours ahead of all three frost nights, the Easter freeze included. A measurement's encoding is unchanged, byte for byte |
| 2026-10-07 | §5.2 | A cumulative acknowledgement must be able to abandon a permanent hole, with the grace measured from when the hole appeared; a node's give-up bound must be in time rather than attempts | Both found by assembling the canonical farm and running a season. A three-day outage left a node holding 800 readings and evicting, because the gateway's contiguous mark could never pass frames that no longer existed. Fixing it with an attempt count then capped outage survival at two hours |
| 2026-10-07 | §7.2 | Derived state is indexed rather than recomputed; a conformance suite should include a season-scale ingest | One simulated day of a five-node farm took 61 seconds and scaled quadratically, against §7.2's own promise. Nothing smaller than a season reveals it |
| 2026-10-08 | §4.2 | For soil water, spikes are judged by shape, one reading late; falls faster than soil drains are suspect; rises are not capped (Q23, decided) |
The rate check allows a jump that grows with the gap, so at hourly sampling it caught none of 1,849 ISMN spikes and jumps. Measured on 1,797 SCAN probes: a shape check (out by 0.05 and straight back) is silent on the median probe and fires less at coarser sampling; its flags on readings ISMN rates good were mostly on plainly broken sensors — Spickard's probes flipping between two levels, a dead sensor reading 0.000. Healthy soil falls at less than half the speed it rises (99.9th percentile 0.032 vs 0.073 per hour), so a cap on falls caught 61% of ISMN's negative jumps. On the virtual farm the change made electrical noise visible to a single probe (0% to 40% recall, every flag correct) with no false alarms on the clean control. ISMN's own spike flag sits on steps with a median of 0.005 — a relative test firing on dry-soil noise — and is not a target |
| 2026-10-07 | §4.2 | A rate-of-change threshold derives from the fastest legitimate process | The first value tried on the canonical farm permitted a jump of 0.36 in one 15-minute interval — the measurand's entire physical range — so the check could never fire. A threshold that cannot fire is worse than none, because it looks like protection |
| 2026-10-07 | §4.2, §9.2 | A feature with one sensor has no drift detection; cross-check compares like with like, and says suspect only; a control drill is mandatory |
Scored drills measured a single probe at 0% recall on drift, bias and noise, against 79/100/19% with a redundant probe. The first scored run also exposed a unit error: the cross-check was comparing a derived series against a neighbour's raw one — 0.280 against 280 — for a 99.8% false-alarm rate on a farm with nothing wrong with it |
| 2026-10-07 | §5.2 | A resend must reuse its sequence number; an acknowledgement deadline is mandatory with cumulative acknowledgement; a node must use one clock at both ends of an age | All three found by putting a simulated lossy link between the node and the gateway. A resend under a fresh number produced 2498 observations where 768 were expected; without a deadline the cumulative ack stalled at the first hole for a whole day; and two mixed-clock bugs in the test harness itself showed how quietly §2's cancellation is lost |
| 2026-10-08 | §4.2 | A soil-moisture reading taken while the same probe's soil is below 0 °C is suspect |
Capacitance and impedance probes sense liquid water only. Measured on ISMN's Tennessee record: in the hours the 5 cm soil was frozen, moisture read a median 0.061 m³/m³ lower than in the six hours before (10th–90th percentile −0.100 to −0.024) — a 20% false drop on a typical reading, enough to irrigate frozen ground. Rare in Tennessee (66 hours over two decades at three stations) and common further north. Needs the device's own temperature; never inferred from the season |
| 2026-10-08 | §4.1 | The siting check accepts two peers, with a wider tolerance and the finding marked not robust | Validated on real ISMN soil temperature rather than the simulator: a real station is one profile with one probe per depth, and only 5, 10 and 20 cm carry a readable daily wave (±1.4, 0.9 and 0.45 °C; 0.06 at 51 cm), so every shallow probe has exactly two usable peers. Requiring three, the check could not decide about 80% of the time. With two: median depth error 1.6–3.6 cm in the root zone, a 15 cm lift caught 86–96% of the time, and the median implied depth of every scripted move exactly right. 10 cm moves fall under the 12 cm two-peer tolerance, and false alarms are 2–4% per window — see notes/siting-real-soil.md |
| 2026-10-08 | §4.1 | A siting displacement is acted on only when it persists: five of the last seven daily assessments | Assessed every day for 23 years at three ISMN stations — about 22,000 daily windows per scenario — one window at a time raised 3.9 false displaced episodes per correctly placed probe-year. Requiring 5 of the last 7 days: 0.04, while a 15 cm lift was still caught on every day the check could decide. A real move is permanent and a bad window rarely repeats; siting is not urgent, so the week is cheap, and the flagged span covers every displaced day. Persistence buys quiet, not resolution: some implied depths sit 3–6 cm off for a decade — traced to a peer installed a few centimetres from its record and amplified by extrapolation — a bias no average removes. Running it found real unlogged changes: replacement 10 cm probes at two stations (January 2023) went in 2–4 cm from where their predecessors were, with the old depth kept on record. At McAllister Farm the alarm landed on the 20 cm probe, extrapolated from the moved one. Which probe moved is Q25 |
| 2026-10-08 | §4.1 | A two-peer siting alarm reports the profile and names the probe whose records changed (Q25, decided) | At McAllister and Allen Farms the 10 cm probe went quiet for 18 months and was replaced, recorded at the old depth, and the daily check blamed the 20 cm probe extrapolated from it. The rule that would have been right at both: look back from the alarm's onset for a record change on any probe in the profile. Changes made when the profile was set up are not changes — the first implementation counted every probe's original installation and calibration, and could never name anyone |
| 2026-10-08 | §4.1 | Where deeper probes are too weak to calibrate on over two days, the siting check should also run over a window long enough that they are | Every real station measured has a 51 cm probe whose daily swing (0.04–0.10 °C) is below the two-day floor, so the daily check calibrates on two peers and cannot outvote a displaced one. Over two weeks that probe resolves as a third peer. Measured through the SDK on three ISMN stations: weekly two-week windows, displaced on 3 of the last 4, raised 0.04 false episodes per probe-year on undisturbed profiles — as few as the daily 5 of 7 — and called 78–83% of 10 cm moves within six months, where the daily check calls 7–8%. McAllister Farm's 20 cm probe, blamed daily in 26% of windows after its neighbour was replaced, read 20 cm in every year and was flagged in 1%. Q25 records the measurement and what is still open: how to report a non-robust daily alarm. Also measured and rejected: fitting phase and amplitude jointly to name the culprit, which in uniform soil carry the same information |
| 2026-10-08 | §4.2, §9.2 | The checks scored against ISMN's flags on 2.8 million real readings | The first validation on real sensors. In the top 20 cm the checks disagree with readings ISMN rates good on at most 0.27% — the root zone, where decisions are made, holds up. Below it the flatline misfires, 1.62% at 51 cm and 8.83% at 1 m, because healthy readings repeat one value far longer at depth (Q24). The rate check caught none of 1,849 spikes and jumps on hourly data, as Q23 predicted before the data arrived. See notes/ismn-scoring.md |
| 2026-10-08 | §4.2, §5.3 | A check judges a reading against the readings measured before it, not the ones inserted before it | Profiling the remaining season cost found every check re-sorting the last 112 readings inserted. Under a fresh-first drain — newest out first after an outage, then the backlog, which §5.3 permits and the paddock node uses — a backlog reading arrives after more than 112 newer ones, sorts to the front of that window with no predecessor, and the rate check has nothing to compare. Measured against the previous commit: a spike to 0.45 after a 0.20 run, missed entirely; now caught. Keeping each series ordered on arrival fixed it and made a 32-day season 21% faster, with both scored drills producing identical results |
| 2026-10-08 | §10.1 | The service root carries serverSettings.conformance, claiming only what is fully met; Locations and HistoricalLocations are served | Reading 1.1 §9.2.1 directly, rather than relying on clients, found that serverSettings is a SHALL this service had never emitted — QGIS and Fraunhofer's client both tolerated its absence. The same reading settled the Locations question: the link had been advertised with nothing behind it, then dropped; neither satisfies 1.1. Serving Locations from feature geometry made QGIS's Location layer valid — 9 features, all with geometry — where it had been rejected. $metadata, listed earlier as a gap, is not part of 1.1 at all |
| 2026-10-08 | §4.1 | A lateral siting check from irrigation; consistency must discriminate; no check judges an installation by readings from a previous one | The depth check's admitted blind spot — a move between beds at one depth — is covered by moisture: each bed's own drip timer is a fingerprint. Building it found two defects worth a clause each. The drill's control, a properly logged move, was flagged on two days because a three-day window reached back into the old bed; the depth check carried the same latent flaw and had escaped by luck. And a probe that had really moved was "confirmed" in a rain-only window, because rain makes every bed match. Scored on two seasons: the bed swap caught within two days, zero false alarms on 138 clean device-days, and the logged move flagged by neither check |
| 2026-10-08 | §3.4 | Term resolution is enforced offline against a registry; the registry is what gets fetched | "MUST resolve" cannot mean fetching at declaration on a gateway that is offline for days. The gate checks registration and unit offline; make vocab-resolve fetches the registry. Its first catch was one of our own tests declaring a Celsius channel under the soil-moisture term. Its first online run reports the local term's namespace as 404 — the open Q4c gap, now a failing check rather than a footnote |
| 2026-10-08 | §10 | An import adapter must require a declared timezone and must not transform values | Porting campus_twin's CSV and MQTT adapters turned up the same two defaults in both. Reading a spreadsheet's 14:00 as UTC shifts every reading five or six hours with nothing visibly wrong; applying a scale on import stores a value the source never held, with no record of the change. And the MQTT bridge's upstream read a zoneless Tasmota time in whatever zone the host machine was set to, so one message got different timestamps on different machines |
| 2026-10-08 | §4.1, §4.3 | Nodes are devices; a GNSS-timed node reports its fix, which checks its siting laterally | The paddock node kept GPS time and threw the position away — the only signal that sees a node carried to another feature, the blind spot the temperature check had just admitted. Building it exposed that each node's battery and signal had been modelled as two separate "sensor" devices, ten fake devices across the farm. Now 18 sensors and 5 nodes. A node carried 130 m unlogged is flagged; one 6 m off its post is correctly not |
| 2026-10-08 | §11 | Records store codes and message keys, never translated text; translation is presentation, from data-only language packs | "Bilingual" generalised once deployments by universities in other countries became the goal. The rule that had to come first is not about language: a flag stored as sospechosa is invisible to a query for suspect, a CSV written with 0,29 does not round-trip, and a log in whichever language a gateway was set to cannot be read by the next maintainer. So §4.2 findings now store a key and parameters alongside the English sentence, and the same record renders in any language with a pack — including ones that did not exist when it was written. Packs are JSON, checked so that a translation cannot drop or misspell a field; decimals are regional (Spain a comma, Mexico a point); a missing string falls back visibly |
| 2026-10-08 | §1 | Labels are log records; renames append; one per entity per language, with a visible fallback | §1 said only that labels are not keys, which left a name nowhere to live: a simulator helper fed them to the SensorThings view as a side table carrying both names and geometry, and a real farm could not name anything. Making them records gave names history, which answers the question renames otherwise destroy — what an old report meant by "north bed" |
| 2026-10-08 | §4.1 | A calibration is scoped to the raw channel it converts | Giving each soil probe a second channel — temperature, which every capacitive probe of its class reports — derived every moisture reading on twelve probes with the temperature coefficients: 29.2 cm3/cm3 for a soil holding 0.292. Calibration was selected by sensor alone, and on a two-channel device the later one won for both. validate reported the overlap; nothing in ingest consults validate, so the data was silently wrong. No earlier test could see a 100x error because none put two channels on one device |
| 2026-10-08 | §4.1 | An installation should be checked against physics; the check must calibrate on peers, fit robustly, and say where it is blind | Implementing the depth-from-temperature check, the first version used least squares on peers and set its tolerance from their scatter. With two unlogged moves on one farm, the displaced probes sat in each other's calibration set, the tolerance widened to 2.8 m, and the check passed almost everything — one bad peer disabled it for every other probe. Robust (Theil–Sen) fitting fixed it. Scored by make siting on two seeds: zero false alarms over 138 clean device-days, lifts and pushes of 15 cm caught within one to two days, a 7 cm nudge caught at the edge of resolution, a lateral move at the same depth never caught — the blind spot, asserted rather than hidden |
| 2026-10-08 | §7.5 | A subscription cursor is an offset into arrival order, never a position in HLC order; a cursor refuses to run against another replica | Building §7.5 against §7.4's merge exposed that every obvious cursor — an index, the last stamp seen, the last event id — silently skips an offline edit that syncs late, because receive inserts it by stamp into the middle of history. It fails only in the offline case, which is the case this spec exists for. The test runs the naive cursors for real and shows them missing the event the arrival cursor delivers |
| 2026-10-08 | §4.1 | The device record is three records and a projection, not one record; last seen and the current boot counter are derived | §4.1 asked a single record to hold "the current boot counter", which an append-only log cannot do without the mutation §3.2 forbids. Building it forced the split: Device for the durable half, Installation for siting with dates, MaintenanceEvent for the log, and a derived view for anything described as current. Deriving them has a second benefit nobody can edit their way around — a certifier-facing field that no one can set by hand |
| 2026-10-08 | §4.1 | Installation intervals are half-open and must not overlap; closing the old installation is mandatory on a move | The question the dates exist to answer — how deep was this probe when it took that reading — has two answers if the previous installation is left open, and nothing about that looks wrong from inside. The move has two halves and the closing half is the one that gets forgotten, so State.validate reports the overlap and the canonical farm exercises a real move |
| 2026-10-08 | §4.1 | An undeclared driver capability reads as unknown, never as a default; the maintenance vocabulary is closed by the spec | §6.3 branches on whether a node can persist its dedup table across a reboot, and a False default would silently claim a guarantee no driver made. The maintenance actions are an enumeration because "when was this last cleaned" is a certifier's question that prose cannot answer — which immediately exposed a gap in the spec's own four, recorded as Q22 |
| 2026-10-07 | §7.6 | The export's unit is the event, not the record; one file is the archive and the rest must declare themselves lossy; losslessness is verified by decoding, not asserted; an export carries a verifiable manifest | Implementing §7.6 found that the convenient interface is the wrong one — log.records() silently drops every §7.3 correction, so an export built the obvious way is an audit trail missing the part that says something was wrong. Verification-by-decoding then caught a defect reasoning had missed: a check_version of "1" comes back from a CSV cell as the integer 1. The matrix row that had been skipped since the farm was assembled now passes over a whole season |
| 2026-10-07 | §7.6 | Compression must be available, with the uncompressed archive canonical | Measured, not assumed: a simulated week is 35.1 MB and 3.4 MB gzipped, so a season is roughly 900 MB against 87 MB. "The farmer can take their data" is not satisfied by a file they cannot move over a rural connection. Uncompressed stays canonical because that form is byte-compatible with the log, which is what makes an import a replay rather than a second decoding |
| 2026-10-07 | §10.1 | An unsupported query option is an error, never silence; every emitted link must resolve; a non-UUID key is percent-encoded and decoded only after the path is split | Two real clients — QGIS 3.44 and Fraunhofer's frost-sta-client — were pointed at the new HTTP service and found five defects, four of them ours. Two were links the service emitted and then answered 404 to: an observed property keyed by its vocabulary URI, and a Locations link to an entity set we do not serve. Neither was reachable by a test written from the inside, because only a consumer has opinions about what a document promises |
| 2026-10-07 | §10.1 | observationType is derived from the declaration, not from a result's runtime type |
A raw soil-moisture series carries scaled integers, so the first value typed the whole datastream OM_CountObservation while the same document declared cm3/cm3. A count has no unit, so one document asserted two incompatible things, and a consumer honouring the type could refuse to average a continuous measurement. Two of our own tests asserted the old behaviour and were wrong. The deeper question — what unit a raw series should declare at all — is Q21 |
| 2026-10-07 | §10.1 | Feature geometry is served, through the label side table rather than as a log record | Every QGIS layer loaded with NullGeometry: the farm appeared as six attribute tables and nothing could go on a map, while §7.6 names a GIS client as the acceptance test for exports. Geometry stays out of the log deliberately — field boundaries are site-private material — so the side table that already carries names carries this too |
| 2026-10-07 | §7.2 | A season-length simulation is a performance requirement, not a convenience | The promise that years of readings never slow everyday work was being broken by the tooling that was supposed to demonstrate it. Three independent quadratic patterns — a nested buffer scan in the node, a hand-written supersession test that re-derived an index the projection already had, and UUID.__eq__ in two hot paths — made a 180-day season take an extrapolated 5.6 hours. It now takes 7.8 minutes. Every one of them was correct code |
| 2026-10-07 | §3.4 | A declared observed-property URI must resolve, and its unit must match the term's definition | Implementing Q4 found that the project's own placeholder, cf/soil_moisture_content, is not a CF standard name at any version — and the nearest real CF names are mass-based (kg m-2), so the plausible guess named the wrong dimension. Nothing in the spec required a URI to be fetchable, so a term that resolved to nothing satisfied every clause. The volumetric name is volume_fraction_of_condensed_water_in_soil, units 1, which is what cm3/cm3 is |
| 2026-10-07 | §3.4 | A local vocabulary term is permitted, but must be declared in one place with its reason and published upstream | Exactly one of the farm's nine measurands has no term in CF or ODM2: a vessel's fill fraction. ODM2's waterLevel is a height in metres, so reusing it would have repeated the unit error above under a term that does resolve. Gaps are legitimate; unrecorded gaps become a private vocabulary |
| 2026-10-07 | §4.2 | Flatline must not be applied to a regulated quantity; a flatline threshold must exceed the quantisation dwell | Running the virtual farm flagged 228 of 384 readings on a healthy day. A working float holds a level for 226 identical samples, and a soil probe at three decimals repeats for eight hours overnight. Both are correct readings, and a default that fires on a working farm costs trust in every flag |
| 2026-10-07 | §3.2 | A calibration may declare an output resolution (decimals) |
Derived values carried IEEE 754 noise — 0.08600000000000001 — into the SensorThings output |
| 2026-10-07 | §5.3.1 | max_latency is a ceiling, not a schedule: a node must also drain on backlog, and an infeasible sample-rate/link combination must be refused at registration |
A simulated node inside its latency ceiling lost data permanently and looked healthy doing it — it sampled 12 readings an hour and sent one frame an hour carrying five. Found by running sim/node.py against a three-day outage |
| 2026-10-07 | §5.2 | dropped added to the message grammar; ages are whole seconds, which bounds the minimum sample interval per channel |
§5.3 required a node to report evictions but the grammar had nowhere to put the count. The resolution bound surfaced when the §3.2 uniqueness check caught the gateway producing two live observations at one instant |
| 2026-10-07 | §4.2 | A threshold lives on the datastream whose values it bounds | Putting a physical limit on the raw series makes it silently inert. Surfaced by a gateway test |
| 2026-10-07 | §3.1, §5.3.1, §9.1 | max_latency added to the datastream, distinct from expected_interval; the node's transmit interval is the tightest latency it carries; installation guidance on co-location |
"Sample less to save battery" turned out to be mostly wrong — sampling is under 2% of a node's energy, and co-location costs an order of magnitude more than any rate choice. Measured in notes/power-budget.md |
| 2026-10-07 | §4.2 | Health-check thresholds are expressed in physical units; flatline takes flat_seconds with a sample count as a guard |
A count-based threshold is coupled to the sample rate, so one configured value is a different physical test on two farms. Surfaced by asking whether hourly sampling suffices; see notes/q5-encoding-measurement.md |
| 2026-10-07 | §5.2 | Encoding decided: CBOR packed arrays + CDDL; node_short_id optional on the wire; batch size computed from the current data rate |
Measured rather than argued — two candidates turned out not to fit a single reading at DR0. See notes/q5-encoding-measurement.md |
| 2026-10-07 | §3.3, §4.2 | An assertion covers an assessment run and carries the union of flags; check names the suite |
As written, §4.2's four one-flag checks and §3.3's single live assertion could not both hold. Separate per-check assertions meant one won on precedence and the others' findings vanished — demonstrated in sdk/test_checks.py::test_separate_assertions_per_check_would_lose_a_flag |
| 2026-10-07 | §3.3 | Optional evidence object on an assertion |
Collapsing four checks into one assertion would otherwise lose which check fired and why. Whether it should be normative is open |
| 2026-10-07 | §10 | Datastream resultTime is omitted, not emitted as null |
1.1 Table 10 makes it zero-to-one and layer zero records no result times. Caught by validating against FROST-Server; Observation's resultTime is mandatory-and-nullable and still emitted as null. See notes/frost-validation.md |