<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Philip Shaw</title>
    <description>The latest articles on DEV Community by Philip Shaw (@virorum).</description>
    <link>https://dev.to/virorum</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4013836%2Fe5cca143-b113-4aa0-909c-5ae3ceddc5af.png</url>
      <title>DEV Community: Philip Shaw</title>
      <link>https://dev.to/virorum</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/virorum"/>
    <language>en</language>
    <item>
      <title>Sentinel: Ingest Path and Log Schema</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 23 Sep 2026 10:33:04 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-ingest-path-and-log-schema-2mfj</link>
      <guid>https://dev.to/virorum/sentinel-ingest-path-and-log-schema-2mfj</guid>
      <description>&lt;p&gt;Last week's sensor is still reporting &lt;code&gt;71.6&lt;/code&gt; in Fahrenheit, and its driver has published that number untouched, as the contract requires. The conversion to Celsius happens here instead, at the eighth of thirteen stages between the bus and the database, and the row it writes keeps both numbers: &lt;code&gt;22.0&lt;/code&gt; in the canonical column, and &lt;code&gt;71.6&lt;/code&gt; in the native one beside the unit the device actually used.&lt;/p&gt;

&lt;p&gt;Keeping both is what makes a conversion reversible. Suppose that weeks later the descriptor's scale factor turns out to have been wrong. Nothing collected in the meantime is lost, because the number the device produced was never overwritten; a reprocess recomputes every canonical value from native under a corrected registry version, rebuilds the current state of the affected points, and refreshes the aggregates built over them. It can only reach rows that still exist, and it refuses outright rather than correcting the part of a request it can reach. What it will not do is re-run the rules that fired on the wrong numbers, because those decisions were made on the data as it stood.&lt;/p&gt;

&lt;p&gt;The other load-bearing decision is smaller and much easier to get wrong: where &lt;code&gt;ingested_at&lt;/code&gt; comes from. Timescale requires the partitioning column in every unique index, so the dedup key cannot be just the three columns that identify a message; it has to carry the timestamp as well. Stamp that from the core's clock at processing time, and a message redelivered after a crash arrives with a new timestamp, slips past the index and inserts a duplicate, while the index goes on looking as though it enforces dedup. Take it from the bus instead, where it is fixed once and identical on every redelivery, and the same index turns at-least-once delivery into exactly-once effects without a watermark table of its own.&lt;/p&gt;

&lt;p&gt;Those two decisions are the spine of this document. The two logs, the per-class hypertables, the thirteen stages and their ordering rules, the restart sequence and the reprocess command are what the rest of the schema has to look like once both are held fixed.&lt;/p&gt;


&lt;p&gt;&lt;strong&gt;What this document owns:&lt;/strong&gt; the split into an observation log and a control-event log, the observation schema and its physical split into one hypertable per retention class, where &lt;code&gt;ingested_at&lt;/code&gt; comes from and why, the versioned and content-addressed registry, the thirteen-stage ingest pipeline and its three ordering rules, the in-memory projection and its checkpoint, the restart sequence, reprocess and its refusal to apply partially, and the properties of the continuous aggregates that belong with the schema rather than the policy. What it deliberately does not own is how long anything is kept, how each class rolls up and how state-class compaction works, which belong to &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;; keeping cold-start rehydration bounded, which belongs to &lt;em&gt;Watchdog &amp;amp; Derived Points&lt;/em&gt;; the core epoch, restarting against a backlog and the utilisation arithmetic the ingest ceiling feeds into, which belong to &lt;em&gt;Core Runtime&lt;/em&gt;; and who a &lt;code&gt;principal&lt;/code&gt; is and why a &lt;code&gt;wrong_instance&lt;/code&gt; count is an alert rather than a dashboard figure, which belong to &lt;em&gt;Security Model&lt;/em&gt;. Everything downstream that asks what a point's value is, or was, is reading a projection of the two logs defined here.&lt;/p&gt;

&lt;p&gt;Two things drive the whole design here: the bus message's own identity doubles as the dedup key and the ingest watermark, and &lt;code&gt;value_native&lt;/code&gt; in the log is what makes conversion reversible. Get those right and restart and reprocess both fall out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two logs, not one
&lt;/h2&gt;

&lt;p&gt;The pure event-sourcing instinct is one ordered log. It is the wrong shape here. Observations and control events have different volumes, retention, and query shapes, and Timescale hypertables want homogeneous rows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;observation&lt;/code&gt;&lt;/strong&gt; — high volume, append-only, compressed, aged out. Ordering matters only within a point. Physically several hypertables, one per retention class; see below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;control_event&lt;/code&gt;&lt;/strong&gt; — commands, availability, quality transitions, assignment changes, reprocess operations. Low volume, globally sequenced, kept forever, not classed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The projection is built from both. Quality transitions from the freshness watchdog go in &lt;code&gt;control_event&lt;/code&gt; because they carry no new value; in a healthy system they are rare, and when they are not rare that is the thing most worth a permanent record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'restored'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'live'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'stale'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'unavailable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'bad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'assumed'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- identical definition for each retention class:&lt;/span&gt;
&lt;span class="c1"&gt;--   observation_measurement, observation_state, observation_diagnostic&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;observation_&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;ingested_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- bus-accept time; see below&lt;/span&gt;
  &lt;span class="n"&gt;point_id&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;observed_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;device_time&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="n"&gt;value_num&lt;/span&gt;         &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- canonical&lt;/span&gt;
  &lt;span class="n"&gt;value_bool&lt;/span&gt;        &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_text&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;-- canonical enum member / string&lt;/span&gt;
  &lt;span class="n"&gt;native_num&lt;/span&gt;        &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;native_text&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;-- raw device representation&lt;/span&gt;

  &lt;span class="n"&gt;unit_native&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;quality&lt;/span&gt;           &lt;span class="n"&gt;quality&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reason&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;detail&lt;/span&gt;            &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="n"&gt;changed&lt;/span&gt;           &lt;span class="nb"&gt;boolean&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;registry_version&lt;/span&gt;  &lt;span class="nb"&gt;integer&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;driver_instance&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_epoch&lt;/span&gt;      &lt;span class="nb"&gt;integer&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_seq&lt;/span&gt;        &lt;span class="nb"&gt;bigint&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;skew_flagged&lt;/span&gt;      &lt;span class="nb"&gt;boolean&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;create_hypertable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'observation_&amp;lt;class&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ingested_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;chunk_time_interval&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;observation_&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;driver_instance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_epoch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ingested_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;observation_&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;point_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;observed_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typed columns rather than JSONB for values. JSONB costs space on the highest-volume table and blocks index-only scans; five nullable narrow columns cost less, with only one or two populated per row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One hypertable per retention class.&lt;/strong&gt; Timescale attaches retention, compression and continuous-aggregate policies to a table rather than to a row, so per-class policy means a physical split. The class comes from the resolved descriptor, which ingest already has by the time it writes. The policies themselves are in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;; what belongs here is that the split exists, that the schema is identical across classes, and that it cannot be deferred — adding the class field later is easy, splitting a populated table is a rewrite of everything ever collected.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;observation&lt;/code&gt; view over the three, as &lt;code&gt;UNION ALL&lt;/code&gt;, exists for ad-hoc querying. The hot paths do not use it: rehydration and reprocess resolve the class from the descriptor and query the specific table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunk on &lt;code&gt;ingested_at&lt;/code&gt;, not &lt;code&gt;observed_at&lt;/code&gt;.&lt;/strong&gt; A driver host replaying a buffered partition writes rows with old &lt;code&gt;observed_at&lt;/code&gt; values, which would land in chunks that are already compressed. &lt;code&gt;ingested_at&lt;/code&gt; is monotonic by construction, so chunks close cleanly and never reopen. The cost is that analytical queries filtering on &lt;code&gt;observed_at&lt;/code&gt; lose chunk exclusion, which the secondary index mostly covers at these volumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every observation writes a row, including one that changed nothing.&lt;/strong&gt; Suppressing unchanged rows at write time is a tempting optimisation and it breaks two mechanisms: derived-point windows rehydrate by querying these tables, so &lt;code&gt;mean&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;min_samples&lt;/code&gt; need the samples that were suppressed; and reprocess can only reconvert rows that exist. Volume is managed afterwards, by retention and compaction, where the trade is explicit and bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where &lt;code&gt;ingested_at&lt;/code&gt; comes from
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is the JetStream message timestamp, not &lt;code&gt;now()&lt;/code&gt; at processing time.&lt;/strong&gt; This is load-bearing and easy to get wrong.&lt;/p&gt;

&lt;p&gt;Timescale requires the partitioning column in every unique index, so the dedup index is necessarily &lt;code&gt;(driver_instance, source_epoch, source_seq, ingested_at)&lt;/code&gt; rather than the three-column key that would otherwise be correct. If &lt;code&gt;ingested_at&lt;/code&gt; were assigned when the core happened to process the message, a redelivery after a crash would carry a different value, land outside the index, and insert a duplicate row. The index would look like it was enforcing dedup and would not be.&lt;/p&gt;

&lt;p&gt;The bus timestamp is fixed when the message enters the stream and is identical on every redelivery, which makes the four-column index behave exactly like the three-column one. It also keeps the monotonicity that chunking depends on, because a single &lt;code&gt;obs&lt;/code&gt; stream assigns timestamps in stream order. If observations ever arrive from more than one stream, that property weakens and this decision needs revisiting.&lt;/p&gt;

&lt;p&gt;The cost is a change of meaning: &lt;code&gt;ingested_at&lt;/code&gt; is when the bus accepted the observation, not when the core recorded it. That is the more useful of the two anyway — it is the moment the observation became durable, it comes from one clock rather than from whichever core process handled it, and it is stable across a core restart. Core processing lag belongs on a &lt;code&gt;self&lt;/code&gt; point, not on a column of the highest-volume table in the system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;control_event&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;seq&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;kind&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- quality_changed, command_*, availability, assignment, reprocess&lt;/span&gt;
  &lt;span class="n"&gt;point_id&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;subject_key&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;-- driver_instance / device / component&lt;/span&gt;
  &lt;span class="n"&gt;principal&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- who caused it; see Security Model&lt;/span&gt;
  &lt;span class="n"&gt;core_epoch&lt;/span&gt;   &lt;span class="nb"&gt;integer&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- which core instance; see Core Runtime&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;      &lt;span class="n"&gt;jsonb&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;registry_version&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;principal&lt;/code&gt; and &lt;code&gt;core_epoch&lt;/code&gt; are both &lt;code&gt;NOT NULL&lt;/code&gt; deliberately. A code path that cannot name who asked, or which core acted, cannot write a control event — which means it cannot change state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry is versioned data
&lt;/h2&gt;

&lt;p&gt;Vocabularies and point descriptors load from git into Postgres. Every registry load creates a version row, and descriptors are stored per version by content hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;registry_version&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;version&lt;/span&gt;    &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;loaded_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;git_sha&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;descriptor_blob&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;hash&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- of the canonicalised descriptor&lt;/span&gt;
  &lt;span class="k"&gt;descriptor&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;point_descriptor&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;version&lt;/span&gt;   &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;registry_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;point_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;hash&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;descriptor_blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;point_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The indirection exists because of scale. At a few hundred points a full descriptor copy per version is a rounding error; at the four thousand points targeted in &lt;em&gt;Core Runtime&lt;/em&gt; it is around 1.6 MB per commit, growing without bound, when a typical commit changes a handful of points. Content-addressing collapses that to one small row per point per version plus one blob per genuinely new descriptor.&lt;/p&gt;

&lt;p&gt;It preserves the property that matters: any historical observation can be reinterpreted under the exact descriptor that produced it, or under any other version, because every version still names a complete descriptor set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ingest pipeline
&lt;/h2&gt;

&lt;p&gt;Stages, in order, all core-side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Consume&lt;/strong&gt; from the JetStream durable consumer on &lt;code&gt;obs.&amp;gt;&lt;/code&gt;, taking &lt;code&gt;ingested_at&lt;/code&gt; from the message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate envelope&lt;/strong&gt; — required fields, parseable timestamps, &lt;code&gt;observed_at&lt;/code&gt; not absurdly in the future&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup&lt;/strong&gt; on &lt;code&gt;(driver_instance, source_epoch, source_seq)&lt;/code&gt; against an in-memory set of recently seen keys; this is a fast path, and the unique index is the authority&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve point&lt;/strong&gt; — registry lookup; miss goes to quarantine and the message is acked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check publisher&lt;/strong&gt; — the host and instance assigned to this point must match the ones the message was published from; a mismatch goes to quarantine with reason &lt;code&gt;wrong_instance&lt;/code&gt; and the message is acked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit check&lt;/strong&gt; — driver-supplied &lt;code&gt;unit_native&lt;/code&gt; disagreeing with the descriptor gives quality &lt;code&gt;bad&lt;/code&gt;, reason &lt;code&gt;unit_mismatch&lt;/code&gt;, no conversion attempted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate value&lt;/strong&gt; — range check, enum codebook lookup; failures give &lt;code&gt;bad&lt;/code&gt; with the offending value preserved in &lt;code&gt;native_text&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert&lt;/strong&gt; — &lt;code&gt;value_native × factor + offset&lt;/code&gt;, affine-aware&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skew check&lt;/strong&gt; — &lt;code&gt;device_time&lt;/code&gt; against &lt;code&gt;observed_at&lt;/code&gt;, sets &lt;code&gt;skew_flagged&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate change&lt;/strong&gt; — deadband against the in-memory projection, sets &lt;code&gt;changed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append&lt;/strong&gt; to the hypertable for the descriptor's retention class, batched, as &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING ... RETURNING&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit&lt;/strong&gt;, then &lt;strong&gt;ack&lt;/strong&gt; the JetStream messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update projection&lt;/strong&gt; and &lt;strong&gt;publish&lt;/strong&gt; transitions to &lt;code&gt;state.{point_id}&lt;/code&gt;, for returned rows only&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Stage 5 is what makes bus permissions mean anything.&lt;/strong&gt; Subject scoping confines a host to publishing under its own prefix, but says nothing about which point IDs it puts inside those subjects. Without this check, one compromised driver host could publish readings for every point in the system, including the inputs to thermal interlocks, entirely within its permissions. It is one comparison against the assignment mapping the registry already holds.&lt;/p&gt;

&lt;p&gt;The result is a security signal rather than a data-quality one. A &lt;code&gt;unit_mismatch&lt;/code&gt; is a configuration error; a &lt;code&gt;wrong_instance&lt;/code&gt; is a host claiming points that were never assigned to it, and the count belongs in the alerting path rather than on a dashboard. See &lt;em&gt;Security Model&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 11 batches per class&lt;/strong&gt;, since a batch has to target one table. That is a grouping in the flush, not three pipelines — a batch is partitioned by class immediately before the insert and committed in one transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stages 2 to 10 are the throughput ceiling&lt;/strong&gt;, not the insert. Per-observation validation, registry lookup, codebook resolution and conversion all happen once per row in application code, and that is where sustainable ingest rate is actually determined. It is the unmeasured term in the utilisation arithmetic in &lt;em&gt;Core Runtime&lt;/em&gt;, and it is worth benchmarking against the design rate before sizing anything that depends on it.&lt;/p&gt;

&lt;p&gt;Three ordering rules that are easy to get wrong and expensive to debug:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ack after commit.&lt;/strong&gt; The consumer's ack position is the ingest watermark. Crash between commit and ack, and the redelivered messages carry the same &lt;code&gt;(instance, epoch, seq, ingested_at)&lt;/code&gt; and are swallowed by the unique index. Crash before commit, and they are redelivered and processed properly. This produces exactly-once effects from at-least-once delivery without a separate watermark table — but only because &lt;code&gt;ingested_at&lt;/code&gt; is deterministic. If that ever changes, the whole property goes with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publish after commit.&lt;/strong&gt; Rules must never fire on data that could roll back. A relay that clicks because of an aborted transaction is a bad afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project only what actually inserted.&lt;/strong&gt; &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; makes a redelivered batch a no-op in the table, but the projection update and the transition publish are not idempotent in the way that matters: re-publishing a transition re-fires the rules engine on an edge that already happened. Stage 13 is driven from the &lt;code&gt;RETURNING&lt;/code&gt; set rather than from the batch submitted. This is the redelivery case doing its job silently, and it is invisible in testing unless a core is crashed mid-batch on purpose.&lt;/p&gt;

&lt;p&gt;Inserts are batched — multi-row &lt;code&gt;INSERT&lt;/code&gt; flushed on 500 rows or 100 ms, whichever comes first. Per-row inserts will not keep up at this scale, and the batch window is invisible next to the polling intervals.&lt;/p&gt;

&lt;p&gt;Failure at stages 6–8 still writes a row. A &lt;code&gt;bad&lt;/code&gt; observation is data — it refreshes freshness, it feeds bad-data rule triggers, and it is the evidence when working out why a sensor went strange at 3am. Silence is the one outcome that helps nobody.&lt;/p&gt;

&lt;p&gt;Failures at stages 4 and 5 do not write a row, because there is no descriptor to write one against, and therefore no class to route to. Both go to quarantine with the full native payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Projection
&lt;/h2&gt;

&lt;p&gt;The authoritative copy is in memory in the core. Postgres holds a checkpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;point_state&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;point_id&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_num&lt;/span&gt;       &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_bool&lt;/span&gt;      &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_text&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;quality&lt;/span&gt;         &lt;span class="n"&gt;quality&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reason&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;observed_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ingested_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;changed_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- last value change past deadband&lt;/span&gt;
  &lt;span class="n"&gt;quality_since&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;watermark_seq&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt;                &lt;span class="c1"&gt;-- last control_event applied&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write policy: &lt;strong&gt;immediately on quality change, at most every 5 s per point on value change, plus a full flush every 30 s.&lt;/strong&gt; Per-observation &lt;code&gt;UPDATE&lt;/code&gt; on a hot table generates MVCC bloat for no benefit — the value churn does not need durability because the log already has it. Quality changes are rare and operationally important, so those go through synchronously.&lt;/p&gt;

&lt;p&gt;The value in &lt;code&gt;point_state&lt;/code&gt; is never nulled when quality degrades. Consumers get &lt;code&gt;(value, quality, observed_at)&lt;/code&gt; and decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restart
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Load vocabularies and current registry version&lt;/li&gt;
&lt;li&gt;Load &lt;code&gt;point_state&lt;/code&gt;, force every quality to &lt;code&gt;restored&lt;/code&gt; (except rows already &lt;code&gt;unknown&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Resume the JetStream consumer from its ack position; replay anything the checkpoint missed&lt;/li&gt;
&lt;li&gt;Replay &lt;code&gt;control_event&lt;/code&gt; above each point's &lt;code&gt;watermark_seq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rehydrate derived-point windows by querying the appropriate class table for the required lookback&lt;/li&gt;
&lt;li&gt;Rehydrate durable timers from &lt;code&gt;control_event&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Start drivers; instances go &lt;code&gt;connecting → connected → ready&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rules leave warmup as their inputs reach &lt;code&gt;live&lt;/code&gt;, or when their warmup deadline expires&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 can be blunt precisely because of the dedup key: anything already committed is re-consumed and discarded, so the consumer resumes from a conservative position without the core having to reason about how far it got.&lt;/p&gt;

&lt;p&gt;Step 5 is the quiet payoff of log-first. A 15-minute moving average needs no separately persisted ring buffer — it is a query. Derived points become stateless to restart, which removes an entire category of subtle post-restart wrongness. It is also the step that dominates cold start, and &lt;em&gt;Watchdog &amp;amp; Derived Points&lt;/em&gt; covers how to keep it bounded.&lt;/p&gt;

&lt;p&gt;Step 2's blanket &lt;code&gt;restored&lt;/code&gt; is deliberately pessimistic. Something that was &lt;code&gt;bad&lt;/code&gt; before the restart might be fine now, and the driver will say so within one poll interval.&lt;/p&gt;

&lt;p&gt;Where the restart follows an outage long enough to have built a backlog, steps 3 onward run against a draining queue rather than a current one. What that changes is in &lt;em&gt;Core Runtime&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reprocess
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;value_native&lt;/code&gt; is never rewritten. That is what makes this possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;reprocess &lt;span class="nt"&gt;--point-glob&lt;/span&gt; &lt;span class="s1"&gt;'boiler.*'&lt;/span&gt; &lt;span class="nt"&gt;--from&lt;/span&gt; 2026-06-01 &lt;span class="nt"&gt;--registry-version&lt;/span&gt; current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recompute canonical values from native under a chosen registry version, update the affected rows, rebuild the projection for those points, refresh every dependent continuous aggregate over the affected range, and write a &lt;code&gt;reprocess&lt;/code&gt; entry to &lt;code&gt;control_event&lt;/code&gt; recording the scope, the old version, the new one, and the aggregate ranges refreshed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ingested_at&lt;/code&gt; is never rewritten either. Reprocess changes what a row means, not when it arrived, and the dedup key has to keep pointing at the same message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refreshing the aggregates is part of the operation, not a side effect of it.&lt;/strong&gt; Timescale records an invalidation when the underlying rows change, but the background refresh policy only revisits its own recent window — typically the last day or two. A correction to June's data leaves an invalidation nobody will ever process, and the aggregates keep serving the old numbers indefinitely while the raw table is right. That divergence is worse than the original error, because the raw table and the dashboard now disagree and nothing says so.&lt;/p&gt;

&lt;p&gt;So the reprocess command calls &lt;code&gt;refresh_continuous_aggregate&lt;/code&gt; explicitly for each aggregate over the affected range, after the row updates commit. Two details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Round the range outward to bucket boundaries.&lt;/strong&gt; A range that starts mid-bucket produces a partially recomputed bucket, which is a worse artefact than either the old or the new value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The refresh is point-blind.&lt;/strong&gt; Aggregates bucket by time, not by point glob, so refreshing recomputes every point in those buckets, not just the ones reprocessed. That is more work than the reprocess itself and it is the right trade: filtering by point would mean maintaining a parallel invalidation map, and the whole operation is rare.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The horizon is per class, and partial reprocess is refused
&lt;/h3&gt;

&lt;p&gt;Reprocess can only reach rows that still exist, and raw retention differs by retention class. A point glob can therefore span classes with different horizons, and a range that is valid for a measurement point may be long gone for a diagnostic one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The command validates the whole scope first and refuses outright if any point in it cannot be covered&lt;/strong&gt;, reporting which points and which classes made it impossible. It does not correct the part it can reach.&lt;/p&gt;

&lt;p&gt;That refusal is the important behaviour. Silently correcting two thirds of a glob leaves the system in a state where some points are right, some are wrong, and nothing distinguishes them — which is precisely the divide-and-be-wrong failure the operation exists to prevent. Narrowing the glob or the range is then a deliberate second command, taken with the horizon in view.&lt;/p&gt;

&lt;p&gt;The practical consequence: a unit error found within a class's raw retention is fully correctable, and one found after that is permanent in the aggregates. If a horizon is wrong, the retention policy is the thing to change, not this.&lt;/p&gt;

&lt;p&gt;Compressed chunks need decompress-update-recompress, which is slow, but this is a maintenance operation run rarely. It is built as a real command with a dry-run mode from the start, while the surface is small. Dry run reports the row count, the classes in scope with their horizons, the aggregate ranges it would refresh, and a time estimate, because the refresh is usually the expensive half.&lt;/p&gt;

&lt;p&gt;Reprocess does not re-run rules. Historical automation decisions were made on the data as it stood, and rewriting them would make the control event log a lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention and rollups
&lt;/h2&gt;

&lt;p&gt;The continuous aggregates are defined here as schema; how long anything is kept, which functions each class rolls up with, and how state-class compaction works are all in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Three properties of the aggregate definitions belong with the schema rather than the policy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store &lt;code&gt;sum&lt;/code&gt; and &lt;code&gt;count&lt;/code&gt;, not &lt;code&gt;avg&lt;/code&gt;.&lt;/strong&gt; An average is a view expression over the two. The moment anything rolls an hourly bucket up to a day, or a minutely bucket up to an hour, an average of averages is wrong wherever the bucket counts differ — and it is wrong quietly, by a few percent, in exactly the direction nobody checks. Sum and count compose; averages do not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregates are defined directly on the raw class tables, never hierarchically.&lt;/strong&gt; Building the hourly on the minutely would be cheaper to maintain and would introduce a refresh ordering dependency that reprocess has to get right every time. Defining both on raw costs some materialisation work and removes the ordering question entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;agg_safe&lt;/code&gt; is enforced in the aggregate definition&lt;/strong&gt;, not only in derived points. An hourly mean of RSSI in a continuous aggregate is exactly as wrong as one computed live, and much harder to notice once it is sitting in a dashboard.&lt;/p&gt;

&lt;p&gt;Only rows where &lt;code&gt;quality = 'live'&lt;/code&gt; are aggregated, and &lt;code&gt;live_count&lt;/code&gt; is carried alongside &lt;code&gt;count&lt;/code&gt; so a gappy hour is visibly gappy rather than silently averaged over three samples.&lt;/p&gt;

&lt;p&gt;Deleting data by age happens in Postgres, under the policies in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;. The observation stream on the bus is bounded by size instead, for the reasons in &lt;em&gt;Driver Contract&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit criteria
&lt;/h2&gt;

&lt;p&gt;This is the step whose claims are most expensive to discover are false, and most of them are otherwise only prose. Prose cannot fail loudly. Each of the following is a test that runs in CI with a fault injector, not a procedure performed by hand once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactly-once effects from at-least-once delivery.&lt;/strong&gt; Kill the core mid-batch, restart, and assert two things: no duplicate rows, and no transition re-published for a row that was already committed. The second half is the one that gets missed, because the table looks correct either way — it is the rules engine that would have fired twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both crash windows, separately.&lt;/strong&gt; Crash before commit: the messages are redelivered and processed normally. Crash after commit but before ack: the redelivered messages are swallowed by the unique index and produce no projection update. If the second case ever starts inserting duplicates, &lt;code&gt;ingested_at&lt;/code&gt; has stopped being deterministic and the whole property has gone with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publisher check.&lt;/strong&gt; An observation for a point assigned to a different host or instance is quarantined with reason &lt;code&gt;wrong_instance&lt;/code&gt;, no row is written, and the message is acked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Class routing.&lt;/strong&gt; One point per retention class, each landing in its own hypertable; a count over the &lt;code&gt;UNION ALL&lt;/code&gt; view equals the sum of the three. A point whose class changes is not silently split across two tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reprocess refuses rather than partially applies.&lt;/strong&gt; A dry run over a glob spanning classes, with one point out of horizon, refuses and names the offending points and classes. Assert that no rows were changed and no aggregate was refreshed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reinterpretation under an older registry version&lt;/strong&gt; reproduces the original canonical value from native, which is the property that makes the whole log-first argument true.&lt;/p&gt;

&lt;p&gt;Two measurements to record rather than assert, because their thresholds are open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sustainable ingest throughput&lt;/strong&gt; against the design rate, measured with synthetic load through stages 2 to 10 rather than against the insert alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold-start duration&lt;/strong&gt;, broken down by startup step.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;On Monday - the Dev Diary.&lt;/strong&gt; Not one finding this time but the whole apparatus: the five instruments that have grown up around the specification, and the different thing that keeps each of them honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the Wednesday after - &lt;em&gt;The Watchdog and Derived Points&lt;/em&gt;.&lt;/strong&gt; One timer wheel for every freshness deadline, why a sensor that only reports on change cannot be told apart from a dead one unless it is made to heartbeat, and how the restart step that dominates cold start is kept bounded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start of the series: &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-introduction/" rel="noopener noreferrer"&gt;An Introduction&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The map, the two decisions every later document is downstream of, and why a specification at this scale is being published in public while the system it describes gets built.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Sentinel Dev Diary: Where the Check Lives</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:10:58 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-dev-diary-where-the-check-lives-248g</link>
      <guid>https://dev.to/virorum/sentinel-dev-diary-where-the-check-lives-248g</guid>
      <description>&lt;p&gt;Build step three is the driver contract: a shared conformance harness, one push module and one poll module, and the host process that supervises them. It was audited a fortnight ago against the documents that own it, and the first of those, &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-driver-contract/" rel="noopener noreferrer"&gt;The Driver Contract&lt;/a&gt;&lt;/em&gt;, went out last Wednesday.&lt;/p&gt;

&lt;p&gt;Entry two was about silence. A specification that described things carefully, at length, and then never said whether they were required.&lt;/p&gt;

&lt;p&gt;This one is the opposite failure, and I did not see it coming. The document is not silent. It says the thing. It says the thing twice, in two places, and the two do not agree.&lt;/p&gt;

&lt;p&gt;Everything below is that same shape. Not a false statement about the system; a true statement in the wrong place, or two true statements that cannot both be the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract said both, and the code believed the one with a code block
&lt;/h2&gt;

&lt;p&gt;From &lt;em&gt;Push and poll are the same thing&lt;/em&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The driver module implements one method: emit observations. Whether that is an MQTT callback or a poll loop is internal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From &lt;em&gt;Conformance&lt;/em&gt;, eighty lines further down, where the interface is printed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;read(points) → observations&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are two different contracts. The first says a module emits, on its own initiative, whenever it has something. The second says a host asks and a module answers.&lt;/p&gt;

&lt;p&gt;The driver host built the second. I would build the second again from the same document, and the reason is not subtle: it is the one with a code block beside it. A signature beats a sentence, every time, and it beats it silently.&lt;/p&gt;

&lt;p&gt;So a push module latches the latest frame per point, and the host samples that latch on the assignment's interval; thirty seconds where a point declares nothing.&lt;/p&gt;

&lt;p&gt;For a level reading that is fine. A temperature that arrives at an awkward moment gets read at the next tick, and it is still the temperature.&lt;/p&gt;

&lt;p&gt;For a transient it is not fine at all. A button press. An alarm that raises and clears itself. A notification with no status counterpart. Those complete inside one interval and are never published. Not delayed; never published.&lt;/p&gt;

&lt;p&gt;And the document has a sentence about exactly that, which it can no longer support. &lt;em&gt;Buffering&lt;/em&gt; says of the bounded disk queue that&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;this is the single place in the whole path where data is dropped by design.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The latch is a second place. It has no drop count, and it has no &lt;code&gt;self&lt;/code&gt; point. So the problem is not that a sentence went out of date; it is that the guarantee the sentence makes, that data is never lost quietly, is defeated by the other reading of the same document.&lt;/p&gt;

&lt;h3&gt;
  
  
  The audit did not mention it
&lt;/h3&gt;

&lt;p&gt;This is the part that stings.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;build-step-3.md&lt;/code&gt; is the audit of the step that built that interface. It runs to twenty thousand characters, it is unsparing elsewhere, and it does not mention the choice at all.&lt;/p&gt;

&lt;p&gt;Not because anything was hidden. Because an audit asks whether the exit criteria pass, and "which of the document's two readings did you implement" was never an exit criterion. There was nothing there to fail.&lt;/p&gt;

&lt;p&gt;Which means the pull shape has been load-bearing since step three with nothing behind it. No decision row. No open item. No line anywhere saying a fork had been reached and a side taken.&lt;/p&gt;

&lt;p&gt;It is three rows now, because it is three different kinds of statement: the gap in the document, the reading the code took while it stood, and the build that closes it. Splitting them is not bureaucracy. The gap is discharged by the document saying which reading is the contract and by nothing else; the reading is history the moment the build lands; the build is work somebody has to do. One row would have to be wrong about two of those.&lt;/p&gt;

&lt;h3&gt;
  
  
  The step went amber for a day
&lt;/h3&gt;

&lt;p&gt;The amendment landed over the weekend, and it brought four new exit criteria with it. None of them passed.&lt;/p&gt;

&lt;p&gt;So step three, complete since a fortnight ago, went back to amber.&lt;/p&gt;

&lt;p&gt;Nothing regressed. No code changed. Every test that passed before the amendment passed after it. The document simply started asking for more than had been built, and a step is finished when its owning document's exit criteria pass.&lt;/p&gt;

&lt;p&gt;It was green again a day later, with the delivery path built and both hosts in the field running it.&lt;/p&gt;

&lt;p&gt;I want to be precise about why that yo-yo is the machine working rather than the machine embarrassing itself, because a status marker that moves backwards looks like a mistake and this one is the opposite.&lt;/p&gt;

&lt;p&gt;The marker follows the definition. The alternative is a marker that follows the work, and that quietly changes what the marker means: it stops recording whether the thing the document asks for exists, and starts recording how much effort has been spent. Those come apart precisely when a document is amended, which is the moment you most need the marker to be honest.&lt;/p&gt;

&lt;p&gt;A step going amber because its document grew is not a regression. It is the only truthful thing the marker can do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks, and one place the third can go
&lt;/h2&gt;

&lt;p&gt;Same shape, smaller blast radius, and it is sitting in the post that went out last Wednesday where anyone can check it. &lt;em&gt;Conformance&lt;/em&gt; names&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Three additions to the harness that come from later documents and belong in the same suite&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and lists epoch rejection, publish scope, buffer bounds.&lt;/p&gt;

&lt;p&gt;Only publish scope is in the harness. The other two are the host's, and the suite says so as a matter of design:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The suite is deliberately silent on anything the host owns. Backoff, buffering, epoch fencing, self-points, sequence numbers and subjects never reach a module, so a module cannot get them wrong and the harness does not ask.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the right call, and the argument for it comes from the document itself, in the section immediately before. &lt;em&gt;What drivers must never do&lt;/em&gt; forbids a module to hold verification state or timers. A module that tracked the highest epoch would be holding a fencing token; a module that owned the durable queue would be holding buffering state. The queue is one per host, behind every instance, so there is not even a coherent thing a single module could be asked to demonstrate.&lt;/p&gt;

&lt;p&gt;A check cannot be placed in a suite that runs against an interface which never sees the thing being checked. The code is right. The document put three checks in a place that can hold one of them.&lt;/p&gt;

&lt;p&gt;And that is a gap in the document rather than in the code, which decides what happens next: shipping the code that routed around it does not close it. So it was filed against &lt;em&gt;The Driver Contract&lt;/em&gt;, with the reading recorded separately so nobody would have to re-derive it meanwhile, and the document was amended a week later to say whose each check is. One of the three is the harness's; two are the host's.&lt;/p&gt;

&lt;p&gt;The order that repair happened in is the part worth keeping. The requirements register carried a paraphrase of the original sentence, and the paraphrase was wrong in an extra way of its own: it said the check happens in the harness "as well as core-side", and buffer bounds has no core half at all. It was still left alone while the finding was open, and moved in the same commit as the document rather than ahead of it. Correcting a paraphrase before the thing it paraphrases does not fix anything; it just produces a second answer, and now you have two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The register said otherwise, for three pull requests
&lt;/h2&gt;

&lt;p&gt;This is the one I keep coming back to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ROADMAP.md&lt;/code&gt; carried a sentence claiming that publish scope and buffer bounds "were module checks in the harness from the start". A docstring in the driver host repository said the same two live under &lt;code&gt;tests/conformance/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Buffer bounds never did. Not moved, not relocated: never there.&lt;/p&gt;

&lt;p&gt;Epoch rejection's move out of the harness &lt;em&gt;was&lt;/em&gt; noticed and recorded when it happened. Buffer bounds' move was not, and so a sentence asserting the opposite survived three pull requests, in two repositories, saying something false about the codebase it shipped with.&lt;/p&gt;

&lt;p&gt;Entry one ended on the registers being under test. &lt;code&gt;test_register_integrity.py&lt;/code&gt; asserts that ids are contiguous and unduplicated, that a requirement is never both a bare row and a set of sub-ids. Fifty-seven of those tests at the time of this audit. I wrote then that pointing the specification's own convention back at the process documents felt faintly ridiculous, and that it had caught things.&lt;/p&gt;

&lt;p&gt;It had, and it does. It could not catch this, and the reason is worth saying plainly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not one of the fifty-seven can check a claim about where a test lives.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They check the shape of the registers, which is the thing a register can be wrong about on its own. A sentence asserting that a particular test sits in a particular file is a claim about the world outside the register, and the register has no way to reach out and look.&lt;/p&gt;

&lt;p&gt;The same audit turned up two stale cross-reference anchors the same way: by a person reading, while all fifty-seven passed.&lt;/p&gt;

&lt;p&gt;And those &lt;em&gt;did&lt;/em&gt; have a fix available, which is what makes the boundary visible rather than merely assertable. An anchor points at a section &lt;strong&gt;inside&lt;/strong&gt; the register, so a test can follow it and see whether the row is there. One was written, straight after the audit, and it found four more the moment it ran.&lt;/p&gt;

&lt;p&gt;A sentence about which file a test lives in points outward, at a repository the register cannot see. There is nothing there for a test to follow.&lt;/p&gt;

&lt;p&gt;So the idea holds, and its edge is now drawn, which is more useful than the idea was on its own. A register can be machine-checked for everything that is a statement about itself. Everything it says about the world outside is still something somebody has to go and look at, and the cadence at which that happens is the audit, not the test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller ones, and they rhyme
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The command dedup window is a fixed five minutes&lt;/strong&gt;, deliberately not derived from the command's own deadline. Sizing it from the deadline is the obvious move and it is actively harmful: it puts the core's clock inside a driver-side decision, so a host running fast forgets a command id early and pulses the relay on exactly the redelivery the window exists to suppress. Wrong in the direction that costs memory, rather than wrong in the direction that moves a physical thing twice. The published copy still leaves the figure open as "a short window"; it is a number now, and fixed on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two subjects were built and never called.&lt;/strong&gt; Availability and quarantine each had a subject builder, an envelope type, and a publish path asserted against a real broker, and nothing in the driver host ever constructed a message for either. A test that proves the machinery works is not a test that proves anything runs it.&lt;/p&gt;

&lt;p&gt;Availability has both ends now. The core subscribes at startup and narrows each envelope to what the declaring instance actually owns, and a driver declares on exactly two edges: losing the transport while it is serving, and stopping deliberately. Quarantine was lifted out of that row rather than carried along inside it, because its producer end turns out not to be buildable yet at all, and a resolved row still quietly carrying half its subject is a row nobody ever comes back for. That is entry two's distinction between deferred and forgotten, met again from the other direction: the danger this time was not an absence with no row, but a row marked done with an absence still in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the shape is
&lt;/h2&gt;

&lt;p&gt;Entry two found one root cause behind three symptoms. This step has a different unity, and it took me a while to see it.&lt;/p&gt;

&lt;p&gt;Every finding is about &lt;strong&gt;placement&lt;/strong&gt;. Which of two sentences is the contract. Which side of a process boundary a check belongs on. Which file a test is in, as asserted by a document that cannot see the file. Whether a built path has anything calling it.&lt;/p&gt;

&lt;p&gt;None of that is the kind of error a test catches, because a test runs the code, and these are all statements about &lt;em&gt;where things are&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And the drift check, which reads the code against the document and is the thing I would have expected to catch all of it, only works from one side. It finds where the code departs from the prose. When the prose departs from itself, both halves are documented, both are defensible, and the code picks one without recording that it picked.&lt;/p&gt;

&lt;p&gt;The honest summary of the step: the specification was wrong in three places, none of them about the system, all of them about itself.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This Wednesday - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-ingest-path-and-log-schema/" rel="noopener noreferrer"&gt;Ingest Path &amp;amp; Log Schema&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The specification's fourth part: what an observation does between arriving on the bus and being answerable, and the log that the current state is only ever a projection of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next Monday - the hole entry one ended on has been filled.&lt;/strong&gt; &lt;em&gt;Nothing reads the documents against each other&lt;/em&gt; was an open question a fortnight ago. It is a procedure now, and the first run of it broke its own rule on purpose. That entry waits for the documents it found things in to be published.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start of the diary: &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-the-registers/" rel="noopener noreferrer"&gt;Four Registers and a Drift Check&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Why the registers exist at all, and the one rule about where a sentence is allowed to live.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>documentation</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Sentinel: The Driver Contract</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:31:10 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-the-driver-contract-48li</link>
      <guid>https://dev.to/virorum/sentinel-the-driver-contract-48li</guid>
      <description>&lt;p&gt;A temperature sensor reports &lt;code&gt;71.6&lt;/code&gt;, and it means Fahrenheit. Meanwhile, the canonical unit for temperature in the system is Celsius. It is a simple conversion: a subtraction and a multiplication, and the driver bringing the sensor's reading into the system has everything it needs to perform it.&lt;/p&gt;

&lt;p&gt;But it must not convert. What it publishes is &lt;code&gt;71.6&lt;/code&gt;, tagged with the unit the device actually used: the conversion happens on the far side of a wire boundary.&lt;/p&gt;

&lt;p&gt;Two things break if the conversion happens at the driver. The number the sensor actually produced stops existing anywhere, so the reading cannot be reproduced once the unit tag or the scale factor turns out to have been wrong - and on a long enough timeline one of them is bound to be. The other is that the driver becomes a second place where the meaning of a point gets decided, when the registry is supposed to be the only one. A protocol adapter running on a Raspberry Pi in a cupboard is a poor place to keep half a semantic model.&lt;/p&gt;

&lt;p&gt;Converting a unit is the first item on a list of eight things a driver is forbidden to do, and that list is the spine of this document. Everything else here - the process model, the assignment export, the subject layout, the buffering rules, the deliberately narrow command contract - is what remains once protocol ugliness has been confined to one side of that line and meaning to the other.&lt;/p&gt;


&lt;p&gt;&lt;strong&gt;What this document owns:&lt;/strong&gt; the driver host and instance process model, the assignment export and why it is pushed rather than queried, the instance lifecycle, the bus subject layout and the limits on the observation stream, the driver-supplied half of the observation envelope, local buffering and the one place in the path where data is dropped by design, availability declarations, driver-side command handling, discovery and quarantine, the prohibitions list, and the conformance harness every module is tested against. What it deliberately does not own is anything about what a value &lt;em&gt;means&lt;/em&gt;, which belongs to &lt;em&gt;The Semantic Model&lt;/em&gt;; command verification and the epoch whose token the driver checks, which belong to &lt;em&gt;Core Runtime&lt;/em&gt;; and the permissions that turn the subject layout into an authorisation surface, which belong to &lt;em&gt;Security Model&lt;/em&gt;. The boundary drawn here is what lets all three assume a device looks the same whatever protocol it speaks.&lt;/p&gt;

&lt;p&gt;The driver contract has one job: make every device look identical to the core while keeping all protocol ugliness on the far side of a wire boundary. Everything below follows from drivers being dumb by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process model
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Driver host&lt;/strong&gt; is the deployment unit — one container or systemd unit per network segment. It loads driver modules and supervises &lt;strong&gt;driver instances&lt;/strong&gt;, one per configured device or device group.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;driver host "rpi-c1"
├── modbus-tcp    → instance "boiler-relay"
├── mqtt          → instance "esp32-fleet"
└── ble           → instance "ble-local"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supervision is per instance. A wedged Modbus poller must not take down MQTT ingest in the same host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One northbound transport, for every protocol without exception.&lt;/strong&gt; Every driver host reaches the core the same way: over the bus, in the observation envelope below. A second northbound path for any one protocol buys two failure models, two buffering implementations, and two places to fix backpressure, in exchange for convenience in a single module.&lt;/p&gt;

&lt;p&gt;The temptation is strongest for BLE, because the radio work genuinely is bespoke — supervision, retry-on-connect, per-device connection state — and it is easy to let that bespokeness leak upward into how the host talks to the core. It stays below the line. Whatever a BLE host has to do to hold a connection is its own business; what it emits is an observation like any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assignment, not configuration
&lt;/h2&gt;

&lt;p&gt;Drivers must not read the registry, but they need to know what to poll. The registry &lt;strong&gt;exports&lt;/strong&gt; assignments rather than having drivers query it.&lt;/p&gt;

&lt;p&gt;On startup a driver host requests its assignment from the core and caches it to local disk. If the core is unreachable, it starts from cache. An assignment carries addressing only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;driver_instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boiler-relay&lt;/span&gt;
&lt;span class="na"&gt;module&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;modbus-tcp&lt;/span&gt;
&lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;10.0.4.12&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;502&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;unit_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;points&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;point_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boiler.ch1.flow-temp&lt;/span&gt;
    &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;input_reg&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30001&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;int16&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;poll&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30s&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;point_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boiler.ch1.pump&lt;/span&gt;
    &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;coil&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;coil&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;idempotent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;quantity&lt;/code&gt;, no &lt;code&gt;unit&lt;/code&gt;, no &lt;code&gt;subject&lt;/code&gt;, no freshness policy. The driver sees a point ID, an address, and a scale factor. &lt;code&gt;scale&lt;/code&gt; is a raw-register-to-native adjustment, not a unit conversion — a Modbus register holding tenths of a degree is a wire encoding, not a unit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credentials are referenced, never carried.&lt;/strong&gt; Where a transport needs one — an MQTT broker password, a vendor API key — the assignment names it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mqtt.lan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8883&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;credential&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mqtt/fleet&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver host resolves that reference against its own local store, provisioned out of band. The bus never carries a secret and neither does the on-disk assignment cache, which matters because the cache sits on every host and the assignment subject is readable by anything that can subscribe to it. See &lt;em&gt;Security Model&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Assignment changes arrive as a push on the bus with a version number. The driver applies them without restarting: new points start polling, removed points stop, changed addresses rebind. This is also the load-shedding lever — raising poll intervals across a fleet is an assignment change that takes effect in seconds, which is the manual response to sustained ingest lag described in &lt;em&gt;Core Runtime&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifecycle
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;init → connecting → connected → ready → degraded → connecting
                                  ↓
                              stopping → stopped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;connecting&lt;/code&gt; — transport not established, exponential backoff with jitter, capped around 60s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;connected&lt;/code&gt; — transport up, initial reads in flight&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ready&lt;/code&gt; — initial read pass complete, or explicitly skipped for points with no readable state&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;degraded&lt;/code&gt; — transport up, some points failing; instance stays up and keeps serving the rest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;connected → ready&lt;/code&gt; transition is what makes cold start work. Until an instance is &lt;code&gt;ready&lt;/code&gt;, the core keeps its points in &lt;code&gt;restored&lt;/code&gt; or &lt;code&gt;unknown&lt;/code&gt; rather than promoting them, rule warmup gates on it, and the command plane treats every claim on its points as undeliverable rather than sending into the dark.&lt;/p&gt;

&lt;p&gt;Instances publish their own lifecycle as points under &lt;code&gt;subject: self&lt;/code&gt; — &lt;code&gt;driver.boiler-relay.state&lt;/code&gt;, &lt;code&gt;.reconnects&lt;/code&gt;, &lt;code&gt;.poll-latency&lt;/code&gt;, and the queue and rejection counters below. Driver health is not a separate observability channel; it is the same pipeline, with the same freshness and alerting. Driver monitoring comes free, and the ingest path is exercised constantly as a side effect. The full catalogue of what a driver must publish is in &lt;em&gt;Observability&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bus subjects
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;obs.{host}.{instance}.{point_id}         observations
avail.{host}.{instance}                  device/component availability
quarantine.{host}.{instance}             unregistered points
cmd.{host}.{instance}.{point_id}         commands, core → driver
cmdres.{host}.{instance}.{command_id}    command outcome, driver → core
assign.{host}                            assignment push, core → host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every driver-plane subject leads with the host&lt;/strong&gt;, and that is what makes bus permissions workable: one credential grant per host covers every instance it will ever run, so credentials stop needing to change whenever an assignment does. Two places to keep in step is one too many, and the one that gets forgotten is the credential.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cmdres&lt;/code&gt; carries host and instance for the same reason. A subject of &lt;code&gt;cmdres.{command_id}&lt;/code&gt; alone would force every driver to hold &lt;code&gt;cmdres.&amp;gt;&lt;/code&gt;, and any driver could then forge an outcome for another driver's command. See &lt;em&gt;Security Model&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Commands route by instance rather than by point, because the core knows the binding and the driver should not have to filter a firehose. Each instance subscribes to &lt;code&gt;cmd.{its host}.{its own id}.&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stream limits
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;obs&lt;/code&gt; is a single JetStream stream, and it is the system's outage buffer. Three properties, all load-bearing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reject new publishes when full; never discard old.&lt;/strong&gt; A full stream pushes back onto the driver's local queue, which is bounded and reports its drops. Discarding old messages would delete observations that had already been accepted, between two components that both believe the handoff succeeded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded by bytes and messages, with no &lt;code&gt;max_age&lt;/code&gt;.&lt;/strong&gt; An age limit deletes unconsumed messages silently, which is the same failure by a different route. Age-based deletion belongs only where dropping old data is the actual intent, which here is Postgres retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sized from tolerable outage and utilisation&lt;/strong&gt;, not from a duration that feels sufficient for a restart. The arithmetic is in &lt;em&gt;Core Runtime&lt;/em&gt;, and the size cannot be computed until capacity targets exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Durability lives in Postgres. The stream exists so that a core outage costs nothing, not as an archive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Northbound: the observation
&lt;/h2&gt;

&lt;p&gt;Exactly the envelope from the semantic model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;point&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;native&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;unit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;native&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nv"&gt;quality:&lt;/span&gt; &lt;span class="k"&gt;live&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;bad&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;reason&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;detail&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;device&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;time&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;observed&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;driver&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;driver&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;assignment&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unit_native&lt;/code&gt; is populated only when the source self-describes — Shelly and UniFi JSON do, Modbus does not. When present it drives the mismatch check; when absent the registry is trusted.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;source_seq&lt;/code&gt; is monotonic per instance and resets on restart, with the restart marked by an epoch counter. &lt;code&gt;(epoch, seq)&lt;/code&gt; is what the core dedups on.&lt;/p&gt;

&lt;p&gt;A driver may publish only for points its own assignment names. The core enforces this rather than trusting it — see the publisher check in &lt;em&gt;Ingest Path &amp;amp; Log Schema&lt;/em&gt; — because subject permissions confine a host to its own subjects but say nothing about which point IDs it puts inside them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push and poll are the same thing
&lt;/h2&gt;

&lt;p&gt;The driver module implements one method: emit observations. Whether that is an MQTT callback or a poll loop is internal.&lt;/p&gt;

&lt;p&gt;Poll scheduling lives in the driver, never the core, for two reasons: the scheduler must survive a core outage, and it needs to be adjacent to the transport to do jitter, coalescing, and batching. A Modbus driver reading twenty registers should issue one multi-register read, not twenty round trips the core scheduled independently.&lt;/p&gt;

&lt;p&gt;Every poll produces an observation. A poll that returns the same value still publishes, because the deadband decision is core-side and freshness needs the refresh. A poll that fails produces quality &lt;code&gt;bad&lt;/code&gt; with the reason — not silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buffering
&lt;/h2&gt;

&lt;p&gt;When the bus is unreachable or full, the driver buffers to a local disk queue and replays with &lt;strong&gt;original &lt;code&gt;observed_at&lt;/code&gt; values intact&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is exactly why &lt;code&gt;ingested_at&lt;/code&gt; exists as a separate field. A driver host that buffers through a twenty-minute partition and then floods must not have those readings evaluated as fresh. The core orders by &lt;code&gt;ingested_at&lt;/code&gt;, computes freshness from &lt;code&gt;observed_at&lt;/code&gt;, and a rules engine seeing a burst of old readings treats them correctly.&lt;/p&gt;

&lt;p&gt;The queue is bounded by size and age. On overflow it drops oldest and publishes a &lt;code&gt;self&lt;/code&gt; point recording the drop count — silent data loss is the failure mode nobody notices, and this is the single place in the whole path where data is dropped by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Availability
&lt;/h2&gt;

&lt;p&gt;The driver declares unavailability; the core fans it out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;avail.{host}.{instance}&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;instance | device | component&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boiler"&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boiler.ch1"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;transport_timeout&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;observed_at&lt;/span&gt;
&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver names the scope and the core marks every affected point &lt;code&gt;unavailable&lt;/code&gt;. This is the difference between "the cause of the silence is unknown" (&lt;code&gt;stale&lt;/code&gt;) and "the device is off the network" (&lt;code&gt;unavailable&lt;/code&gt;), and only the driver can tell which.&lt;/p&gt;

&lt;p&gt;A driver that dies without saying anything is caught by its own heartbeat going stale — the core marks the instance unavailable after a grace period. Both paths converge on the same state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Southbound: commands
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;command_id, point_id, value, issued_at, deadline, idempotent, core_epoch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver's contract is narrow. It attempts the write and replies once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;command_id,
outcome: accepted | rejected | failed | timeout,
device_ack?: { value, observed_at },
reason?, detail?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;accepted&lt;/code&gt; means the transport accepted it. &lt;code&gt;device_ack&lt;/code&gt; is populated only when the protocol returns confirmation inline. Everything else the core owns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;core_epoch&lt;/code&gt; is a fencing token.&lt;/strong&gt; The driver tracks the highest epoch it has seen and rejects any command carrying a lower one, with outcome &lt;code&gt;rejected&lt;/code&gt; and reason &lt;code&gt;stale_epoch&lt;/code&gt;. It needs to understand nothing else about the value, only that it never goes backwards. The reasoning is in &lt;em&gt;Core Runtime&lt;/em&gt;; the effect is that a core which has already been replaced cannot still be driving relays through a partition it has not noticed yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification is core-side, without exception.&lt;/strong&gt; &lt;code&gt;poll_verify&lt;/code&gt; is the core issuing a read command after a delay. &lt;code&gt;async_event&lt;/code&gt; is the core correlating a later observation. &lt;code&gt;optimistic&lt;/code&gt; is the core marking the point &lt;code&gt;assumed&lt;/code&gt;. The driver has no concept of confirmation modes, no timers, and no memory of what it was asked to do.&lt;/p&gt;

&lt;p&gt;That boundary matters because verification needs durable timers and registry knowledge, and because a driver that retries on its own behalf will eventually double-fire a non-idempotent command. Drivers retry transport establishment, never command delivery — unless &lt;code&gt;idempotent: true&lt;/code&gt;, which permits one retry within the deadline.&lt;/p&gt;

&lt;p&gt;Commands are deduplicated on &lt;code&gt;command_id&lt;/code&gt; within a short window, so a bus redelivery does not pulse a relay twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery and quarantine
&lt;/h2&gt;

&lt;p&gt;An observation for an unknown &lt;code&gt;point_id&lt;/code&gt; goes to &lt;code&gt;quarantine.{host}.{instance}&lt;/code&gt; with its full native payload, never to &lt;code&gt;obs.*&lt;/code&gt;. The core records it, deduplicates by point ID, and surfaces it for triage. Nothing auto-registers.&lt;/p&gt;

&lt;p&gt;This gives discovery as a side effect of normal operation. Connect a new device, let the driver run, and watch what accumulates in quarantine. Promoting a point is a registry commit supplying &lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, unit, and freshness — the four things no driver can guess correctly.&lt;/p&gt;

&lt;p&gt;For protocols with real discovery (mDNS, vendor announce messages, device lists), the driver publishes candidates to the same quarantine subject in the same shape. One triage path regardless of how the point was found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What drivers must never do
&lt;/h2&gt;

&lt;p&gt;Worth writing down and enforcing in review, because each of these is a plausible-looking shortcut that dissolves the boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Convert units, or emit anything but &lt;code&gt;value_native&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Assert quality other than &lt;code&gt;live&lt;/code&gt;, &lt;code&gt;bad&lt;/code&gt;, or &lt;code&gt;unavailable&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Invent, rename, or auto-register points&lt;/li&gt;
&lt;li&gt;Publish for a point its assignment does not name&lt;/li&gt;
&lt;li&gt;Implement debounce, deadband, moving averages, or hysteresis&lt;/li&gt;
&lt;li&gt;Retry a non-idempotent command&lt;/li&gt;
&lt;li&gt;Hold verification state or timers&lt;/li&gt;
&lt;li&gt;Read the registry directly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conformance
&lt;/h2&gt;

&lt;p&gt;The module interface is small enough to test properly, and with six or more modules to write, a shared harness pays for itself immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect() → transport
read(points) → observations
write(point, value) → outcome
close()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A conformance suite is built alongside the first driver: a mock transport that produces malformed payloads, unmapped enum values, out-of-range readings, mid-poll disconnects, slow responses that exceed a deadline, and clock skew. Every module runs the same suite. The failure modes are protocol-independent even though the protocols are not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mqtt&lt;/code&gt; and &lt;code&gt;modbus-tcp&lt;/code&gt; come first — push and poll, self-describing and not, verifiable and not. Between them they exercise every branch of this contract. Other protocols are then variations rather than new problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit criteria
&lt;/h2&gt;

&lt;p&gt;The conformance suite above is this step's exit criterion, not a nice-to-have. A driver module is done when it passes the shared harness, and &lt;code&gt;mqtt&lt;/code&gt; and &lt;code&gt;modbus-tcp&lt;/code&gt; both pass it before anything else is written.&lt;/p&gt;

&lt;p&gt;Three additions to the harness that come from later documents and belong in the same suite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Epoch rejection.&lt;/strong&gt; A command carrying a &lt;code&gt;core_epoch&lt;/code&gt; below the highest seen is rejected with &lt;code&gt;stale_epoch&lt;/code&gt;, and the driver does not attempt the write. Tested by replaying an old command after a higher epoch has been observed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish scope.&lt;/strong&gt; The module publishes only under its own host and instance prefix, and only for point IDs its assignment names. This is checked here as well as core-side, because a driver that gets it wrong should fail its own tests rather than fill quarantine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffer bounds.&lt;/strong&gt; Fill the local queue past its limit and assert that the oldest entries are dropped, the drop count is published, and nothing is lost silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The step as a whole is done when both modules pass, and when an instance taken from &lt;code&gt;connecting&lt;/code&gt; to &lt;code&gt;ready&lt;/code&gt; to &lt;code&gt;degraded&lt;/code&gt; and back publishes the lifecycle transitions that cold start and deliverability depend on.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;On Monday - the &lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-where-the-check-lives/" rel="noopener noreferrer"&gt;Dev Diary&lt;/a&gt;.&lt;/strong&gt; The third entry of what the build has had to send back to the specification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next part - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-ingest-path-and-log-schema/" rel="noopener noreferrer"&gt;Ingest Path and Log Schema&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Two logs rather than one, and the column that makes the unit conversion a driver is forbidden to do reversible once it happens in the right place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start of the series: &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-introduction/" rel="noopener noreferrer"&gt;An Introduction&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The map, the two decisions every later document is downstream of, and why a specification at this scale is being published in public while the system it describes gets built.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>iot</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Sentinel Dev Diary: What the Specification Left Unsaid</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:52:49 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-dev-diary-what-the-specification-left-unsaid-3jgl</link>
      <guid>https://dev.to/virorum/sentinel-dev-diary-what-the-specification-left-unsaid-3jgl</guid>
      <description>&lt;p&gt;Build step one is the semantic model: identity, the closed vocabularies, units, quality, retention class. It was marked complete a fortnight ago. Then it was audited against &lt;em&gt;The Semantic Model&lt;/em&gt;, which is the document that owns it and which went out last Wednesday, and the audit is the part worth writing about.&lt;/p&gt;

&lt;p&gt;Here is the thing I did not expect. The specification was not wrong anywhere. Not once. Every delta between what it says and what got built came from the same place: it described something carefully, at length, with the reasoning attached... and then never said whether it was optional.&lt;/p&gt;

&lt;p&gt;Silence is not neutral. Silence resolves, and it resolves to whatever the code does by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six rejections became twenty-eight
&lt;/h2&gt;

&lt;p&gt;The exit criteria name six ways the registry must refuse bad input. &lt;code&gt;app/registry/errors.py&lt;/code&gt; defines twenty-eight.&lt;/p&gt;

&lt;p&gt;Most of that is not interesting. &lt;code&gt;malformed_point_id&lt;/code&gt;, &lt;code&gt;duplicate_point_id&lt;/code&gt;, &lt;code&gt;canonical_unit_missing&lt;/code&gt;, &lt;code&gt;kind_mapped_twice&lt;/code&gt;: these are finer-grained versions of what the criteria already asked for, and they need no justification beyond a load reporting &lt;em&gt;what&lt;/em&gt; is wrong rather than &lt;em&gt;that&lt;/em&gt; something is. If you are going to refuse, refuse specifically.&lt;/p&gt;

&lt;p&gt;Two of the twenty-eight are different. They are new rules, in the sense that the specification does not state them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A numeric point must declare a deadband&lt;/strong&gt; (&lt;code&gt;change_policy_required&lt;/code&gt;). The descriptor carries &lt;code&gt;change_policy&lt;/code&gt;, and the document argues hard for why the deadband exists; without one "a noisy analogue sensor generates an edge per sample and the rules engine spends its life on 0.01 °C". Having made that argument, it never says the field is required.&lt;/p&gt;

&lt;p&gt;So what happens if it is absent? Every sample becomes an edge. The exact failure the document spent a paragraph warning about, arrived at by leaving a field out. Booleans, enums and strings still default to &lt;code&gt;any&lt;/code&gt;, because a boolean that changes is an edge by construction; but a float with no deadband is a mistake wearing the costume of a default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A writable point must declare a confirmation mode&lt;/strong&gt; (&lt;code&gt;confirmation_mode_required&lt;/code&gt;). Same shape, worse consequence. There is no safe default here, and that is the whole argument: the mode an unset field would most naturally mean is &lt;code&gt;optimistic&lt;/code&gt;, which is the mode that verifies nothing. A default that quietly means "assume the write worked" is not a default; it is a trap with good manners.&lt;/p&gt;

&lt;p&gt;Both could have been solved with a default value. I want to be clear about why they were not, because "make it mandatory" is the more annoying choice and it needs defending.&lt;/p&gt;

&lt;p&gt;A default is a decision made once, by whoever wrote the schema, on behalf of every point that will ever exist. It is invisible at the point of use. Nobody authoring a descriptor sees the deadband they did not write. Whereas a load-time refusal is a decision made by the person who knows what the point actually is, at the moment they are thinking about it, with a named error telling them precisely which of the twenty-eight things went wrong.&lt;/p&gt;

&lt;p&gt;It costs a line of YAML per point. It buys the guarantee that no point in the system has a policy nobody chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning prose into types found a missing number
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The Semantic Model&lt;/em&gt; writes its policies as strings: &lt;code&gt;periodic(30s, grace=15s)&lt;/code&gt;, &lt;code&gt;absolute(0.2)&lt;/code&gt;, &lt;code&gt;poll_verify(500ms)&lt;/code&gt;, &lt;code&gt;assume(v)&lt;/code&gt;. They are readable, they are compact, and they are exactly what &lt;em&gt;Introduction&lt;/em&gt; warns against: "anything encoded as a string suffix will have to be parsed later."&lt;/p&gt;

&lt;p&gt;So they became discriminated unions in &lt;code&gt;app/schemas/descriptor.py&lt;/code&gt;. That is a straightforward application of a rule the specification already made, and it would not be worth a paragraph except for what fell out of it.&lt;/p&gt;

&lt;p&gt;Writing the type forced a number the document never gives. &lt;code&gt;periodic&lt;/code&gt; has a &lt;code&gt;grace&lt;/code&gt; shown as optional, with no statement of what it defaults to. In prose that reads fine; you skim past it. In a typed model you cannot skim past it, because the field either has a default or it does not, and you have to type one or the other.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PeriodicFreshness.effective_grace&lt;/code&gt; now resolves it to half the interval, capped at 60 s, at load. Not at every read; the distinction matters, because a value computed at read time is a value that can differ between two readers of the same descriptor.&lt;/p&gt;

&lt;p&gt;That is the second-order benefit of turning prose into types, and I had not really appreciated it before this step. The type system does not just enforce the document. It &lt;em&gt;interrogates&lt;/em&gt; it, and it asks about precisely the things a careful reader glides over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three smaller decisions, one pattern
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Units are nested under &lt;code&gt;quantity&lt;/code&gt; rather than becoming a sixth vocabulary.&lt;/strong&gt; The specification names five closed vocabularies and gives the unit table no home of its own, so it could have gone either way. It went inside &lt;code&gt;quantity&lt;/code&gt; because the quantity owns its canonical unit and there is no useful reading in which the conversions &lt;em&gt;into&lt;/em&gt; that unit live somewhere else. Nesting keeps the count at five as specified, makes &lt;code&gt;unit_native&lt;/code&gt; convertibility a membership test rather than a lookup across files, and lets &lt;code&gt;affine: false&lt;/code&gt; reject an offset at load; which, as the document itself points out, is where a 5 °C rise mistaken for 41 °F gets caught.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derived fields are declared anyway, so authoring one is a named refusal.&lt;/strong&gt; &lt;code&gt;unit_canonical&lt;/code&gt; and &lt;code&gt;interlock_protected&lt;/code&gt; are both derived. The document says so and says nothing about how a hand-authored one should fail. They are declared on &lt;code&gt;PointDescriptor&lt;/code&gt; regardless, so writing one yourself fails as &lt;code&gt;derived_field_authored&lt;/code&gt; rather than being swallowed as an unknown key. A typo and a misunderstanding deserve different errors; the first wants a spell-checker, the second wants an explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dBm&lt;/code&gt; was kept rather than "corrected" to strict UCUM &lt;code&gt;dB[mW]&lt;/code&gt;.&lt;/strong&gt; The quantity table spells it &lt;code&gt;dBm&lt;/code&gt;, which is not a valid UCUM code. It is, however, what every datasheet, vendor API and engineer writes for RSSI. The owning document was followed rather than quietly corrected in code, and the divergence was recorded in the register instead. This one is a small rule with a big blast radius: canonical values are frozen in the log at ingest, so changing that spelling stops being a YAML edit and becomes a migration over the highest-volume table in the system, the moment the first driver publishes.&lt;/p&gt;

&lt;p&gt;The pattern in all three: where the document had made a choice, follow it and record the disagreement. Where the document had made no choice, make one and record the reasoning. Never quietly improve a document from inside the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the step did not deliver
&lt;/h2&gt;

&lt;p&gt;This is the half of an audit that earns it. Three findings, and the first two share a root cause: &lt;strong&gt;nothing compared registry version N against version N−1 at load.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A guard that existed as a function with a fixture
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;vocabulary_member_removed_while_referenced&lt;/code&gt; is one of the six exit-criteria rejections, and its fixture passed. Green. Ticked.&lt;/p&gt;

&lt;p&gt;The check needs a baseline, and &lt;strong&gt;only the tests supplied one.&lt;/strong&gt; Both real call sites passed none: the runtime at startup, and the CI check that runs against the actual registry. So removing &lt;code&gt;heatsink&lt;/code&gt; from the subject vocabulary would have failed as &lt;code&gt;unknown_vocabulary_member&lt;/code&gt;; a different error, from a different check, that happens to look exactly like the guard working.&lt;/p&gt;

&lt;p&gt;That is the bit that took a moment to sit with. The guard was not missing. It was not broken. It was unarmed, and the symptom of it being unarmed was a passing test and a plausible-looking error message.&lt;/p&gt;

&lt;p&gt;The fix was not "call it with a baseline". The fix was to make the resolver &lt;strong&gt;refuse&lt;/strong&gt; when it cannot find a previous revision, rather than returning "no baseline" and carrying on. A fallback would have reproduced the original failure exactly where it matters most, which is a shallow CI checkout: no previous revision available, so no comparison, so no guard, so green. The cost is that CI now needs &lt;code&gt;fetch-depth: 0&lt;/code&gt;, and the failure message says so in as many words.&lt;/p&gt;

&lt;p&gt;A guard that degrades gracefully is not a guard. It is a guard-shaped hole that reports success.&lt;/p&gt;

&lt;h3&gt;
  
  
  The register had no row for &lt;code&gt;addresses&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;The Semantic Model&lt;/em&gt; is the document that places addresses in the registry:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The registry holds &lt;code&gt;addresses&lt;/code&gt; separately: MQTT topic, Modbus unit ID and register, BLE MAC, UniFi site path. These change freely without touching identity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is no field on &lt;code&gt;PointDescriptor&lt;/code&gt;, no file under &lt;code&gt;registry/&lt;/code&gt;, and no table. Nothing was built.&lt;/p&gt;

&lt;p&gt;And that is defensible, because the document that consumes them is later in the series and the work is genuinely blocked behind it. What is &lt;em&gt;not&lt;/em&gt; defensible is that the step's ledger had no row saying so. The gap between "deliberately deferred" and "forgotten" is invisible from outside; both look like an absence. The only thing that distinguishes them is a row somebody wrote at the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The transition criterion was delivered in half
&lt;/h3&gt;

&lt;p&gt;The criterion asks that the quality transition table "exists as a checked enum, with a test asserting that no code path can produce a transition outside it".&lt;/p&gt;

&lt;p&gt;The first half landed: the full 7×7 matrix is asserted, including the two an implementation gets wrong, which are that &lt;code&gt;unavailable&lt;/code&gt; has only two exits and that nothing returns to a startup state. The second half could not land, because at that point nothing in the system wrote a quality at all, and the deferral was written into a docstring honestly.&lt;/p&gt;

&lt;p&gt;The interesting part is what happened next. A later step added the first real writer, and a test that it cannot write an illegal transition. What was never added is the &lt;em&gt;structural&lt;/em&gt; assertion that no writer bypasses the function; and the docstring still reads as though nothing writes a quality, which is no longer true. So the deferral note itself has rotted, in the specific way that deferral notes rot: not by being wrong when written, but by being right about a world that moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the whole thing
&lt;/h2&gt;

&lt;p&gt;One root cause produced three symptoms across two build steps, and none of the three looked related while I was in them. That is what an audit buys that a code review does not: not a closer look at a change, but a look at the step from the outside, against the document rather than against the commits.&lt;/p&gt;

&lt;p&gt;The step was already marked complete when the audit ran. It stayed complete; nothing found here unmade it. What changed is that "complete" now means something checkable, because the enumeration of what is missing exists and is written down where it can be argued with.&lt;/p&gt;

&lt;p&gt;I would rather ship a step with three named gaps than one with none I have looked for.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This Wednesday - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-driver-contract/" rel="noopener noreferrer"&gt;The Driver Contract&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The specification's third part: making every device look identical to the core while keeping the protocol ugliness on the far side of a wire boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next entry - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-where-the-check-lives/" rel="noopener noreferrer"&gt;Where the Check Lives&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The driver contract turned out to say a module emits and to say a host reads, and only one of those was built; plus three conformance checks placed where one of them can go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start of the diary: &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-the-registers/" rel="noopener noreferrer"&gt;Four Registers and a Drift Check&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Why the registers exist at all, and the one rule about where a sentence is allowed to live.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Sentinel: The Semantic Model</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:08:10 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-the-semantic-model-2k7p</link>
      <guid>https://dev.to/virorum/sentinel-the-semantic-model-2k7p</guid>
      <description>&lt;p&gt;A UUID is the obvious primary key. It is opaque, globally unique, immune to renames, and every tutorial reaches for it. It is also the wrong choice here, and the reason has nothing to do with database design.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;boiler.ch1.flow-temp&lt;/code&gt; shows up in a NATS subject, a Postgres row, a rule definition, a log line and a stack trace. Every one of those is a place a person reads under time pressure. &lt;code&gt;f47ac10b-58cc-4372-a567-0e02b2c3d479&lt;/code&gt; is correct in all five and useful in none of them: it moves the question "which point is this?" one join away, and one join away is far enough that nobody makes the trip at eleven at night.&lt;/p&gt;

&lt;p&gt;So the identifier is a dotted slug, and it is the primary key rather than a label hanging off one. That choice has a price, and it is paid at rename time and nowhere else. The rest of this document is the same kind of trade made about twenty more times - what a point is, what it is allowed to mean, and what the system is required to admit it does not know.&lt;/p&gt;


&lt;p&gt;&lt;strong&gt;What this document owns:&lt;/strong&gt; point identity and hierarchy, the descriptor fields, the five closed vocabularies, units and conversion, the seven quality states and their legal transitions, the three timestamps, and the observation envelope. Everything downstream reads the registry defined here - the log schema, the watchdog, the rules engine and the command plane all resolve against it. It is the first build step for the same reason it is the first document: nothing later can correct a semantic model that was wrong, only migrate away from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;point_id&lt;/code&gt; is a dotted path: &lt;code&gt;{device}.{component}.{point}&lt;/code&gt;, each segment &lt;code&gt;[a-z0-9-]+&lt;/code&gt;. This maps 1:1 onto NATS subjects, survives being pasted into logs and grep, and reads correctly in a stack trace.&lt;/p&gt;

&lt;p&gt;The slug is the primary key in Postgres, not a UUID with the slug as a label. Stability comes from the ID being assigned at registration and never derived from anything the network can change. A rename is a deliberate migration event, and it should feel like one.&lt;/p&gt;

&lt;p&gt;The registry holds &lt;code&gt;addresses&lt;/code&gt; separately: MQTT topic, Modbus unit ID and register, BLE MAC, UniFi site path. These change freely without touching identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hierarchy
&lt;/h2&gt;

&lt;p&gt;Device → Component → Point, where Component may be trivial (&lt;code&gt;main&lt;/code&gt;) so a one-sensor device does not pay ceremony for structure it does not have.&lt;/p&gt;

&lt;p&gt;Component earns its place because it is the unit of independent failure and hot-swap: the four channels of a multi-channel relay, a sensor behind one leg of an I²C multiplexer. When the multiplexer leg goes dead, one component is marked &lt;code&gt;unavailable&lt;/code&gt;, not the device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Points are scalar.&lt;/strong&gt; If a device emits a struct, the driver explodes it into points. No exceptions — the moment a point can hold a composite, every downstream consumer has to understand payload shapes, which is the coupling this model exists to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Point descriptor
&lt;/h2&gt;

&lt;p&gt;The declarative half, living in the registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;id                 boiler.ch1.flow-temp&lt;/span&gt;
&lt;span class="s"&gt;device_id          boiler&lt;/span&gt;
&lt;span class="s"&gt;component_id       ch1&lt;/span&gt;
&lt;span class="s"&gt;direction          in | out | inout&lt;/span&gt;
&lt;span class="s"&gt;kind               measurement | state | setpoint | command | diagnostic&lt;/span&gt;
&lt;span class="s"&gt;value_type         float | int | bool | enum | string&lt;/span&gt;

&lt;span class="s"&gt;quantity           temperature&lt;/span&gt;
&lt;span class="s"&gt;unit_native        Cel&lt;/span&gt;                    &lt;span class="c1"&gt;# UCUM&lt;/span&gt;
&lt;span class="s"&gt;unit_canonical     Cel&lt;/span&gt;                    &lt;span class="c1"&gt;# derived from quantity&lt;/span&gt;
&lt;span class="s"&gt;subject            water&lt;/span&gt;
&lt;span class="s"&gt;subject_ref        zone:upstairs-ch&lt;/span&gt;       &lt;span class="c1"&gt;# optional, nullable&lt;/span&gt;
&lt;span class="s"&gt;modifiers          { aggregation&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;instant }&lt;/span&gt;
&lt;span class="s"&gt;tags               [ heating, primary-loop ]&lt;/span&gt;

&lt;span class="s"&gt;valid_range        { min&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;-10, max&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;120 }&lt;/span&gt;
&lt;span class="s"&gt;precision          &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="s"&gt;change_policy      absolute(0.2)&lt;/span&gt;
&lt;span class="s"&gt;freshness          periodic(30s, grace=15s)&lt;/span&gt;
&lt;span class="s"&gt;retention_class    measurement&lt;/span&gt;            &lt;span class="c1"&gt;# defaults from kind&lt;/span&gt;

&lt;span class="c1"&gt;# out / inout only&lt;/span&gt;
&lt;span class="s"&gt;init_strategy      read_on_connect | restore_from_log | assume(v) | assert(v)&lt;/span&gt;
&lt;span class="s"&gt;confirmation_mode  inline | async_event | poll_verify(500ms) | optimistic&lt;/span&gt;
&lt;span class="s"&gt;default_value      &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;                  &lt;span class="c1"&gt;# optional&lt;/span&gt;
&lt;span class="s"&gt;require_intent     &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="s"&gt;interlock_protected &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;                 &lt;span class="c1"&gt;# derived at rule load, not authored&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kind&lt;/code&gt; and &lt;code&gt;direction&lt;/code&gt; are not redundant. &lt;code&gt;kind&lt;/code&gt; describes what the value &lt;em&gt;means&lt;/em&gt;; &lt;code&gt;direction&lt;/code&gt; describes flow. A setpoint is &lt;code&gt;inout&lt;/code&gt;; a command is &lt;code&gt;out&lt;/code&gt; with no retained value; a diagnostic is &lt;code&gt;in&lt;/code&gt; but must never appear in a measurement dashboard or share a freshness policy with real sensors.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;default_value&lt;/code&gt; and &lt;code&gt;require_intent&lt;/code&gt; are the point's half of the arbitration model in the command plane. &lt;code&gt;default_value&lt;/code&gt; is optional; when present, the registry materialises a non-expiring claim at the lowest band, which is the resting state the point falls back to when every other claim is released. When absent, releasing the last claim leaves the point alone rather than driving it anywhere.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;require_intent: true&lt;/code&gt; says the point must never be without a resolved intent, and registry load fails if it has no &lt;code&gt;default_value&lt;/code&gt;. &lt;code&gt;interlock_protected&lt;/code&gt; is set at rule load rather than authored, and implies &lt;code&gt;require_intent&lt;/code&gt;. Between them, a protective point is one that cannot accidentally be left with nothing asserting a state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention class
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;retention_class&lt;/code&gt; is a closed vocabulary — &lt;code&gt;measurement&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;diagnostic&lt;/code&gt; — and it decides how the point's data is stored, aged and summarised. The policy behind each class is in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It is rarely authored, because it &lt;strong&gt;defaults from &lt;code&gt;kind&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;kind&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;default class&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;state&lt;/code&gt;, &lt;code&gt;setpoint&lt;/code&gt;, &lt;code&gt;command&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;diagnostic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;diagnostic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two properties belong here rather than in the retention document, because they are facts about the descriptor:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The default is resolved at registry load and stored explicitly.&lt;/strong&gt; The class determines physical placement, and placement must not change silently because &lt;code&gt;kind&lt;/code&gt; was edited for an unrelated reason. What the registry stores is the resolved class, not the rule that produced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changing it afterwards is a migration&lt;/strong&gt;, in the same category as a point rename: existing rows live in a table chosen by the old class and have to be moved. A deliberate operation, not a config edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The semantic axes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;quantity&lt;/strong&gt; is a closed vocabulary, and it owns the canonical unit. One table, one entry per quantity, and the canonical unit is a property of the quantity rather than a per-point decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision:&lt;/strong&gt; pragmatic canonical units (°C, Wh) rather than strict SI ones (K, J). Strict would buy clean dimensional algebra — divide energy by time, get power, units fall out. Pragmatic buys rules and dashboards that read without conversion. This system does not do dimensional algebra, so pragmatic is the right choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;unit&lt;/strong&gt; uses UCUM codes (&lt;code&gt;Cel&lt;/code&gt;, &lt;code&gt;mA&lt;/code&gt;, &lt;code&gt;V&lt;/code&gt;, &lt;code&gt;W.h&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt;) rather than invented strings, with a separate &lt;code&gt;display_symbol&lt;/code&gt;. UCUM is a real standard with libraries, and it permanently kills the &lt;code&gt;°C&lt;/code&gt; / &lt;code&gt;degC&lt;/code&gt; / &lt;code&gt;C&lt;/code&gt; / &lt;code&gt;celsius&lt;/code&gt; ambiguity. The point stores &lt;code&gt;unit_native&lt;/code&gt;; the observation stores both native and canonical values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;subject&lt;/strong&gt; is the class of thing measured: &lt;code&gt;air&lt;/code&gt;, &lt;code&gt;water&lt;/code&gt;, &lt;code&gt;heatsink&lt;/code&gt;, &lt;code&gt;core_body&lt;/code&gt;, &lt;code&gt;skin&lt;/code&gt;, &lt;code&gt;battery-cell&lt;/code&gt;, &lt;code&gt;mains&lt;/code&gt;. Also a closed vocabulary.&lt;/p&gt;

&lt;p&gt;Subject alone is underspecified. Two air-temperature sensors in different rooms have the same subject, and that difference is location rather than subject. So the axis splits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subject&lt;/code&gt; — the class of thing (closed vocabulary)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;subject_ref&lt;/code&gt; — an optional typed reference to a specific modelled entity: &lt;code&gt;zone:loft&lt;/code&gt;, &lt;code&gt;device:rpi-t2&lt;/code&gt;, &lt;code&gt;person:x&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;subject_ref&lt;/code&gt; is nullable and unresolved for now. It is the hook that allows spatial or logical modelling to be added later without reworking every point. Declaring the field costs nothing; retrofitting it costs everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;modifiers&lt;/strong&gt; is a small typed map for things that qualify a reading without changing quantity or subject:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;current_type&lt;/code&gt;: &lt;code&gt;ac&lt;/code&gt; | &lt;code&gt;dc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;phase&lt;/code&gt;: &lt;code&gt;l1&lt;/code&gt; | &lt;code&gt;l2&lt;/code&gt; | &lt;code&gt;l3&lt;/code&gt; | &lt;code&gt;n&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aggregation&lt;/code&gt;: &lt;code&gt;instant&lt;/code&gt; | &lt;code&gt;mean&lt;/code&gt; | &lt;code&gt;min&lt;/code&gt; | &lt;code&gt;max&lt;/code&gt; | &lt;code&gt;sum&lt;/code&gt; | &lt;code&gt;delta&lt;/code&gt; — this is what distinguishes a daily energy total from an instantaneous power reading, and it is a real semantic difference that must not hide in the point name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;aggregation&lt;/code&gt; earns its place twice over: it is also what tells the rollup layer that a cumulative counter must not be averaged. See &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;tags&lt;/strong&gt; are free-form and exist to hold things not yet formalised. One governance rule, enforced strictly: &lt;strong&gt;tags are never load-bearing.&lt;/strong&gt; No rule, no unit conversion, no aggregation may branch on a tag. The moment one needs to, the tag is promoted to a field. Without that rule, tags become a shadow schema within six months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vocabularies are closed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;enum_type&lt;/code&gt;, &lt;code&gt;retention_class&lt;/code&gt;, and the modifier value sets live in the registry as versioned tables, not free strings. Adding &lt;code&gt;heatsink&lt;/code&gt; is a deliberate registry change. This is the only thing that prevents accumulating &lt;code&gt;heatsink&lt;/code&gt;, &lt;code&gt;heat_sink&lt;/code&gt;, and &lt;code&gt;heatSink&lt;/code&gt; alongside a rule that silently matches nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enums and codebooks
&lt;/h2&gt;

&lt;p&gt;A point with &lt;code&gt;value_type: enum&lt;/code&gt; references an &lt;code&gt;enum_type&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;enum_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;switch_state&lt;/span&gt;
&lt;span class="na"&gt;members&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;off&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;unknown-position&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drivers map native representations (Modbus &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;1&lt;/code&gt;, Shelly &lt;code&gt;"on"&lt;/code&gt;/&lt;code&gt;"off"&lt;/code&gt;, UniFi status strings) to canonical members. A native value that maps to nothing produces quality &lt;code&gt;bad&lt;/code&gt; with the unmapped value in the detail field. It does not invent a member and does not throw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality
&lt;/h2&gt;

&lt;p&gt;Seven states, plus a machine-readable &lt;code&gt;reason&lt;/code&gt; and free-text &lt;code&gt;detail&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;never observed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;restored&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;loaded from log at startup, not yet reconfirmed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;live&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;observed within its freshness window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;observed, now past its window, cause unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unavailable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;driver or device known-disconnected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bad&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;last attempt gave an invalid or device-reported-error value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;assumed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;written, and unverifiable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;stale&lt;/code&gt; and &lt;code&gt;unavailable&lt;/code&gt; are separate because the difference is actionable: stale means the cause of the silence is unknown, unavailable means it is known. Alerting and rule tolerance differ.&lt;/p&gt;

&lt;p&gt;Two states are not entered by any runtime transition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unknown&lt;/code&gt; is the state a point is in from registration until its first observation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;restored&lt;/code&gt; is entered only at core start, when the checkpointed projection loads. A point checkpointed as &lt;code&gt;unknown&lt;/code&gt; stays &lt;code&gt;unknown&lt;/code&gt;; everything else becomes &lt;code&gt;restored&lt;/code&gt; regardless of what it was.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legal runtime transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown     → live | bad | unavailable | assumed
restored    → live | stale | bad | unavailable | assumed
live        → stale | bad | unavailable | assumed
stale       → live | bad | unavailable | assumed
bad         → live | stale | unavailable | assumed
assumed     → live | stale | bad | unavailable
unavailable → live | bad
*           → unavailable          (driver reports disconnect)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of those need stating explicitly, because they are the ones an implementation gets wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything readable can become &lt;code&gt;assumed&lt;/code&gt;.&lt;/strong&gt; A write with &lt;code&gt;confirmation_mode: optimistic&lt;/code&gt; asserts a value irrespective of what the point held before, so &lt;code&gt;unknown → assumed&lt;/code&gt; (a cold-boot relay with &lt;code&gt;init_strategy: assert(v)&lt;/code&gt;), &lt;code&gt;restored → assumed&lt;/code&gt;, &lt;code&gt;stale → assumed&lt;/code&gt; and &lt;code&gt;bad → assumed&lt;/code&gt; are all reachable. The prior state has no bearing on it; that is exactly what &lt;code&gt;assumed&lt;/code&gt; means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;unavailable&lt;/code&gt; has only two exits, and both require real data.&lt;/strong&gt; A point known to be off the network is never promoted synthetically, so there is no &lt;code&gt;unavailable → stale&lt;/code&gt; and no &lt;code&gt;unavailable → live&lt;/code&gt; by timer expiry. It leaves only when an observation arrives. There is deliberately no &lt;code&gt;unavailable → assumed&lt;/code&gt; either: writing to a device known to be disconnected must not manufacture confidence in the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No transition returns to &lt;code&gt;unknown&lt;/code&gt; or &lt;code&gt;restored&lt;/code&gt;.&lt;/strong&gt; Both are startup states. A point that has been observed once has been observed forever, and &lt;code&gt;restored&lt;/code&gt; is a claim about a particular process start.&lt;/p&gt;

&lt;p&gt;The rule that matters most: &lt;strong&gt;when quality is not &lt;code&gt;live&lt;/code&gt;, the value field still holds the last known value.&lt;/strong&gt; It is never nulled. Consumers receive &lt;code&gt;(value, quality, observed_at)&lt;/code&gt; and decide for themselves. Clearing the value on staleness destroys information and reintroduces the null-means-three-things problem this model exists to escape.&lt;/p&gt;

&lt;p&gt;Drivers may only assert &lt;code&gt;live&lt;/code&gt;, &lt;code&gt;bad&lt;/code&gt;, or &lt;code&gt;unavailable&lt;/code&gt;. Everything else is the core's to derive. This keeps the driver contract small and prevents a badly-behaved driver from declaring things it cannot know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time
&lt;/h2&gt;

&lt;p&gt;Three timestamps. Collapsing them to two destroys information that later mechanisms depend on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;device_time&lt;/code&gt; — nullable, untrusted. Devices without an RTC will lie. Used for analysis only, and flagged when skew against &lt;code&gt;observed_at&lt;/code&gt; exceeds a threshold.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;observed_at&lt;/code&gt; — when the driver saw it. &lt;strong&gt;This is what freshness is computed against.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ingested_at&lt;/code&gt; — when the bus accepted the observation. This is what the log orders by.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ingested_at&lt;/code&gt; is taken from the bus message itself, not from the core's clock at the moment of handling. It is part of the dedup key, so it must be identical on every redelivery of the same message; the reasoning is in &lt;em&gt;Ingest Path &amp;amp; Log Schema&lt;/em&gt; and it is not safe to change here in isolation. The practical reading is that it means "when this observation became durable in the system", which is also the more useful of the two candidate meanings.&lt;/p&gt;

&lt;p&gt;The second and third diverge exactly when a driver host buffers through a network partition and then floods. Any host on a wireless link or a segment that drops makes this routine rather than exceptional, and BLE and radio hosts are the obvious candidates. Collapsed together, a burst of buffered readings all look fresh at the moment they land.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;source_seq&lt;/code&gt;, monotonic per driver instance, exists for dedup and out-of-order detection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change vs refresh
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;change_policy&lt;/code&gt; (&lt;code&gt;any&lt;/code&gt; | &lt;code&gt;absolute(delta)&lt;/code&gt; | &lt;code&gt;relative(pct)&lt;/code&gt; | &lt;code&gt;none&lt;/code&gt;) belongs in the semantic model because it determines what the rules engine sees as an edge.&lt;/p&gt;

&lt;p&gt;Every observation refreshes freshness. Only observations that clear the change policy emit a &lt;code&gt;value_changed&lt;/code&gt; event. Without the deadband, a noisy analogue sensor generates an edge per sample and the rules engine spends its life on 0.01 °C.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;changed&lt;/code&gt; flag this produces has a second life in storage: it is what state-class compaction keeps and what it drops. See &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The observation envelope
&lt;/h2&gt;

&lt;p&gt;What a driver publishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;point&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;native&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;unit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;native&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;quality&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;live&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="k"&gt;bad&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="k"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;device&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;time&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;observed&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;driver&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;driver&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;instance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the core adds on ingest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;event&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ingested&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;unit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;prev&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;prev&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;changed&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;per&lt;/span&gt; &lt;span class="k"&gt;change&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second block is the full transition tuple, which is exactly what the rules engine consumes. Edge detection, staleness triggers, and bad-data triggers all read the same record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalisation is a write-time decision
&lt;/h2&gt;

&lt;p&gt;Core-side normalisation plus a log-first core means canonical values are computed once, at ingest, and then frozen in the log. If a wrong &lt;code&gt;unit_native&lt;/code&gt; is corrected in the registry six months later, every historical event stays wrong.&lt;/p&gt;

&lt;p&gt;The fix is cheap now and impossible later: &lt;strong&gt;the log stores native and canonical side by side, plus the registry version that did the conversion.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;native&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;unit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;native&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;unit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;registry&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reprocessing then becomes a real operation — replay the log, reconvert from native under the current registry, rebuild the projection. Without &lt;code&gt;value_native&lt;/code&gt; in the log, a unit error is permanent data loss. This is codified as a &lt;code&gt;reprocess&lt;/code&gt; command from day one rather than a script written in anger later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registry authority and the two failure cases
&lt;/h2&gt;

&lt;p&gt;With drivers dumb, the core needs explicit answers for two things that happen in week one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unregistered point.&lt;/strong&gt; A driver publishes &lt;code&gt;boiler.ch1.return-temp&lt;/code&gt; and the registry has never heard of it. It is neither dropped nor auto-registered. It goes to a quarantine stream with full native payload, raised for triage. Auto-registration guesses at semantics, and a guessed &lt;code&gt;quantity&lt;/code&gt; is worse than no point at all. Quarantine also provides discovery as a side effect: connect a new device, let the driver run, and watch what accumulates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit mismatch.&lt;/strong&gt; Some sources self-describe (Shelly and UniFi JSON carry units), most do not. Where a driver supplies &lt;code&gt;unit_native&lt;/code&gt; and it disagrees with the registry, the observation gets quality &lt;code&gt;bad&lt;/code&gt; with reason &lt;code&gt;unit_mismatch&lt;/code&gt;. Neither claim is converted from — either could be the wrong one, and silently picking is how a heatsink reading of 90 °F becomes a value a rule treats as 90 °C.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quantity table
&lt;/h2&gt;

&lt;p&gt;Pragmatic canonicals. Two columns that are easy to omit and expensive to add later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;quantity&lt;/th&gt;
&lt;th&gt;canonical (UCUM)&lt;/th&gt;
&lt;th&gt;affine&lt;/th&gt;
&lt;th&gt;agg_safe&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;temperature&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Cel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;temperature_delta&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Cel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;relative_humidity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pressure&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kPa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;voltage&lt;/td&gt;
&lt;td&gt;&lt;code&gt;V&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;current&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;power_active&lt;/td&gt;
&lt;td&gt;&lt;code&gt;W&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;power_apparent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;V.A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;power_factor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;frequency&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Hz&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;energy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;W.h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;charge&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A.h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;illuminance&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flow_volumetric&lt;/td&gt;
&lt;td&gt;&lt;code&gt;L/min&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;volume&lt;/td&gt;
&lt;td&gt;&lt;code&gt;L&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;signal_strength&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dBm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sound_level&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;duration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ratio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;count&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;affine&lt;/code&gt; exists because °F → °C needs an offset, not just a scale factor. A multiplicative-only conversion layer is correct for every quantity except temperature, which is the one most likely to be converted. The conversion is &lt;code&gt;value × factor + offset&lt;/code&gt; from the start.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;agg_safe: false&lt;/code&gt; marks logarithmic quantities. The mean of two dBm values is not the dBm of the mean, and a naive moving average over RSSI produces a number that looks plausible and means nothing. Derived points refuse to compute &lt;code&gt;mean&lt;/code&gt; on a non-agg-safe quantity unless the definition explicitly opts into &lt;code&gt;log_mean&lt;/code&gt;, and the same flag governs which functions a rollup may use.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;temperature_delta&lt;/code&gt; as a distinct quantity is the same class of trap. A 5 °C rise is not 41 °F.&lt;/p&gt;

&lt;p&gt;Two notes on the table. &lt;code&gt;pressure&lt;/code&gt; in kPa is awkward for both atmospheric and hydraulic readings, since neither is the natural unit; one quantity handled through &lt;code&gt;display_symbol&lt;/code&gt; is preferred to splitting, and the decision is worth revisiting once real pressure points exist. And &lt;code&gt;quantity&lt;/code&gt; is required for &lt;code&gt;kind: measurement&lt;/code&gt; and null for boolean and enum state points — a &lt;code&gt;state&lt;/code&gt; pseudo-quantity must not be invented to fill the column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subject and subject_ref
&lt;/h2&gt;

&lt;p&gt;Starter vocabulary, flat, to be edited rather than accepted as given:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;air&lt;/code&gt;, &lt;code&gt;water&lt;/code&gt;, &lt;code&gt;surface&lt;/code&gt;, &lt;code&gt;heatsink&lt;/code&gt;, &lt;code&gt;enclosure&lt;/code&gt;, &lt;code&gt;soil&lt;/code&gt;, &lt;code&gt;mains&lt;/code&gt;, &lt;code&gt;circuit&lt;/code&gt;, &lt;code&gt;battery&lt;/code&gt;, &lt;code&gt;core_body&lt;/code&gt;, &lt;code&gt;skin&lt;/code&gt;, &lt;code&gt;ambient&lt;/code&gt;, &lt;code&gt;self&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;self&lt;/code&gt; covers diagnostics — a device reporting its own uptime, free heap, or signal strength. Those are real points with real freshness needs and must not be forced into a physical subject.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subject_ref&lt;/code&gt; format is &lt;code&gt;{kind}:{slug}&lt;/code&gt;, with kinds &lt;code&gt;zone&lt;/code&gt;, &lt;code&gt;device&lt;/code&gt;, &lt;code&gt;circuit&lt;/code&gt;, &lt;code&gt;asset&lt;/code&gt;, &lt;code&gt;person&lt;/code&gt;. The format is validated, the string stored, and nothing resolved. No entity tables until a second consumer needs them.&lt;/p&gt;

&lt;p&gt;The signal that flat subjects have stopped working is a point declared as &lt;code&gt;subject: water&lt;/code&gt; with &lt;code&gt;tags: [flow]&lt;/code&gt; and another with &lt;code&gt;tags: [return]&lt;/code&gt;, and then a rule branching on the tag. That is the tag governance rule firing as designed. When it happens the distinction gets promoted — either to a &lt;code&gt;circuit_position&lt;/code&gt; modifier or to a nested subject — and by then there is real evidence for which.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vocabularies as code
&lt;/h2&gt;

&lt;p&gt;All five closed vocabularies (&lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;enum_type&lt;/code&gt;, &lt;code&gt;retention_class&lt;/code&gt;, modifier value sets) live as versioned files in the repository, loaded into Postgres by a migration. The registry version stamped on every observation refers to this.&lt;/p&gt;

&lt;p&gt;Three checks belong in CI rather than at runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every point's &lt;code&gt;unit_native&lt;/code&gt; is convertible to its quantity's canonical unit&lt;/li&gt;
&lt;li&gt;no point references a vocabulary member that does not exist&lt;/li&gt;
&lt;li&gt;no vocabulary member is removed while points still reference it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last is the migration guard. Renaming &lt;code&gt;heatsink&lt;/code&gt; to &lt;code&gt;heat-sink&lt;/code&gt; fails the build rather than silently orphaning forty points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit criteria
&lt;/h2&gt;

&lt;p&gt;This step is done when a test suite demonstrates that the registry rejects what it is supposed to reject. Not that it accepts good input — that will be obvious — but that bad input fails at load rather than in production.&lt;/p&gt;

&lt;p&gt;A fixture set of deliberately invalid descriptors, each expected to fail with a named reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unit_native&lt;/code&gt; not convertible to its quantity's canonical unit&lt;/li&gt;
&lt;li&gt;a reference to a vocabulary member that does not exist&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;on_change&lt;/code&gt; freshness with neither &lt;code&gt;heartbeat&lt;/code&gt; nor an explicit &lt;code&gt;never&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;retention_class&lt;/code&gt; whose rollup functions cannot apply to the point's &lt;code&gt;value_type&lt;/code&gt; or quantity&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;require_intent: true&lt;/code&gt; with no &lt;code&gt;default_value&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;removing a vocabulary member while points still reference it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus two positive checks that are cheap and catch real errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conversion round-trip against known pairs&lt;/strong&gt;, affine included: °F → °C at 32 and 212, mA → A, W.h at scale. A multiplicative-only conversion layer passes every case except the one that matters, so the affine cases must be in the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The quality transition table exists as a checked enum&lt;/strong&gt;, with a test asserting that no code path can produce a transition outside it. The prose table above is for readers; this is what keeps it true.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;On Monday - the &lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-what-the-spec-left-unsaid/" rel="noopener noreferrer"&gt;Dev Diary&lt;/a&gt;&lt;/strong&gt; will look at what needed to change when the model spec'ed above met reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next part - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-driver-contract/" rel="noopener noreferrer"&gt;The Driver Contract&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Why every driver speaks one northbound transport no matter what protocol it talks to the device in, and why converting a unit is the first item on a list of eight things a driver is forbidden to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start of the series: &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-introduction/" rel="noopener noreferrer"&gt;An Introduction&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The map, the two decisions every later document is downstream of, and why a specification at this scale is being published in public while the system it describes gets built.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Sentinel Dev Diary: Four Registers and a Drift Check</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Tue, 08 Sep 2026 10:21:49 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-dev-diary-four-registers-and-a-drift-check-501l</link>
      <guid>https://dev.to/virorum/sentinel-dev-diary-four-registers-and-a-drift-check-501l</guid>
      <description>&lt;p&gt;The Sentinel specification is ten documents, and it was finished before a line of the system was written. That was the whole idea: decide what the thing is while deciding is cheap, then build it.&lt;/p&gt;

&lt;p&gt;Changes to the spec were anticipated. I have a series of Dev Diary look-backs planned that address exactly this set of deltas starting next week. But barely had the first lines of code been written before a wholly different gap surfaced: registers!&lt;/p&gt;

&lt;p&gt;Correcting specifications, recording decisions, creating issues to come back to things later, hand-written notes...these are all fine in their own right, but they rot so fast... This is where registers earn their keep from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two false starts
&lt;/h2&gt;

&lt;p&gt;The registers were the third arrangement, not the first. Let me be honest about that up front. First the story of the short-shelf-life solutions I mentioned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage one: PR bodies.&lt;/strong&gt; Anything getting deferred, assumed or decided-in-passing was written in the pull request. This is a natural default and it is not nothing; it is at least written down, next to the diff that motivated it. Where it fails is on retrieval and discovery. Nobody greps forty merged PRs to find out whether a thing was deliberate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage two: one file.&lt;/strong&gt; So it went into &lt;code&gt;docs/deferred.md&lt;/code&gt;: blocked work, overdue work, measurements, and findings about the specification, all in one place. Wonderfully arranged by category with notes and even some commit hashes. Better. It lasted... hmm... a few days if I am generous. Why?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It was carrying four different kinds of statement in one file. Because its content was sourced from PRs merged, it organised along similar lines and mixed findings and deferrals into one because it lacked a set of organising principles to keep it disciplined. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A file that holds four kinds of thing has no rule for what belongs in it, so it also has no rule for what has fallen out of it. Nothing was lost dramatically. Things were lost quietly, which is worse, because a register you have stopped trusting is one you stop reading.&lt;/p&gt;

&lt;p&gt;And it had a twin. The driver-host repository had grown its own &lt;code&gt;docs/deferred.md&lt;/code&gt;, on the same convention and for the same reasons, so the same mistake was now being made twice, in two repositories, about one specification. It was time to do things properly. Enter, the Registers...&lt;/p&gt;

&lt;p&gt;Any deferral, every decision or a freshly discovered risk goes in the register that covers the specification, naming the repository it belongs to. Numbered, immutably and durably, and referenced. Importantly, never repeated elsewhere - when the temptation to do so arises, squash it, and link back to the register that owns it instead.&lt;/p&gt;

&lt;p&gt;One set of registers spans both repositories, deliberately. Two registers on one specification is two places to look and one to forget, and the seam between two repositories is exactly where a row goes missing; build step three is split across both, so its deferrals were too. Code does not move; documentation does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four registers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docs/
  spec/              ← normative. Ten documents. Nothing outside this defines anything.
  REQUIREMENTS.md    ←  SR-*  what the system must do, grouped by owning document
                        CN-*  the constraints that may never be violated
  COMPONENTS.md      ←  C-*   components, each with a build status and its package
                        I-*   integration contracts
  DECISIONS.md       ←  D-*   every choice taken where the spec was silent, superseded ones kept
  ROADMAP.md         ←  CP-*  checkpoints: evidence that does not exist yet
                        A-*   assumptions
                        R-*   risks
                        OI-*  open questions - gaps in the specification
                        DW-*  deferred work - code that is owed
  spec-deltas/       ← one audit per completed build step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything except &lt;code&gt;spec/&lt;/code&gt; is derivative. That word matters rather more than it looks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nothing in a register defines anything. They point at &lt;code&gt;docs/spec/&lt;/code&gt;, which is normative; a register that restates a mechanism has created a second home for it, which is the failure the specification exists to prevent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The registers number what the specification already says, so that a component, a commit, an Issue or a review can cite it. &lt;code&gt;SR-14&lt;/code&gt; is not a requirement written in &lt;code&gt;REQUIREMENTS.md&lt;/code&gt;; it is a pointer with a stable handle, and the requirement itself lives in the document that owns the mechanism. Where the two disagree, the row is the bug. Always the row.&lt;/p&gt;

&lt;p&gt;Then a precedence order, because two documents will disagree eventually and I would rather settle it now than in the moment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On what a mechanism is, or what a term means - the owning specification document wins, always.&lt;/li&gt;
&lt;li&gt;On why a decision was taken where the specification is silent - &lt;code&gt;DECISIONS.md&lt;/code&gt; wins.&lt;/li&gt;
&lt;li&gt;On what is built right now - &lt;code&gt;COMPONENTS.md&lt;/code&gt; and &lt;code&gt;ROADMAP.md&lt;/code&gt; win over any document's description of intent.&lt;/li&gt;
&lt;li&gt;On implementable work - the GitHub Issue wins over all of the above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without that written down, every disagreement gets settled by whoever happened to notice it, on the day, in whatever mood they were in. And half of those settlements go the other way next month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction that is actually load-bearing
&lt;/h2&gt;

&lt;p&gt;If you take one thing from any of this: &lt;strong&gt;an open question is not a task, and shipping code does not close one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;OI-*&lt;/code&gt; is a gap in the specification - something that could have been decided and was not. A &lt;code&gt;DW-*&lt;/code&gt; is code that is owed. In the moment they feel identical. They are not, and getting them confused is filed as a risk against my own process:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;R-2&lt;/strong&gt; - A specification gap is "closed" by the commit that routes around it. The work that reveals a gap is almost always the work that works around it, so a finding filed as a task is struck by the commit that proves it real.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is exactly how it goes. You are building, you find the document never said which way a case falls, you pick a reading, you ship. If that finding was filed as a task, the task is now done. The specification is still silent - and nobody will ever notice, because the code works. That last part is the one that gets you.&lt;/p&gt;

&lt;p&gt;So an &lt;code&gt;OI-*&lt;/code&gt; is discharged by amending the owning document and by nothing else. Where a gap was routed around in code, the reading taken gets recorded as a &lt;code&gt;D-*&lt;/code&gt; and &lt;strong&gt;the open question stays open, pointing at it&lt;/strong&gt;. Two rows, two lifecycles. One says what the code does; the other says the document never said.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snippets from the first three steps
&lt;/h2&gt;

&lt;p&gt;Some of what actually landed in the registers as the build got going, quoted as written. The shape of the thing rather than the content of it.&lt;/p&gt;

&lt;p&gt;The register vocabulary managed to break the specification's own citation rule, more or less immediately:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;OI-1&lt;/strong&gt; - &lt;em&gt;Introduction&lt;/em&gt;, &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;. &lt;strong&gt;There is no build step that owns retention policy values.&lt;/strong&gt; In the absence of one, this repo invented &lt;strong&gt;"step 10"&lt;/strong&gt; - the document's reading-order number wearing a step's clothes - and it has spread into the register, the self-point catalogue test's deferral map and several docstrings. It also directly contradicts &lt;em&gt;Introduction&lt;/em&gt;'s rule that nothing cites a document by number.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is why every specification document here is cited by title, in italics, and never by number or path. The rule pre-dates the incident. The incident is what made it enforceable, because there is now a row explaining precisely what happens when it lapses.&lt;/p&gt;

&lt;p&gt;The constraints had the opposite problem. Real, load-bearing, and entirely uncitable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;OI-17&lt;/strong&gt; - &lt;em&gt;Introduction&lt;/em&gt;. &lt;strong&gt;The constraints have no numbered home in the document that states them.&lt;/strong&gt; &lt;em&gt;Introduction&lt;/em&gt; carries twenty-two Conventions - the properties everything else is measured against, each extracted from a specific error - in a document that says of itself "this note is the map and the rationale. It defines nothing." A definition found there is stated to be a bug in the note, and yet these are the closest thing the system has to invariants. They are also uncitable: every reference to one quotes its prose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are indexed as &lt;code&gt;CN-1&lt;/code&gt; to &lt;code&gt;CN-22&lt;/code&gt; now, which is an index and not a copy. The argument for each one stays exactly where it was.&lt;/p&gt;

&lt;p&gt;An assumption is a different animal again, because work is already resting on it before anyone thinks to check:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A-1&lt;/strong&gt; - The test environment has TimescaleDB, not plain Postgres, and NATS started with JetStream. &lt;strong&gt;If it is wrong:&lt;/strong&gt; ~95 tests &lt;strong&gt;skip&lt;/strong&gt; rather than fail and the suite reports green; the tell is the skip count and coverage near 80% rather than ~97%. &lt;strong&gt;Tested by:&lt;/strong&gt; every run - a correct one has 0 skipped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A green suite that is green because a hundred tests did not run is the most expensive kind of wrong. And it is invisible unless somebody wrote down what the number is supposed to be.&lt;/p&gt;

&lt;p&gt;A good many decisions turn out not to be about the system at all, but about the ground it is standing on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;D-07 - Enum members are quoted in YAML.&lt;/strong&gt; YAML 1.1 resolves bare &lt;code&gt;on&lt;/code&gt;, &lt;code&gt;off&lt;/code&gt;, &lt;code&gt;yes&lt;/code&gt; and &lt;code&gt;no&lt;/code&gt; to booleans, which would have turned the &lt;code&gt;switch_state&lt;/code&gt; codebook into &lt;code&gt;[true, false]&lt;/code&gt; silently. Not a specification matter - a trap in the serialisation format the specification chose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No specification was ever going to catch that one. It is the format's trap rather than the design's, and without the codebook check it would have been found eventually by something behaving very strangely at runtime.&lt;/p&gt;

&lt;p&gt;And occasionally a decision is really a finding about the tests:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;D-14 - A guard with no caller is not a guard.&lt;/strong&gt; The check passed its fixture and neither real call site supplied a baseline, so the operation it was meant to refuse failed with a different error, from a different check, that happens to look like the guard working.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one is worth considerably more than its two lines. A test that exercises a function directly, plus a codebase where nothing calls that function in anger, gives you a green build and an unarmed guard - and the failure presents as the guard working correctly. I have no idea how many more of those there are. Presumably not zero.&lt;/p&gt;

&lt;p&gt;Then the category for numbers that do not exist yet. A checkpoint says what produces it, what it must yield, what it unblocks, and one more field that is the whole point of the exercise:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CP-1 - Ingest throughput against a populated database.&lt;/strong&gt; &lt;strong&gt;Current value:&lt;/strong&gt; 4,369 obs/s, taken 2026-08-31 against freshly-truncated hypertables with the three aggregates defined and no refresh policy. Steady state is 220/s, so utilisation ≈ 5%. &lt;strong&gt;Do not pre-decide:&lt;/strong&gt; that the margin holds. Re-taking can only move the figure down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Do not pre-decide&lt;/em&gt; is the field that earns the register its keep. A number measured under favourable conditions reads as headroom, and the caveat only survives if it travels in the same row as the figure. That is &lt;code&gt;R-3&lt;/code&gt; - and &lt;code&gt;R-3&lt;/code&gt; is retired by re-taking the measurement, not by anyone deciding it is probably fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The status marker that lies
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;COMPONENTS.md&lt;/code&gt; and &lt;code&gt;ROADMAP.md&lt;/code&gt; mark every entry built, partial, or not started. Partial is the interesting one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A 🟡 is a claim about what is missing, not only about what is there. It is the marker most likely to be wrong, because it is the one that requires someone to have looked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So partial is only legal when what is missing is enumerated, with a deferred-work row behind each gap. A 🟡 with one named gap and three real ones is worse than an honest "not started"; it has spent the credibility of a status marker to buy an inaccuracy.&lt;/p&gt;

&lt;p&gt;Which is where the list of things reviewers are asked to attack begins: any 🟢 that is really a 🟡, and any 🟡 whose account of what is missing looks suspiciously short.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drift check
&lt;/h2&gt;

&lt;p&gt;None of this stays true on its own. Registers rot exactly like the notes they replaced, unless something makes them false loudly. So there is a ritual, and it does not vary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At the end of each build step:&lt;/strong&gt; enumerate the owning document's exit criteria and map each one to a test. A criterion with no test is a finding, not something to quietly fix - all three audits so far have turned one up. Then audit the code against the documents rather than against the commit history, because the commits record what I intended and only the code records what I did. Write it up as &lt;code&gt;spec-deltas/build-step-N.md&lt;/code&gt;, in two halves: what changed during implementation, and what the step did not deliver. Then route every finding into exactly one register, which is the part that actually matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On each pull request:&lt;/strong&gt; if it defers something, the &lt;code&gt;DW-*&lt;/code&gt; row lands in the same commit. If it adds a mechanism, that mechanism's signals go into the observability document in the same commit. That rule exists in writing because I broke it once.&lt;/p&gt;

&lt;p&gt;The audits keep earning their place. The third one found a paragraph in a register asserting the opposite of what the code did - not drift between the code and the specification, but drift between the register and reality, in a document whose entire job is to describe reality. Humbling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape, specifically
&lt;/h2&gt;

&lt;p&gt;Sentinel is built almost entirely with coding agents, and that has shaped the registers more than anything else here has.&lt;/p&gt;

&lt;p&gt;An agent opening a session has read the repository and none of my recollection. It cannot know that a field is mandatory because of an argument three weeks ago, or that a number is provisional, or that a gap in a document was spotted and deliberately left open. Every one of those is something I would have supplied from memory without noticing - and, if I am honest, supplied differently on different days.&lt;/p&gt;

&lt;p&gt;So the project's &lt;code&gt;CLAUDE.md&lt;/code&gt; does not explain the registers. It points at them, and says to read the index before adding to any of them. What that buys is that a decision can be &lt;em&gt;cited&lt;/em&gt; rather than re-derived. A decision that gets re-derived every time gets decided differently at least once.&lt;/p&gt;

&lt;p&gt;Superseded decisions stay for the same reason:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A decision that was overridden is more useful than one that was erased, because the override is itself evidence about what the design gets wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the registers are themselves under test. &lt;code&gt;test_register_integrity.py&lt;/code&gt; asserts that ids are contiguous and unduplicated, and that a requirement is never both a bare row and a set of sub-ids. That is the specification's own convention - every claim a document makes has a test that would fail if it stopped being true - turned around and pointed back at the process documents. It felt faintly ridiculous to write. It has caught things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still unowned
&lt;/h2&gt;

&lt;p&gt;One gap is worth ending on, because it is open and I do not have an answer for it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;OI-7&lt;/strong&gt; - &lt;em&gt;Introduction&lt;/em&gt;. &lt;strong&gt;Nothing reads the documents against each other.&lt;/strong&gt; All three cross-document gaps found so far were found by building rather than by reading, and each sat between two documents that were each internally consistent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The single-owning-document discipline optimises for consistency &lt;em&gt;within&lt;/em&gt; a document, and it works. The price is that the seams between documents are where the gaps actually are, and nothing currently looks there. The drift check runs per build step, so every seam does get inspected eventually - by the build, at the point where it costs the most to find.&lt;/p&gt;

&lt;p&gt;A real hole in the process, then. Filed as an open question, in the register whose job is to hold the questions the process cannot yet answer. Which is at least the machine working as designed.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-semantic-model/" rel="noopener noreferrer"&gt;The Semantic Model&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Part two of the specification proper: why the primary key for every point in this system is a dotted string you can paste into a grep, and never a UUID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next entry - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-what-the-spec-left-unsaid/" rel="noopener noreferrer"&gt;What the Specification Left Unsaid&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; What the first audit found in a step that had already been called complete: six named rejections that had become twenty-eight, and a guard that existed as a function with a fixture and no caller.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>buildinpublic</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Sentinel: An Introduction</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 02 Sep 2026 09:22:19 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-an-introduction-2ldd</link>
      <guid>https://dev.to/virorum/sentinel-an-introduction-2ldd</guid>
      <description>&lt;p&gt;import PlaneArchitecture from '../../assets/sentinel-plane-architecture.svg';&lt;/p&gt;

&lt;p&gt;Does it matter if a soil moisture sensor goes quiet for three days?&lt;/p&gt;

&lt;p&gt;On its own, probably not. But if an irrigation decision is derived from that reading, three days of silence is three days of watering against a number that stopped being true, and nothing in the system said so.&lt;/p&gt;

&lt;p&gt;Move up the scale. Indoor air quality is degrading because the air conditioning has stopped working — not thrown an error, stopped, which from the outside looks identical to an air conditioner with nothing to do. Move up again. A battery bank charging off solar, in an enclosed space, begins to climb in temperature. Do you want to know about that now, or the next time you happen to look at a dashboard?&lt;/p&gt;

&lt;p&gt;Those three questions have the same shape but completely different answers and priorities. What separates them is not technical, it's a judgement about what matters, how much, and how quickly. That call gets made somewhere regardless. Written down, it is a specification. Left unwritten, it is whatever the code or user happened to do.&lt;/p&gt;


&lt;p&gt;&lt;strong&gt;Problem Statement:&lt;/strong&gt; Underneath all three questions is the same problem: a system that cannot tell a reading from a memory of one. A value that arrived three days ago still presents as a value, a device that has stopped reporting looks like a device with nothing to report, and a command that was sent looks like a command that worked — none of it announcing itself, all of it producing plausible numbers and unbroken graphs right up until a decision gets made against something that stopped being true. Sentinel exists to make that difference explicit at every hop, so that acting on a number the system cannot vouch for is a choice rather than an accident.&lt;/p&gt;

&lt;p&gt;There is an obvious objection, which is that this is a solved problem and you can buy it. However, I have spent a long time cajoling systems and their processes to fit around real world situations they were not designed to address — reframing the problem until it fitted the tool. That works until the reshaping costs more than the tool saves, and at that point it is worth exploring something with the flexibility those use-cases actually need.&lt;/p&gt;

&lt;p&gt;So this design is written down, and in public.&lt;/p&gt;

&lt;p&gt;A specification at this scale is not trivially derived from a set of working assumptions; the assumptions are the easy part, and they usually create a false sense of simplicity that unravels the moment you think the work is finished.&lt;/p&gt;

&lt;p&gt;Publishing holds me to the choices I made during the design. Not as choices I must live with, but ones that I must litigate clearly when I adopt or change them. What I decided, what I claimed they would achieve, and what had to change when the design met the thing it describes.&lt;/p&gt;

&lt;p&gt;That pressure is the same in a one-maintainer repository as it is in a corporate monorepo, and so are the failure modes. Arguing the changes in public makes each one a worked example rather than a commit message.&lt;/p&gt;

&lt;p&gt;The spec is here to be read and argued with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Sentinel?
&lt;/h2&gt;

&lt;p&gt;A platform for monitoring and controlling devices across several network segments: collecting readings over mixed protocols, normalising them into a single semantic model, deriving values from them, evaluating automation rules, and driving physical outputs with verification and safety interlocks.&lt;/p&gt;

&lt;p&gt;The name is the job. A sentinel stands watch, knows the difference between quiet and absent, and raises an alarm rather than acting on a guess. Two properties of this design follow that description more closely than they follow any conventional monitoring platform: data quality is a first-class field, so the system is explicit about what it does not know; and absence of data is a signal in its own right rather than a gap to be interpolated over.&lt;/p&gt;

&lt;p&gt;It is written for a system built and operated by one person, and several decisions follow from that constraint rather than from first principles. Where they do, the constraint is stated alongside the decision.&lt;/p&gt;

&lt;p&gt;Components take the system name as a prefix: &lt;code&gt;sentinel-core&lt;/code&gt;, &lt;code&gt;sentinel-driver-modbus&lt;/code&gt;, &lt;code&gt;sentinel-registry&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this series is organised
&lt;/h2&gt;

&lt;p&gt;Eleven parts, published weekly. This one is the map and the rationale for Sentinel's design; the ten that follow are the documents that define it.&lt;/p&gt;

&lt;p&gt;Every mechanism in this system has exactly one owning document. Where this part mentions a state set, a policy name, or a lifecycle, it names it only to say where it is defined.&lt;/p&gt;

&lt;p&gt;Each part is published as it was drafted, rather than as a tidied-up retrospective. The system is being built while the series runs, so where the plan meets reality and loses, a follow-up says what changed and why. Three figures in the design are unmeasured today — sustainable ingest throughput, cold-start time, and the retention policy values — and all three get measured during the build, inside the window this series runs in. You will see the numbers when I do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-semantic-model/" rel="noopener noreferrer"&gt;The Semantic Model&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-driver-contract/" rel="noopener noreferrer"&gt;Driver Contract&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-ingest-path-and-log-schema/" rel="noopener noreferrer"&gt;Ingest Path &amp;amp; Log Schema&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Watchdog &amp;amp; Derived Points&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Command Plane&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Rules Engine&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Core Runtime&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Security Model&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Observability&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Core Runtime&lt;/em&gt;, &lt;em&gt;Security Model&lt;/em&gt; and &lt;em&gt;Observability&lt;/em&gt; come after the mechanisms they constrain: what enforces single-instance operation and what the system is sized for, which boundary carries the authorisation weight, and what signals every mechanism must expose.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt; reads last because it uses descriptor fields defined across four earlier documents, but it is a mechanism rather than a policy appendix — it constrains the log schema, the derived-point layer and the reprocess operation, and part of it cannot be deferred without a data migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope
&lt;/h2&gt;

&lt;p&gt;These ten documents cover the core and the data pipeline: collection, ingest, state, derivation, automation, control, runtime, security, instrumentation and storage lifecycle.&lt;/p&gt;

&lt;p&gt;Two things have been deliberately left out, because neither changes anything above: &lt;strong&gt;alert routing and notification delivery&lt;/strong&gt;, and the &lt;strong&gt;read API and UI&lt;/strong&gt;. &lt;em&gt;Observability&lt;/em&gt; says what must be watchable and what should raise an alarm; but &lt;em&gt;how&lt;/em&gt; an alarm reaches a person is not specified here. &lt;em&gt;Rules Engine&lt;/em&gt; names actions with a channel; the channel model is not specified here either.&lt;/p&gt;

&lt;p&gt;The one constraint those inherit: alerting runs outside the core, because alerting inside the core cannot report the core's absence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two decisions that shape everything else
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Log-first core.&lt;/strong&gt; Current state is a projection of an append-only event log, never the primary artefact. Every observation, quality transition, command and command outcome is an appended event. Restart is snapshot plus replay of the tail. This is the whole answer to restarting without losing state, and retrofitting it later is painful, so it goes in first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Hard driver/core boundary.&lt;/strong&gt; The core has zero protocol knowledge. Drivers have zero knowledge of rules, units or storage. The boundary is exactly two contracts: a normalised observation flowing north, and a command envelope flowing south. The failure this prevents is an observability or collection concern leaking outward into the layers around it, so that every part of the system ends up coupled to whichever part was built first. Making the boundary a wire protocol between processes means it cannot be casually violated.&lt;/p&gt;

&lt;p&gt;Everything else in these documents is downstream of those two choices, and most of it follows fairly directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-goals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This is a system built from scratch, with no migration path in or out.&lt;/strong&gt; No document should acquire a migration section, a dual-run plan or a compatibility shim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple concurrent cores are not a goal.&lt;/strong&gt; Exactly one core instance runs at a time, enforced rather than assumed. The reasoning, the enforcement mechanism, and the two conditions that would reopen the question are in &lt;em&gt;Core Runtime&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keeping raw data indefinitely is not a goal.&lt;/strong&gt; Nothing is retained in original sample detail forever; measurements are rolled up and their samples dropped. State transitions are a different thing and are kept — the distinction is samples versus history, and it is in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second storage tier is not a goal.&lt;/strong&gt; No object-store archive, no cold tier. Data not worth keeping in Postgres is not worth keeping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing here describes existing infrastructure.&lt;/strong&gt; These documents specify what to build. Where they name a protocol, a device or a deployment shape, that is a design target rather than an inventory, and no document should assert that any component currently exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The planes
&lt;/h2&gt;



&lt;p&gt;The diagram is illustrative. This section is the normative description of the decomposition; where the two disagree, this text wins and the diagram should be corrected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Devices&lt;/strong&gt; are anything that produces readings or accepts writes, over any protocol, push or poll.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Driver plane.&lt;/strong&gt; Protocol adapters, deployed as driver hosts, one per network segment, each supervising per-device driver instances. Drivers translate a protocol into observations and attempt writes. They hold no semantics, no verification state and no automation. Defined in &lt;em&gt;Driver Contract&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bus.&lt;/strong&gt; Durable subject streams carrying observations, availability, quarantine, commands and command results. Every driver-plane subject leads with the host, which is what makes per-host credentials workable. The observation stream is the system's outage buffer, bounded by size rather than age and sized from tolerable outage and utilisation — see &lt;em&gt;Core Runtime&lt;/em&gt;. Subject layout is defined in &lt;em&gt;Driver Contract&lt;/em&gt;; it is also the primary authorisation surface, so the two are designed together — see &lt;em&gt;Security Model&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core.&lt;/strong&gt; A single process — see &lt;em&gt;Core Runtime&lt;/em&gt; — containing four stages that are logically distinct and physically together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Normalise&lt;/em&gt; — validate, deduplicate, resolve against the registry, check the publisher, convert to canonical units, evaluate the deadband. Defined in &lt;em&gt;Ingest Path &amp;amp; Log Schema&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Event log&lt;/em&gt; — the durable append-only record, split by volume, retention class and query shape. Schema in &lt;em&gt;Ingest Path &amp;amp; Log Schema&lt;/em&gt;; the class split in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;State and registry&lt;/em&gt; — the current projection, plus the versioned registry that says what every point means. Projection defined in &lt;em&gt;Ingest Path &amp;amp; Log Schema&lt;/em&gt;; registry content defined in &lt;em&gt;The Semantic Model&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Derivation and watchdog&lt;/em&gt; — freshness deadlines and derived points, both of which emit into the same pipeline as device data. Defined in &lt;em&gt;Watchdog &amp;amp; Derived Points&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The diagram places derived points and timers inside the rules engine box. That is an over-simplification, and it is the one worth flagging, because it survived several drafts before the distinction it blurs turned out to matter. Derived points are ordinary points, computed before rules evaluate; the watchdog that ages them is not part of the engine at all. That separation is exactly what keeps the rules engine small, and it is argued properly in &lt;em&gt;Watchdog &amp;amp; Derived Points&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules engine.&lt;/strong&gt; Evaluates predicates over transitions and produces claims. It sends no commands, holds no state between evaluations, and has no visibility into whether a claim reached hardware. Defined in &lt;em&gt;Rules Engine&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command plane.&lt;/strong&gt; Owns the durable claim set and its resolution into a desired value, deliverability and reconciliation of reported against desired, confirmation and verification, and the safety and interlock model. Returns to the bus as commands to driver instances. Defined in &lt;em&gt;Command Plane&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The single closed loop is: device → driver → bus → core → rules → command plane → bus → driver → device, with the log recording every hop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale
&lt;/h2&gt;

&lt;p&gt;The design targets, stated once here and derived from in &lt;em&gt;Core Runtime&lt;/em&gt;: 200+ devices across 16+ network segments, 2,000+ device timeseries points, 1,000+ derived points, and roughly 700 self-points that fall out of instrumenting the rest — about &lt;strong&gt;4,000 points&lt;/strong&gt; in total, at &lt;strong&gt;~220 observations per second&lt;/strong&gt;, designed for 500.&lt;/p&gt;

&lt;p&gt;Two figures matter more than their size suggests. Self-points are a fifth of the registry and are easy to omit when sizing anything. And sustainable ingest throughput is the one term still unmeasured, which makes it the term everything else waits on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where things are defined
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Owning document&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Point identity, hierarchy, descriptor fields&lt;/td&gt;
&lt;td&gt;The Semantic Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed vocabularies: quantity, subject, enum types, retention class, modifiers&lt;/td&gt;
&lt;td&gt;The Semantic Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Units, canonicalisation, affine conversion, aggregation safety&lt;/td&gt;
&lt;td&gt;The Semantic Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality states and legal transitions&lt;/td&gt;
&lt;td&gt;The Semantic Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timestamps and sequence numbers&lt;/td&gt;
&lt;td&gt;The Semantic Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observation envelope&lt;/td&gt;
&lt;td&gt;The Semantic Model (shape), Driver Contract (driver-supplied half)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry authority, quarantine and unit-mismatch handling&lt;/td&gt;
&lt;td&gt;The Semantic Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver host and instance model, lifecycle, assignments&lt;/td&gt;
&lt;td&gt;Driver Contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bus subjects, stream limits, buffering, availability&lt;/td&gt;
&lt;td&gt;Driver Contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver-side command handling and the prohibitions list&lt;/td&gt;
&lt;td&gt;Driver Contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log schema, hypertable split, ingest pipeline ordering, dedup, publisher check, projection, restart, reprocess&lt;/td&gt;
&lt;td&gt;Ingest Path &amp;amp; Log Schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry versioning and descriptor storage&lt;/td&gt;
&lt;td&gt;Ingest Path &amp;amp; Log Schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freshness policies, watchdog behaviour, staleness suppression, flap damping&lt;/td&gt;
&lt;td&gt;Watchdog &amp;amp; Derived Points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Derived point descriptors, operators, quality propagation, evaluation, rehydration&lt;/td&gt;
&lt;td&gt;Watchdog &amp;amp; Derived Points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricted expression grammar, and its two binding contexts&lt;/td&gt;
&lt;td&gt;Watchdog &amp;amp; Derived Points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claim model, bands, resolution, suspension&lt;/td&gt;
&lt;td&gt;Command Plane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desired/reported, deliverability, convergence, command state machine, confirmation modes&lt;/td&gt;
&lt;td&gt;Command Plane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fail-safe versus fail-clear, interlock constraints&lt;/td&gt;
&lt;td&gt;Command Plane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intent API, validation, rate limits, idempotency, escape hatch&lt;/td&gt;
&lt;td&gt;Command Plane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rule shape, triggers, evaluation, claims, warmup, loop protection&lt;/td&gt;
&lt;td&gt;Rules Engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rule and registry load-time validation, rule testing&lt;/td&gt;
&lt;td&gt;Rules Engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-writer enforcement, core epoch, startup and shutdown ordering&lt;/td&gt;
&lt;td&gt;Core Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale targets, outage figures, buffer sizing, drain behaviour&lt;/td&gt;
&lt;td&gt;Core Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backpressure and ingest lag&lt;/td&gt;
&lt;td&gt;Core Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability while the core is down, restart time, geographic split&lt;/td&gt;
&lt;td&gt;Core Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threat model, principals, credentials, bus permissions&lt;/td&gt;
&lt;td&gt;Security Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human authentication, capabilities, what each principal may do&lt;/td&gt;
&lt;td&gt;Security Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret handling, audit requirements, exposure assumptions&lt;/td&gt;
&lt;td&gt;Security Model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-point catalogue, naming, what raises an alarm&lt;/td&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention classes, policy values, rollup derivation, compaction&lt;/td&gt;
&lt;td&gt;Retention &amp;amp; Compaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exit criteria for each build step&lt;/td&gt;
&lt;td&gt;the document owning that step's mechanism&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Conventions
&lt;/h2&gt;

&lt;p&gt;Most of these were extracted from a specific error rather than asserted as principle, and each names the failure it prevents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One definition per concept.&lt;/strong&gt; Cross-reference rather than restate, including in this note. A concept that appears in two documents has one normative home and one pointer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vocabularies and schemas are code.&lt;/strong&gt; The closed vocabularies, point descriptors and rule definitions live as versioned files in the repository and load into Postgres by migration. Prose in these notes explains them; it does not define them once the code exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State machines are enums with tests.&lt;/strong&gt; Quality transitions, driver lifecycle, command lifecycle, convergence and arbitration bands appear as tables in prose here and must exist as checked enums in code, with a test that no code path produces an illegal transition. Prose tables are for readers and will rot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every claim a document makes has a test that would fail if it stopped being true, and that test lives in the same document.&lt;/strong&gt; Prose cannot fail loudly. The exit criteria at the end of each document are that test set, and they are what says a build step is finished — not a feature checklist, but a demonstration that the step's central property actually holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A boolean that has to carry three meanings is an enum.&lt;/strong&gt; Quality was the first instance and convergence the second. When a field needs to say "not yet", "no", and "not applicable", write the enum the first time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store what composes, not what is convenient to read.&lt;/strong&gt; Native alongside canonical, sum and count rather than average. Anything derived can be recomputed from a composable base; a base that has already been reduced cannot be recovered, and the reduction is usually wrong at the next level up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage optimisations that discard raw rows are not optimisations.&lt;/strong&gt; Derived-point windows and reprocess both read the raw tables, so a row not written is a mechanism quietly broken. Every observation writes a row; volume is managed by retention and compaction, which are deliberate and bounded, rather than by suppression at write time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Behaviour that varies by data type is derived from the descriptor, not authored per point.&lt;/strong&gt; Which rollup functions are valid follows from &lt;code&gt;value_type&lt;/code&gt;, &lt;code&gt;agg_safe&lt;/code&gt; and the aggregation modifier; which retention class applies follows from &lt;code&gt;kind&lt;/code&gt;. Restating what the registry already knows is an invitation to state it wrongly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One number per concept.&lt;/strong&gt; Where an ordering exists, it is expressed once. Bands are an ordered enum and there is no parallel priority integer; the same discipline applies anywhere else a rank appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two numbers that differ by an order of magnitude are two numbers.&lt;/strong&gt; Planned restart and tolerable unplanned outage were briefly treated as one quantity, which would have sized the outage buffer against the restart case. Before reusing a figure in a second place, check that the two cases actually coincide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity comes from the message, not from the moment of handling.&lt;/strong&gt; Anything a component assigns to an event at processing time is different on a retry, and therefore cannot be part of an identity or dedup key. This is why &lt;code&gt;ingested_at&lt;/code&gt; is taken from the bus rather than from the core's clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authority comes from the connection, never from the payload.&lt;/strong&gt; Who a request or a message is from is established by the credential that carried it or the binding it arrived on, never by a field inside it. Owner and band are derived from the authenticated principal; a driver's right to publish for a point is checked against the registry rather than taken from the subject it chose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumptions that several documents rely on are enforced somewhere.&lt;/strong&gt; Single-writer was assumed by five mechanisms before anything checked it. An assumption held by convention is one that gets broken on a quiet afternoon, and the failure modes that follow are usually silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing expires data by age except a retention policy chosen for it.&lt;/strong&gt; Age limits on a buffer delete messages that were never consumed, silently, between components that both believe the handoff succeeded. Buffers are bounded by size and refuse new work when full; age-based deletion is confined to the places where dropping old data is the actual intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Physical layout that varies by policy is decided before there is data.&lt;/strong&gt; Retention attaches to a table rather than to a row, so a per-class policy is a table split, and a table split after the fact rewrites everything ever collected. The values can wait; the shape cannot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An operation that cannot cover its whole scope refuses, rather than covering part of it.&lt;/strong&gt; Reprocess spans retention classes with different horizons, and correcting the reachable two thirds would leave right and wrong data indistinguishable. The scope is validated first, the refusal carries a reason, and narrowing it is a deliberate second command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signal about a component must not depend on that component.&lt;/strong&gt; Core self-points are injected at the projection rather than published through the queue they describe, and alerting runs outside the core so it survives the core being down. A monitor that fails with the thing it monitors is not a monitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never report a fact about the core as a fact about a device.&lt;/strong&gt; Ingest lag would otherwise surface as fleet-wide staleness, and the system would take protective action across the estate because it was slow. Where the cause is internal, the system says so and suppresses the derived signal — staleness and warmup deadlines both pause on the same condition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait on conditions, not on clocks.&lt;/strong&gt; A timeout should mean the thing being waited for failed, not that a precondition had not arrived yet. Where a component waits for another to become ready, it gates on the readiness signal and the wait is unbounded; where the wait is genuinely unsafe to leave open, it escalates rather than expiring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment parameters are read, not assumed.&lt;/strong&gt; Retention windows, intervals and limits set at deployment are checked against configuration at load time by querying them, never by hardcoding the current value. A constant copied into validation code is a constraint that silently stops holding the day the original changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A mechanism does not ship without saying how its failure would be noticed.&lt;/strong&gt; Its signals go in &lt;em&gt;Observability&lt;/em&gt; in the same commit that defines it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything encoded as a string suffix will have to be parsed later.&lt;/strong&gt; This applies to units, phases, aggregation and circuit position. It is promoted to a field instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Semantic model and vocabularies — point identity, quantity, unit, subject, quality, retention class&lt;/li&gt;
&lt;li&gt;Event log, state projection, snapshot and replay, cold start&lt;/li&gt;
&lt;li&gt;Driver contract, plus one push driver and one poll driver&lt;/li&gt;
&lt;li&gt;Freshness watchdog and derived points&lt;/li&gt;
&lt;li&gt;Command plane, including arbitration and the confirmation modes&lt;/li&gt;
&lt;li&gt;Rules engine&lt;/li&gt;
&lt;li&gt;Instrumentation and timeseries export&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 and 2 are the expensive ones to change later. Everything from 3 onwards is additive if those are right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each step is finished when the exit criteria in its owning document pass.&lt;/strong&gt; They live there rather than here, because a criterion is the proof of that document's claim and only stays truthful next to it. A separate test plan would drift within a month, leaving two places describing what ingest guarantees.&lt;/p&gt;

&lt;p&gt;Security is not a step. The subject layout and the publisher check land in step 3, bus credentials with the first driver host, and forward auth with the API when it exists. Deferring it to a step of its own is how it becomes a retrofit.&lt;/p&gt;

&lt;p&gt;Neither is the runtime. The advisory lock and the startup ordering belong in step 2, with the first process that writes; the core epoch lands in step 3 alongside the command envelope.&lt;/p&gt;

&lt;p&gt;Neither is observability. Each step adds its own self-points as it goes, which is also how the ingest path gets exercised before there are devices to exercise it.&lt;/p&gt;

&lt;p&gt;Retention is split across both: the class field, the hypertable split and the ingest routing belong in steps 1 and 2, because they are physical; the policy values and the compaction job follow once volume is measured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingest throughput is measured during step 2&lt;/strong&gt;, against the design rate in &lt;em&gt;Core Runtime&lt;/em&gt;. It is the term the stream sizing and the recovery time both depend on, and it is cheap to measure with synthetic load before any driver exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open items
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sustainable ingest throughput.&lt;/strong&gt; The only unmeasured term in the buffer and recovery arithmetic. Measured on per-observation validation, registry lookup and conversion in application code, which is where the ceiling sits — not on the batch insert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold-start time.&lt;/strong&gt; The planned-restart number, dominated by derived-point rehydration at a thousand-plus derived points. Measured rather than estimated, and kept small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention policy values.&lt;/strong&gt; The classes and the split are decided; the durations are not. They need measured volume, and the driver for measurement raw retention is how long it is plausible to want to reprocess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are measurements rather than decisions, and the first two are taken during step 2.&lt;/p&gt;

&lt;p&gt;Settled: scale targets and the four-hour tolerable outage, both in &lt;em&gt;Core Runtime&lt;/em&gt;. Retention classes and the samples-versus-history distinction, in &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;. Exit criteria, in each owning document.&lt;/p&gt;

&lt;p&gt;Out of scope: alert routing, and the read API and UI.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next Wednesday - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-semantic-model/" rel="noopener noreferrer"&gt;The Semantic Model&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Why the primary key for every point in this system is a dotted string you can paste into a grep, and never a UUID.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>iot</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Notes to Self: The Interview Between an Issue and a Spec</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:25:24 +0000</pubDate>
      <link>https://dev.to/virorum/notes-to-self-the-interview-between-an-issue-and-a-spec-4phc</link>
      <guid>https://dev.to/virorum/notes-to-self-the-interview-between-an-issue-and-a-spec-4phc</guid>
      <description>&lt;p&gt;On 1 August I opened an issue that was three sentences long. A hundred and one minutes later&lt;br&gt;
the feature was merged, and the document that got it there ran to&lt;br&gt;
&lt;a href="https://glitchedpixel.io/specs/feat-0007/" rel="noopener noreferrer"&gt;457 lines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I didn't write those 457 lines. In fact, I didn't &lt;em&gt;have&lt;/em&gt; to write any more documentation, and not because I simply allowed Claude to run amok.&lt;/p&gt;

&lt;p&gt;Here is the issue in full — &lt;code&gt;control-api#265&lt;/code&gt;, 225 characters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;control-api#265 — Manifest-backed dashboard feeds&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;For each dashboard, auto create a manifest keyed by dashboard_id.

For each sensor the dashboard uses, tag it to be included in the manifest.
When a dashboard definition is updated, add / remove tags from sensors accordingly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that genesis moment, this is the lifecycle of the issue all the way through to landing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;14:25&lt;/td&gt;
&lt;td&gt;Issue #265 opened — 225 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14:54&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FEAT-0007&lt;/code&gt; spec committed — 457 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15:35&lt;/td&gt;
&lt;td&gt;Spec merged (PR #266)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15:51&lt;/td&gt;
&lt;td&gt;Implementation committed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16:06&lt;/td&gt;
&lt;td&gt;Implementation merged (PR #267, 15 files), issue closed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting part isn't the speed. It's the step at 14:54 that landed a previously non-existent spec document, and what happened in the twenty-nine minutes before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The issue was never a specification
&lt;/h2&gt;

&lt;p&gt;I often write issues like this one...the way most people write shopping lists. &lt;em&gt;Actuator address is not ensured?&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Baseline the trace correctly.&lt;/em&gt; &lt;em&gt;With the pre-rolls, the frame-rate looks out.&lt;/em&gt; They're abbreviated to&lt;br&gt;
the point of being cryptic to everyone else. I write them this way deliberately: I'm usually mid-something&lt;br&gt;
else when I notice a problem, or have an idea for a better route to the solution. The cost of a full write-up right at that moment would be a fractured sense of flow. As most engineers will tell you, the transitions into and out of flow are the most disruptive parts of their working day.&lt;/p&gt;

&lt;p&gt;This terse form of issue-writing can be all you need, and it's worth being precise about &lt;em&gt;why&lt;/em&gt; it works and the trade-offs it includes. It is &lt;strong&gt;not&lt;/strong&gt; because "the issues are good enough".&lt;/p&gt;

&lt;p&gt;They aren't. When you pick one of these up later, you can reconstruct everything missing from memory.&lt;/p&gt;

&lt;p&gt;In my case with control-api#265, it was a particular panel type I was thinking about, a constraint I hit last week with it, the reason the&lt;br&gt;
obvious approach was off the table, etc... That reconstruction was free and, more importantly, it was&lt;br&gt;
&lt;strong&gt;invisible&lt;/strong&gt; to any external observer. When you are the issue filer and its eventual closer, you never experience this rehydration as filling a gap. You see the issue's sparse wording as an aide-memoire for the fuller context that is still cached in your head.&lt;/p&gt;

&lt;p&gt;What bites here is when you work in a team (of other engineers or coding agents): something has to bridge this gap or the issue will get resolved using a foundation of well intentioned misinterpretation and apparently sensible assumptions.&lt;/p&gt;

&lt;p&gt;An agent has something both better and worse than an engineer's recall — it can read&lt;br&gt;
every file in the repository, including ones you have forgotten, and it can read exactly nothing&lt;br&gt;
of your latent intentions.&lt;/p&gt;

&lt;p&gt;Fortunately, the fix is not to write longer issues. Capture is working; it's the handoff that's broken, the context sync.&lt;/p&gt;
&lt;h2&gt;
  
  
  An agent as interviewer asks for decisions, not descriptions
&lt;/h2&gt;

&lt;p&gt;The obvious version of this step — "the agent asks clarifying questions" — undersells it, and&lt;br&gt;
would be annoying in practice. Being interrogated about your own bug report is worse than&lt;br&gt;
writing the report properly in the first place.&lt;/p&gt;

&lt;p&gt;What makes it worth doing is the ordering. The agent goes and reads first, then comes back&lt;br&gt;
with what it found and asks you to &lt;em&gt;decide&lt;/em&gt; — not to &lt;em&gt;describe&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;You can see the split in the spec that grew out of my dashboard issue. Its reference list is entirely material the&lt;br&gt;
agent uncovered on its own:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FEAT-0007 §2 — References (excerpt)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`app/repositories/sensor_repo.py:123-126`&lt;/span&gt; (&lt;span class="sb"&gt;`get_sensors_with_tag`&lt;/span&gt;, the JSONB
  &lt;span class="sb"&gt;`.contains()`&lt;/span&gt; query pattern this spec's reconciliation reuses)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`app/services/sensor_service.py:42-84,131-174`&lt;/span&gt; (&lt;span class="sb"&gt;`upsert_sensor`&lt;/span&gt; /
  &lt;span class="sb"&gt;`_fan_out_upsert`&lt;/span&gt; — precedent for both the reserved-tag-prefix guard and the
  "best-effort, must never fail the primary write" fan-out posture this spec copies)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody asked me where the JSONB containment query lived, or which existing service had already&lt;br&gt;
settled the question of what a fan-out failure should do to the primary write. Those are&lt;br&gt;
enrichment lookups, and lookups are the agent's half of the job.&lt;/p&gt;

&lt;p&gt;What came back to me as decisions to make were the things no amount of reading resolves: whether one SSE stream per&lt;br&gt;
dashboard was actually the point, whether a second repository was allowed to be changed for&lt;br&gt;
this, where the boundary of the work sat. The spec's &lt;em&gt;Out of Scope&lt;/em&gt; section is five such&lt;br&gt;
rulings written down.&lt;/p&gt;

&lt;p&gt;That's the trade that makes the interview cheap enough to bother with. The expensive half is&lt;br&gt;
already done by the time it reaches you.&lt;/p&gt;
&lt;h2&gt;
  
  
  The best question contradicted the issue
&lt;/h2&gt;

&lt;p&gt;Read my second sentence again: &lt;em&gt;for each sensor the dashboard uses, tag it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's an assumption buried in there — that the control-api server can tell which sensors a dashboard&lt;br&gt;
uses. It can't, and the spec says so at length:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FEAT-0007 — Design Decision (excerpt)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;The obvious-looking alternative — have control-api decode &lt;span class="sb"&gt;`layout`&lt;/span&gt; itself and derive
the sensor set — does not actually work cleanly, for one panel type specifically.
&lt;span class="sb"&gt;`chart`&lt;/span&gt;/&lt;span class="sb"&gt;`gauge`&lt;/span&gt; panels store &lt;span class="sb"&gt;`series[].{deviceId,sensorRef}`&lt;/span&gt; plus a panel-level
&lt;span class="sb"&gt;`dataType`&lt;/span&gt;, which &lt;span class="ge"&gt;*would*&lt;/span&gt; parse cleanly. But a &lt;span class="sb"&gt;`health_stats`&lt;/span&gt; panel stores only
&lt;span class="sb"&gt;`healthStats: {bodyDeviceId?, heartRateDeviceId?}`&lt;/span&gt; (confirmed in
&lt;span class="sb"&gt;`serializeDashboardLayout`&lt;/span&gt;) — its 7 &lt;span class="sb"&gt;`sensor_ref`&lt;/span&gt;s are frontend constants
(&lt;span class="sb"&gt;`BODY_SENSOR_REFS`&lt;/span&gt;, &lt;span class="sb"&gt;`HR_SENSOR_REF`&lt;/span&gt; in &lt;span class="sb"&gt;`useHealthStatsPanel.ts`&lt;/span&gt;), and each role's
&lt;span class="sb"&gt;`data_type`&lt;/span&gt; is discovered at render time from &lt;span class="sb"&gt;`listDeviceSensors`&lt;/span&gt;, not stored anywhere.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I wrote the issue I was picturing chart panels, where my sentence is true. Health-stats&lt;br&gt;
panels store two device IDs and resolve the rest at render time in a &lt;em&gt;different repository&lt;/em&gt;.&lt;br&gt;
A server-side parser would have to duplicate a list of constants that already lives in the frontend,&lt;br&gt;
with no shared source of truth and no error when it happens to drift.&lt;/p&gt;

&lt;p&gt;So the spec proposes the opposite: the client sends an explicit &lt;code&gt;sensors&lt;/code&gt; list alongside the&lt;br&gt;
layout, and the server never parses the layout at all. That decision brings a new API rule&lt;br&gt;
with it — a &lt;code&gt;PATCH&lt;/code&gt; supplying &lt;code&gt;layout&lt;/code&gt; without &lt;code&gt;sensors&lt;/code&gt; is rejected with a &lt;code&gt;422&lt;/code&gt;, so a layout&lt;br&gt;
change can't silently leave the tag set stale.&lt;/p&gt;

&lt;p&gt;None of that is in my three sentences. One line of it directly contradicts them.&lt;/p&gt;

&lt;p&gt;This is the part I want to defend hardest. An interview process that only &lt;em&gt;elaborated&lt;/em&gt; what I wrote would be&lt;br&gt;
formatting. This one went and checked, found my premise didn't hold, and brought back a&lt;br&gt;
reversal — before any code existed to be wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The numbering is the fossil record
&lt;/h2&gt;

&lt;p&gt;Something else survives in the finished document. Its requirements run FR-1 through FR-10 —&lt;br&gt;
but there's also an FR-6a, an FR-10a, an NFR-3a, an AC-8a and an AC-8b.&lt;/p&gt;

&lt;p&gt;A list written straight through numbers 1 to &lt;em&gt;n&lt;/em&gt;. Letters mean insertion into a list that had&lt;br&gt;
already been numbered. Each one marks a place where a later question forced something in&lt;br&gt;
between.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FR-6a&lt;/code&gt; is the one I'd point at. &lt;code&gt;FR-6&lt;/code&gt; had established that dashboard tags are reserved, so a&lt;br&gt;
caller's tag edit must preserve them. Then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FEAT-0007 — FR-6a (excerpt)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;The prior-tags read [...] is already wrapped in &lt;span class="sb"&gt;`try/except Exception:
logfire.exception(...)`&lt;/span&gt; so it can never break the upsert — but under FR-6, silently
treating a failed read as &lt;span class="sb"&gt;`old_tags = []`&lt;/span&gt; would mean &lt;span class="sb"&gt;`preserved`&lt;/span&gt; comes back empty and
the caller's list wins outright, wiping every &lt;span class="sb"&gt;`dashboard-*`&lt;/span&gt; tag on that sensor: the
exact outcome FR-6 exists to prevent.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An existing &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;except&lt;/code&gt; in unrelated code turns the new requirement into precisely the&lt;br&gt;
failure it was written to stop. Follow FR-6 faithfully and you ship the bug. That is not a&lt;br&gt;
question anyone thinks to ask about a three-sentence issue, and it was answered in a spec document&lt;br&gt;
before any code was written rather than in a post-mortem.&lt;/p&gt;

&lt;p&gt;The whole document is published alongside this article — &lt;a href="https://glitchedpixel.io/specs/feat-0007/" rel="noopener noreferrer"&gt;FEAT-0007: Manifest-Backed Dashboard&lt;br&gt;
Feeds&lt;/a&gt; — so the excerpts above can be read in context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the spec bought afterwards
&lt;/h2&gt;

&lt;p&gt;Implementation merged thirty-one minutes after the spec did, across 15 files. What I want to&lt;br&gt;
draw out isn't the speed — it's this line from the implementation PR's test plan:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AC-8b (migration's &lt;code&gt;server_default&lt;/code&gt; behaving correctly against a pre-populated table) has no&lt;br&gt;
automated test — this repo has no migration-test harness (integration tests use&lt;br&gt;
&lt;code&gt;metadata.create_all&lt;/code&gt;, not Alembic). Verified by construction: [...]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One acceptance criterion, honestly marked unmet, with the reason. That's only possible because&lt;br&gt;
AC-8b existed to be unmet. Without the spec there'd be no gap — just a migration nobody had&lt;br&gt;
thought to test, and no record that anyone noticed.&lt;/p&gt;

&lt;p&gt;A spec's most underrated property is that it gives the work something to fail against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it on purpose
&lt;/h2&gt;

&lt;p&gt;The shape I'd suggest, in the order that matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep writing bald issues.&lt;/strong&gt; The three-sentence note is doing its job. Don't move the cost
back to capture time; you'll just stop capturing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the interview at triage pickup, not at capture.&lt;/strong&gt; This is the whole trick. At pickup you're
already thinking about the problem and the agent can read the code &lt;em&gt;now&lt;/em&gt;, against the repo as
it currently is rather than as it was when you filed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the agent read before it asks.&lt;/strong&gt; Questions that arrive with file paths attached are
answerable in a sentence. Questions that arrive cold are homework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale the output to the work.&lt;/strong&gt; A small change wants an implementation-plan comment on the
issue itself. A feature wants a numbered document — mine live at
&lt;code&gt;docs/specs/FEAT-NNNN-&amp;lt;slug&amp;gt;.md&lt;/code&gt;, and get their own PR ahead of the implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write it back where the issue lives.&lt;/strong&gt; This one I learned by losing it. Enrichment that
stays in the session dies with the session — the issue still reads like three sentences a
month later, and the next pickup starts from nothing. &lt;code&gt;FEAT-0007&lt;/code&gt; survives because it was
committed; plenty of equally good reasoning hasn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the honest limits. This costs about half an hour of agent time and a handful of real&lt;br&gt;
decisions from you, which is absurd overhead for a typo and roughly break-even for a&lt;br&gt;
one-file fix. It leans hard on the agent being able to read &lt;em&gt;everything&lt;/em&gt; relevant — the&lt;br&gt;
insight that reversed my issue came out of a sibling repository, and an agent without that&lt;br&gt;
repository would have cheerfully specified the parser. And it can't manufacture intent: if&lt;br&gt;
you genuinely don't know what you wanted, the interview surfaces that rather than solving it.&lt;br&gt;
Which is still better than finding out from the diff.&lt;/p&gt;

&lt;p&gt;None of this is a claim that issues should be specifications. Mine aren't and won't be. The&lt;br&gt;
issue is a pointer, written in the two minutes I could spare, and it's the right artifact for&lt;br&gt;
that job.&lt;/p&gt;

&lt;p&gt;The interview is just the step that dereferences it — the one I used to run silently, in my&lt;br&gt;
own head, without ever noticing I was running it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applicable to Coding Agents &lt;strong&gt;and&lt;/strong&gt; Engineers
&lt;/h2&gt;

&lt;p&gt;None of the above is specific to working with coding agents. The agent-led interview that spawned the spec doc is recognisably a tech review with a peer, team lead or stakeholder.&lt;/p&gt;

&lt;p&gt;AI assisted software development is not so much about learning entirely new paradigms as it is about having the immediate opportunity to enlist the expertise you need to do a job well.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>documentation</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Trust, Doubt, and a Production Rate-Limit Incident</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/virorum/trust-doubt-and-a-production-rate-limit-incident-4l1c</link>
      <guid>https://dev.to/virorum/trust-doubt-and-a-production-rate-limit-incident-4l1c</guid>
      <description>&lt;p&gt;A production incident post-mortem where the debugging partner was an AI agent — the real story is when I believed it, when I didn't, and why that mattered.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>incidentreview</category>
      <category>observability</category>
    </item>
    <item>
      <title>Quieting 4xx noise in FastAPI + Logfire without losing your 500s</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/virorum/quieting-4xx-noise-in-fastapi-logfire-without-losing-your-500s-4f8k</link>
      <guid>https://dev.to/virorum/quieting-4xx-noise-in-fastapi-logfire-without-losing-your-500s-4f8k</guid>
      <description>&lt;p&gt;A one-line APIRoute fix for a non-obvious interaction between FastAPI's exception handling and Logfire's tracing instrumentation.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Your CLAUDE.md Is an Architecture Document — Write It Like One</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/virorum/your-claudemd-is-an-architecture-document-write-it-like-one-3pn3</link>
      <guid>https://dev.to/virorum/your-claudemd-is-an-architecture-document-write-it-like-one-3pn3</guid>
      <description>&lt;p&gt;The patterns worth stealing from a FastAPI project whose agent-instructions file quietly became its architecture doc — layering, config quarantine, the DI trap that breaks transactions, and more.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>codingagents</category>
      <category>fastapi</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
