<?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: The Command Plane</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 07 Oct 2026 09:59:39 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-the-command-plane-1kgo</link>
      <guid>https://dev.to/virorum/sentinel-the-command-plane-1kgo</guid>
      <description>&lt;p&gt;Everything in this series so far has been about reading from devices. This is the part where Sentinel starts writing to them, and a mistake here can leave a heater running or unlock a door twice.&lt;/p&gt;

&lt;p&gt;A writable point has two values. &lt;code&gt;reported&lt;/code&gt; is what the device says and &lt;code&gt;desired&lt;/code&gt; is what the system wants. Nothing writes to a device directly: a rule or a person sets a claim, arbitration resolves the claims on a point into &lt;code&gt;desired&lt;/code&gt;, and a reconciler drives &lt;code&gt;reported&lt;/code&gt; toward it. A device that reboots into the wrong state is corrected on its next report, and nobody has to send the command again.&lt;/p&gt;

&lt;p&gt;Every claim carries an owner and one of six bands, and the band decides who wins. A person's manual claim has to expire, because otherwise one touch of a switch disables the automation on that point until someone notices in November that the heating never came back on. Frost protection can be suspended for half an hour by someone who names it and gives a reason. An interlock can't be suspended at all.&lt;/p&gt;

&lt;p&gt;A write on the bus is fire-and-forget, so each command is followed through a state machine to an outcome. For a relay whose state can't be read, the outcome is &lt;code&gt;unverified&lt;/code&gt; and the point is marked &lt;code&gt;assumed&lt;/code&gt;, which a rule can choose not to trust. A core that restarts in the middle of a write marks the command &lt;code&gt;timeout&lt;/code&gt; and works out what to do next from &lt;code&gt;desired&lt;/code&gt; and &lt;code&gt;reported&lt;/code&gt;. It never sends the same write a second time. And no command is created for a device that can't be reached: the claim stands and waits, so an interlock asserted during cold start is issued the moment its driver is ready, with no deadline it could have missed in the meantime.&lt;/p&gt;

&lt;p&gt;Rules never send commands. They set claims and release them, and that is what lets the rules engine, the next part, be tested without a device in the loop.&lt;/p&gt;


&lt;p&gt;&lt;strong&gt;What this document owns:&lt;/strong&gt; &lt;code&gt;desired&lt;/code&gt; and &lt;code&gt;reported&lt;/code&gt; as separate values on a writable point, the durable claim set and the projection resolved from it, the convergence states and the bounded retry, the command state machine, per-point serialisation and queue coalescing, the four confirmation modes, deliverability and re-drive, the six bands and how arbitration resolves them, manual expiry and withdrawal, suspension of a named owner, how a rule's band is declared and checked at registry load, fail-safe versus fail-clear on input loss, the limit a confirmation mode puts on what an interlock can protect, the intent API and the direct-command escape hatch, validation before the wire, and idempotency of an intent. It does not own &lt;code&gt;core_epoch&lt;/code&gt; and the fencing behind it, which belong to &lt;em&gt;Core Runtime&lt;/em&gt;; how a principal is established, which belongs to &lt;em&gt;Security Model&lt;/em&gt;; the command subject, the driver's acknowledgement, the instance states that deliverability reads and the &lt;code&gt;idempotent&lt;/code&gt; flag on a driver assignment, which belong to &lt;em&gt;Driver Contract&lt;/em&gt;; the timer that claim expiries share with freshness deadlines, which belongs to &lt;em&gt;Watchdog &amp;amp; Derived Points&lt;/em&gt;; or the shape of a rule and when it sets or releases a claim, which belong to &lt;em&gt;Rules Engine&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The command plane is where the system stops being an observer and becomes able to be wrong in ways that matter. Two things carry the weight: the state machine that turns a fire-and-forget write into a verified outcome, and arbitration, which decides whose intent wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Desired and reported are separate points
&lt;/h2&gt;

&lt;p&gt;A writable point has two values, not one. &lt;code&gt;reported&lt;/code&gt; is what the device says. &lt;code&gt;desired&lt;/code&gt; is what the system wants. They are different fields on the same point, with independent quality.&lt;/p&gt;

&lt;p&gt;Nothing writes to a device directly. Something sets a claim, arbitration resolves the claim set into &lt;code&gt;desired&lt;/code&gt;, and a reconciler drives &lt;code&gt;reported&lt;/code&gt; toward it. This is the difference between "send an off command" and "this light should be off", and it is what makes the system self-healing: a device that reboots into the wrong state gets corrected on its next report without anyone re-issuing anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The claim set is durable; &lt;code&gt;desired&lt;/code&gt; is a projection of it.&lt;/strong&gt; Arbitration has to fall through to the next-highest claim when one is withdrawn, and to restore a suspended claim when its suspension expires. Both require every active claim to be stored, not just the winner.&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;TYPE&lt;/span&gt; &lt;span class="n"&gt;band&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="c1"&gt;-- declaration order is precedence order&lt;/span&gt;
  &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'automation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'schedule'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'manual'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'safety'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'interlock'&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;TYPE&lt;/span&gt; &lt;span class="n"&gt;convergence&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;'no_intent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'undeliverable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'converging'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'converged'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'diverged'&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;claim&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="k"&gt;owner&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;-- 'rule:frost-protect', 'user:alice', 'system:default'&lt;/span&gt;
  &lt;span class="n"&gt;band&lt;/span&gt;        &lt;span class="n"&gt;band&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;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;set_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;expires_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;-- required for band 'manual'&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="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;point_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;)&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_desired&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="k"&gt;owner&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;-- null when no claim is active&lt;/span&gt;
  &lt;span class="n"&gt;band&lt;/span&gt;         &lt;span class="n"&gt;band&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resolved_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;convergence&lt;/span&gt;  &lt;span class="n"&gt;convergence&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;last_command&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres compares enum values in declaration order, so the band &lt;em&gt;is&lt;/em&gt; the comparison. There is no separate priority number to keep in step with it.&lt;/p&gt;

&lt;p&gt;One claim per owner per point. A rule asserting a different value updates its own row rather than accumulating claims.&lt;/p&gt;

&lt;p&gt;Convergence is evaluated on every &lt;code&gt;reported&lt;/code&gt; transition. &lt;code&gt;desired&lt;/code&gt; set with no matching &lt;code&gt;reported&lt;/code&gt; after the confirmation deadline is a divergence, and divergence is a first-class alertable condition, surfaced as a &lt;code&gt;self&lt;/code&gt; point per writable point so that ordinary rules and dashboards can act on it.&lt;/p&gt;

&lt;p&gt;Divergence gets a bounded retry: reconcile, wait, reconcile once more, then stop and mark &lt;code&gt;diverged&lt;/code&gt;. Unbounded reconciliation against a device that physically cannot reach the desired state means a relay clicking every thirty seconds until someone notices.&lt;/p&gt;

&lt;p&gt;What the reconciler does when the device is not reachable at all is a different question, dealt with under deliverability below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Command state machine
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  ┌──────────→ rejected
                  │
created → queued → sent → acked → verifying → verified
                    │       │         │
                    │       │         └────→ unverified
                    ├───────┴─────────────→ failed
                    └─────────────────────→ timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;created&lt;/code&gt; — intent accepted, arbitration passed, &lt;code&gt;command_id&lt;/code&gt; assigned&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;queued&lt;/code&gt; — waiting on the per-point serialiser&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sent&lt;/code&gt; — published to &lt;code&gt;cmd.{host}.{instance}.{point_id}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;acked&lt;/code&gt; — driver reports transport accepted&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rejected&lt;/code&gt; — driver refused before attempting (unknown point, bad value, instance not ready, stale epoch)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verifying&lt;/code&gt; — awaiting confirmation per the point's mode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verified&lt;/code&gt; — &lt;code&gt;reported&lt;/code&gt; now matches &lt;code&gt;desired&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unverified&lt;/code&gt; — &lt;code&gt;optimistic&lt;/code&gt; mode, or verification window elapsed with no contradiction; point quality becomes &lt;code&gt;assumed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;failed&lt;/code&gt; — driver reported an error&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timeout&lt;/code&gt; — no driver response within the deadline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every transition is a &lt;code&gt;control_event&lt;/code&gt;, carrying the principal that caused it and the core epoch that issued it. Terminal states carry the elapsed time, which gives per-device command latency as a &lt;code&gt;self&lt;/code&gt; point without any extra instrumentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every command carries &lt;code&gt;core_epoch&lt;/code&gt;.&lt;/strong&gt; It is the fencing token from &lt;em&gt;Core Runtime&lt;/em&gt;, and a driver rejects any command whose epoch is below the highest it has seen, with reason &lt;code&gt;stale_epoch&lt;/code&gt;. That rejection is treated differently from every other rejection: an unknown point or a bad value is a configuration error, but a stale epoch means two cores existed and one of them was still issuing commands. It escalates rather than retrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serialise per point.&lt;/strong&gt; One in-flight command per point, others queued. Concurrent writes to the same relay are never what anyone wanted, and a queue depth of more than one or two is itself a signal worth exposing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coalesce the queue.&lt;/strong&gt; If two commands for the same point are queued and neither has been sent, the older is dropped. A rule flapping should produce one write, not a backlog that plays out after the condition has passed. Coalescing never crosses a &lt;code&gt;sent&lt;/code&gt; boundary — the ordering guarantee has to hold for anything already on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirmation modes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;inline&lt;/code&gt; — the driver's &lt;code&gt;device_ack&lt;/code&gt; carries the new value. Compare against desired, go to &lt;code&gt;verified&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;. Latency is one round trip.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;async_event&lt;/code&gt; — wait for a subsequent observation on the point. Correlate by value match within the verification window, because most protocols do not echo command IDs. Ambiguity is real here: if something else changed the point to the same value in that window, it will be attributed wrongly. That is accepted, and the window kept tight, typically a couple of seconds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;poll_verify(delay)&lt;/code&gt; — the core schedules a read after the delay. This is the Modbus path, and it is the core issuing a read, not the driver remembering to. Deliberately a re-read rather than waiting for the next scheduled poll, because a 30 s poll interval is too long to hold a command open.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;optimistic&lt;/code&gt; — no verification exists. The command goes to &lt;code&gt;unverified&lt;/code&gt;, &lt;code&gt;reported&lt;/code&gt; becomes the desired value with quality &lt;code&gt;assumed&lt;/code&gt;, and it stays &lt;code&gt;assumed&lt;/code&gt; until something real contradicts or confirms it. This is honest about the write-only relay whose state cannot be read, and the &lt;code&gt;assumed&lt;/code&gt; quality means a rule can choose not to trust it.&lt;/p&gt;

&lt;p&gt;Verification timers are durable, in &lt;code&gt;control_event&lt;/code&gt;, rehydrated at restart. A core that restarts mid-verification resumes the wait rather than losing the command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On restart, in-flight commands do not resume being sent.&lt;/strong&gt; Anything in &lt;code&gt;sent&lt;/code&gt; or &lt;code&gt;verifying&lt;/code&gt; at shutdown is marked &lt;code&gt;timeout&lt;/code&gt; on recovery, and the reconciler re-derives from &lt;code&gt;desired&lt;/code&gt; versus &lt;code&gt;reported&lt;/code&gt;. Replaying a write whose outcome is unknown is how a door gets unlocked twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deliverability and re-drive
&lt;/h2&gt;

&lt;p&gt;Reconciliation only means anything when the device is reachable. Two conditions decide that, and the driver plane already publishes both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A point is deliverable when its driver instance is &lt;code&gt;ready&lt;/code&gt; or &lt;code&gt;degraded&lt;/code&gt;, and the point's own quality is not &lt;code&gt;unavailable&lt;/code&gt;.&lt;/strong&gt; An instance still &lt;code&gt;connecting&lt;/code&gt; and a device the driver has declared off the network are the same thing from here: a command would go into the dark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undeliverable is a state of the intent, not the outcome of a command.&lt;/strong&gt; No command is created while the target is undeliverable. The claim stands, &lt;code&gt;desired&lt;/code&gt; holds its value, convergence sits at &lt;code&gt;undeliverable&lt;/code&gt;, and nothing is queued against a wall-clock deadline. A deadline that expires for reasons unconnected to the device says nothing about the device.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;convergence&lt;/code&gt; is an enum rather than the &lt;code&gt;converged boolean&lt;/code&gt; that would otherwise be the obvious choice. A boolean has to mean three different things — no intent, not yet delivered, and genuinely failing — and that is the same null-means-several-things trap the semantic model exists to escape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retry budget counts only attempts made while deliverable.&lt;/strong&gt; The bounded retry above exists to stop a relay clicking at a device that cannot reach the desired state. A device that is not there has not refused anything. Burning both attempts against an unreachable instance produces a &lt;code&gt;diverged&lt;/code&gt; that means nothing, and consumes the budget the real divergence would have needed later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-drive on the transition to deliverable.&lt;/strong&gt; When an instance reaches &lt;code&gt;ready&lt;/code&gt;, or an observation clears a point's &lt;code&gt;unavailable&lt;/code&gt;, every non-converged claim on the affected points is re-driven and its retry budget resets. It is the same reconciliation a &lt;code&gt;reported&lt;/code&gt; transition triggers, on a different edge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spread the re-drive.&lt;/strong&gt; A driver host with two hundred points reaching &lt;code&gt;ready&lt;/code&gt; would otherwise issue two hundred commands in the same instant, at a device and a transport that have just finished coming up. Modbus in particular does not tolerate that. Concurrency is bounded per instance and the issue jittered. The per-point serialiser and the per-point rate limit do not help here, because the burst is across points rather than within one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold start is the ordinary case of this, not a special one.&lt;/strong&gt; At core start every instance is &lt;code&gt;connecting&lt;/code&gt;, so every claim is undeliverable and nothing is sent. Claims made during warmup — in practice the fail-safe interlocks, since every other rule is gated — are recorded immediately and issued the moment their instance becomes deliverable. The protective claim is never racing a timer it can lose; it is waiting on a condition it can only win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undeliverable is not silent for protective claims.&lt;/strong&gt; An ordinary claim can wait indefinitely and nobody needs to know. An interlock or safety claim sitting &lt;code&gt;undeliverable&lt;/code&gt; is unenforced protection, which is exactly as serious as an interlock that has diverged. Convergence is exposed as a &lt;code&gt;self&lt;/code&gt; point per writable point; for &lt;code&gt;interlock_protected&lt;/code&gt; points, &lt;code&gt;undeliverable&lt;/code&gt; past a short bound escalates rather than waiting quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arbitration
&lt;/h2&gt;

&lt;p&gt;The part that is cheap now and expensive later.&lt;/p&gt;

&lt;p&gt;Every claim carries an owner and a band. The band is the whole of the precedence model.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;band&lt;/th&gt;
&lt;th&gt;typical owner&lt;/th&gt;
&lt;th&gt;notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interlock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;protective rules that may not be overridden&lt;/td&gt;
&lt;td&gt;requires &lt;code&gt;interlock_class&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;safety&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;frost protection, over-temperature cutouts&lt;/td&gt;
&lt;td&gt;suspendable, never outrankable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;manual&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a person&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expires_at&lt;/code&gt; required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;schedule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;time-based rules&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;automation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ordinary rules&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;system:default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;optional resting state from the descriptor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Resolution.&lt;/strong&gt; The active claim set for a point is every claim whose &lt;code&gt;expires_at&lt;/code&gt; is unset or in the future and whose owner is not currently suspended. The winner is the highest band; ties within a band go to the most recent &lt;code&gt;set_at&lt;/code&gt;. The resolver is a pure function from the active set to a winner, and it runs on every claim insert, update, withdrawal, expiry and suspension change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;default&lt;/code&gt; is an ordinary claim, and it is optional.&lt;/strong&gt; A point descriptor may declare &lt;code&gt;default_value&lt;/code&gt;; when it does, the registry materialises a non-expiring claim at band &lt;code&gt;default&lt;/code&gt; owned by &lt;code&gt;system:default&lt;/code&gt;. When it does not, no such claim exists and there is no special case in the resolver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty active set means no intent.&lt;/strong&gt; &lt;code&gt;point_desired.owner&lt;/code&gt; is null, convergence is &lt;code&gt;no_intent&lt;/code&gt;, and the reconciler does nothing. Releasing the last claim on a point leaves the device where it is rather than driving it somewhere. Asserting a resting state is something a point opts into by declaring &lt;code&gt;default_value&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is right for a light and wrong for anything protective, so the descriptor carries &lt;code&gt;require_intent&lt;/code&gt;. A point with &lt;code&gt;require_intent: true&lt;/code&gt; must declare &lt;code&gt;default_value&lt;/code&gt;, checked at registry load, which makes an empty active set unreachable and &lt;code&gt;desired&lt;/code&gt; never null. Any point marked &lt;code&gt;interlock_protected&lt;/code&gt; gets &lt;code&gt;require_intent&lt;/code&gt; implicitly, and the load fails if it has no default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual claims expire.&lt;/strong&gt; Band &lt;code&gt;manual&lt;/code&gt; requires &lt;code&gt;expires_at&lt;/code&gt;, defaulting to an hour or two and configurable per point. On expiry the claim is deleted, arbitration re-resolves, and the point falls through to whatever automation wants. Without expiry, every manual touch permanently disables automation for that point, and the discovery comes in November when the heating never came back on.&lt;/p&gt;

&lt;p&gt;Claim expiries run on the same timer mechanism as freshness deadlines, and like them they are derived rather than stored: recomputed from &lt;code&gt;expires_at&lt;/code&gt; when the claim set loads, so a restart cannot lose one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Withdrawal, not just setting.&lt;/strong&gt; An owner can release its claim, and the point falls through to the next-highest active claim, or to nothing. Rules that have stopped applying must release rather than continue asserting, otherwise arbitration resolves against stale claims forever.&lt;/p&gt;

&lt;p&gt;Every resolution is logged to &lt;code&gt;control_event&lt;/code&gt; with the winner, the full losing set, and the principal responsible. "Why is this light on" is then a query against the claim set, not an exercise in reasoning about rule ordering.&lt;/p&gt;

&lt;p&gt;Two load-time checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two rules claiming the same point at the same band is a lint warning. It is almost always unintentional, and last-writer-wins between them is rarely what anyone meant.&lt;/li&gt;
&lt;li&gt;Two interlock rules claiming the same point with different values is an error, not a warning. Resolving that at runtime is worse than failing the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Safety bands
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;interlock&lt;/code&gt; and &lt;code&gt;safety&lt;/code&gt; are separate bands, and the difference between them is not precedence. &lt;code&gt;manual&lt;/code&gt; outranks neither. The difference is that a &lt;code&gt;safety&lt;/code&gt; claim can be suspended and an &lt;code&gt;interlock&lt;/code&gt; claim cannot.&lt;/p&gt;

&lt;p&gt;Suspension is a distinct mechanism from precedence. It is not that manual wins the comparison; it is that the suspended claim is temporarily withdrawn from arbitration on explicit request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspension is scoped to the owner, not to the point.&lt;/strong&gt; A frost-protection rule holding three pumps is suspended once, for all three. Per-point suspension would let someone servicing the system suspend one pump, believe protection was off, and be wrong about the other two.&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;claim_suspension&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;owner&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;suspended_by&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;suspended_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;expires_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;-- never open-ended&lt;/span&gt;
  &lt;span class="n"&gt;reason&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /claims/rule:frost-protect/suspend
  { for: 30m, reason: "servicing the pump" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request is rejected if the named owner holds any claim at band &lt;code&gt;interlock&lt;/code&gt;. Otherwise the suspension is recorded, every point that owner claims is re-resolved, and the response returns those points with their new winners — so the caller sees the actual blast radius of the decision. The suspension and its expiry are both &lt;code&gt;control_event&lt;/code&gt; entries, and on expiry the claims return automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;expires_at&lt;/code&gt; is not nullable and there is no unsuspend-forever. A suspension that outlives the attention of the person who set it is the failure this mechanism exists to prevent.&lt;/p&gt;

&lt;p&gt;Modelling it as suspension of a named owner rather than as a precedence comparison forces the person to name what they are overriding. "Turn this on" is ambiguous. "Suspend frost protection for thirty minutes" is a decision someone can be held to, and it is legible in the log afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which band a claim gets
&lt;/h2&gt;

&lt;p&gt;The band belongs to the rule, declared in the registry, and validated at load rather than trusted at runtime:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pump-dry-run-cutout&lt;/span&gt;
  &lt;span class="na"&gt;band&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;interlock&lt;/span&gt;
  &lt;span class="na"&gt;interlock_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;thermal&lt;/span&gt;
  &lt;span class="s"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;band: interlock&lt;/code&gt; requires &lt;code&gt;interlock_class&lt;/code&gt; (&lt;code&gt;thermal&lt;/code&gt; | &lt;code&gt;electrical&lt;/code&gt;), and the registry rejects it otherwise. That forces the classification to be explicit and reviewable in a diff rather than emerging from whoever wrote the rule.&lt;/p&gt;

&lt;p&gt;Two load-time checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No point may have interlock claims from more than one rule unless they assert the same value. Two interlocks disagreeing is a design error, and resolving it by precedence at runtime is worse than failing the build.&lt;/li&gt;
&lt;li&gt;Any point that a rule targets with &lt;code&gt;band: interlock&lt;/code&gt; gets &lt;code&gt;interlock_protected: true&lt;/code&gt; on its descriptor. That flag is what the API and UI key off, and it means protection is a property of the point that is visible without walking the rule set.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fail-safe versus fail-clear
&lt;/h2&gt;

&lt;p&gt;The harder question, and the one the band split does not answer on its own: what does an interlock do when it cannot evaluate?&lt;/p&gt;

&lt;p&gt;An over-temperature cutout depends on a temperature point. If that point goes &lt;code&gt;stale&lt;/code&gt;, &lt;code&gt;bad&lt;/code&gt;, or &lt;code&gt;unavailable&lt;/code&gt;, the rule has no basis for a decision. Two possible behaviours, and they are opposite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fail_safe&lt;/strong&gt; — assert the protective value. Cut the heater. Correct for anything where the failure mode is thermal runaway or overcurrent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fail_clear&lt;/strong&gt; — release the claim. Correct where asserting the protective value is itself harmful — a circulation pump forced off during a heat cycle can cause the boiler overheat the interlock was meant to prevent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no sensible default, so it is required:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;heatsink-overtemp&lt;/span&gt;
  &lt;span class="na"&gt;band&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;interlock&lt;/span&gt;
  &lt;span class="na"&gt;interlock_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;thermal&lt;/span&gt;
  &lt;span class="na"&gt;on_input_loss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fail_safe&lt;/span&gt;
  &lt;span class="na"&gt;fail_value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fail_safe&lt;/code&gt; requires &lt;code&gt;fail_value&lt;/code&gt;. The registry rejects an interlock rule without &lt;code&gt;on_input_loss&lt;/code&gt; — this is the one place where inference is refused outright, because getting it wrong is silent until the day it is not.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;fail_safe&lt;/code&gt; interlocks, the asserted claim persists until inputs return to &lt;code&gt;live&lt;/code&gt; and the rule evaluates cleanly, not merely until data reappears. A single good reading after a fault does not clear a cutout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirmation mode constrains what an interlock can protect
&lt;/h2&gt;

&lt;p&gt;An interlock on a point with &lt;code&gt;confirmation_mode: optimistic&lt;/code&gt; cannot guarantee anything — it asserts a value with no way to verify it, and the point sits at quality &lt;code&gt;assumed&lt;/code&gt;. That is acceptable for a light and not for a thermal cutout.&lt;/p&gt;

&lt;p&gt;This is a load-time rejection: &lt;code&gt;interlock_class: thermal&lt;/code&gt; or &lt;code&gt;electrical&lt;/code&gt; requires the target point to have a verifiable confirmation mode (&lt;code&gt;inline&lt;/code&gt;, &lt;code&gt;async_event&lt;/code&gt;, or &lt;code&gt;poll_verify&lt;/code&gt;). Protecting a write-only relay requires a second point that reads the actual state — a current sensor on the circuit, an auxiliary contact — rather than an interlock that cannot see what it is doing.&lt;/p&gt;

&lt;p&gt;Interlock non-convergence is treated differently from ordinary non-convergence in both directions it can fail. &lt;code&gt;diverged&lt;/code&gt; gets a shorter deadline, no bounded-retry give-up, and escalation rather than a settled state. &lt;code&gt;undeliverable&lt;/code&gt; escalates too, on the bound described under deliverability. An interlock that has failed to take effect is the loudest thing this system can say, and it does not matter whether it failed because the device refused or because the device was not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The intent API
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apiblueprint"&gt;&lt;code&gt;POST   /points/{id}/claim
  { value, expires_in?, reason?, idempotency_key? }

DELETE /points/{id}/claim        withdraws the caller's own claim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Owner and band are derived from the authenticated principal, never supplied in the body.&lt;/strong&gt; A rule's band comes from its registry declaration; a person's is &lt;code&gt;manual&lt;/code&gt;. An API that takes &lt;code&gt;owner&lt;/code&gt; and a priority number as parameters lets any caller assert whatever authority it likes, which is not an authorisation model. The mechanism for establishing the principal is in &lt;em&gt;Security Model&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every mutating call names a principal, and there is no default.&lt;/strong&gt; A request that arrives without one is rejected rather than attributed to a service identity, and the principal is written to every &lt;code&gt;control_event&lt;/code&gt; the call produces. That requirement is what makes the claim log an audit trail rather than an activity feed: nothing can change state without saying who asked.&lt;/p&gt;

&lt;p&gt;The response is the resolved arbitration outcome and the resulting convergence state, plus a &lt;code&gt;command_id&lt;/code&gt; if this claim won and a write was issued. A claim that won on an undeliverable point returns &lt;code&gt;undeliverable&lt;/code&gt; and no command ID, which is a normal answer and not an error. A losing claim is still recorded, and the response returns the current winner. That is not an error either, and the caller may well want to know it was overruled.&lt;/p&gt;

&lt;p&gt;Withdrawal removes only the caller's own claim. Removing someone else's is suspension, which is the mechanism above and carries its rules.&lt;/p&gt;

&lt;p&gt;A direct-command escape hatch exists for commissioning and debugging, gated behind its own capability and loudly logged, bypassing arbitration. Two limits on it, both non-negotiable: it does not override an asserted interlock claim, because an escape hatch that can defeat a thermal cutout is the absence of one; and enablement is bounded and expiring exactly like a suspension, with both edges in &lt;code&gt;control_event&lt;/code&gt;, rather than a mode someone leaves on. It is needed when a device is misbehaving late at night, and building it deliberately is better than the alternative of someone reaching for a raw publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation before the wire
&lt;/h2&gt;

&lt;p&gt;Rejection at the core, before anything is queued:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;point is &lt;code&gt;direction: out&lt;/code&gt; or &lt;code&gt;inout&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;value type and enum membership check&lt;/li&gt;
&lt;li&gt;range check against the descriptor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rate limit per point&lt;/strong&gt;, from the descriptor — a physical relay has a duty cycle, and this is the last defence against a rule loop wearing out hardware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deliverability is checked here too, but it is not a rejection. An undeliverable target means the claim is accepted and the command deferred until re-drive, never queued against a deadline waiting for an instance to come up.&lt;/p&gt;

&lt;p&gt;Command values are supplied canonical and converted to native on the way out, using the same table as ingest, inverted. Same registry version, same affine handling. An asymmetric conversion path is a bug waiting to happen, so it is one function used in both directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;idempotency_key&lt;/code&gt; on the intent, deduplicated over a short window. A UI double-click or a bus redelivery must not produce two commands.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;idempotent&lt;/code&gt; flag on the driver assignment is separate and narrower: it tells the driver whether a single transport-level retry is safe. A coil write is idempotent. A pulse or a momentary is not, and is marked so — the driver never retries it, and a lost command becomes a &lt;code&gt;timeout&lt;/code&gt; that the reconciler handles rather than a double-fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys the rules engine
&lt;/h2&gt;

&lt;p&gt;Rules never send commands. They set claims with a value and a band, and release them when they stop applying. Everything about delivery, retry, verification, convergence and conflict happens below them.&lt;/p&gt;

&lt;p&gt;That makes rules testable in isolation: a rule's output is a claim, and a claim is data that can be asserted against without a device in the loop.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Arbitration is a pure function, so it is a fixture table.&lt;/strong&gt; Claim set in, winner out, with no device and no bus. The cases that need covering are the ones the model exists for rather than the obvious ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;withdrawal falls through to the next-highest active claim, and to nothing when there is none&lt;/li&gt;
&lt;li&gt;an expired &lt;code&gt;manual&lt;/code&gt; claim falls through without anyone withdrawing it&lt;/li&gt;
&lt;li&gt;a suspension removes an owner's claims across every point it holds, and they return automatically on expiry&lt;/li&gt;
&lt;li&gt;suspending an owner that holds an &lt;code&gt;interlock&lt;/code&gt; claim is refused&lt;/li&gt;
&lt;li&gt;an empty active set resolves to &lt;code&gt;no_intent&lt;/code&gt; and the reconciler does nothing&lt;/li&gt;
&lt;li&gt;a point with &lt;code&gt;default_value&lt;/code&gt; falls through to the band-&lt;code&gt;default&lt;/code&gt; claim instead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Interlock cold start delivers rather than times out.&lt;/strong&gt; Assert a fail-safe interlock claim before any driver instance is ready; assert that no command is created, that convergence is &lt;code&gt;undeliverable&lt;/code&gt;, and that the command is issued the moment the instance reaches &lt;code&gt;ready&lt;/code&gt;. A timeout here is a failure, not a pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retry budget is not consumed while undeliverable.&lt;/strong&gt; Take a point unavailable, leave a claim standing, bring it back, and assert the bounded retry still has its full allowance. The bug this catches is a &lt;code&gt;diverged&lt;/code&gt; that means nothing, which then hides the real one later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fencing.&lt;/strong&gt; A command carrying a &lt;code&gt;core_epoch&lt;/code&gt; below the driver's highest is rejected with &lt;code&gt;stale_epoch&lt;/code&gt;, and the core treats that outcome as an escalation rather than a retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restart does not replay writes.&lt;/strong&gt; Commands in &lt;code&gt;sent&lt;/code&gt; or &lt;code&gt;verifying&lt;/code&gt; at shutdown become &lt;code&gt;timeout&lt;/code&gt; on recovery, and the reconciler re-derives from &lt;code&gt;desired&lt;/code&gt; versus &lt;code&gt;reported&lt;/code&gt;. Assert that no command is re-published — this is the door-unlocked-twice case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every mutating path names a principal.&lt;/strong&gt; A request without one is rejected, and every &lt;code&gt;control_event&lt;/code&gt; written during the fixture run has &lt;code&gt;principal&lt;/code&gt; and &lt;code&gt;core_epoch&lt;/code&gt; populated. The &lt;code&gt;NOT NULL&lt;/code&gt; constraints make this hard to get wrong, which is the point of putting them in the schema.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A break until November.&lt;/strong&gt; This is the last new article here for October. The specification and the Dev Diary both pause after this part and pick up again at the start of November.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next part - &lt;em&gt;The Rules Engine&lt;/em&gt;.&lt;/strong&gt; An engine kept small because almost every trigger a control system needs is already a derived point or a quality transition. What is left is a predicate over transitions, with a claim as its output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The latest Dev Diary - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-auditing-the-watchdog/" rel="noopener noreferrer"&gt;Auditing the Watchdog&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Build step four read against the previous part: a sentence with two halves and a test asserting the one that worked, and a stale point whose checkpoint never moved.&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>systemdesign</category>
    </item>
    <item>
      <title>Sentinel Dev Diary: Auditing the Watchdog</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Mon, 05 Oct 2026 09:23:13 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-dev-diary-auditing-the-watchdog-4i55</link>
      <guid>https://dev.to/virorum/sentinel-dev-diary-auditing-the-watchdog-4i55</guid>
      <description>&lt;p&gt;Build step four of &lt;a href="https://glitchedpixel.io/blog/sentinel-introduction/#what-is-sentinel" rel="noopener noreferrer"&gt;Sentinel&lt;/a&gt; is the freshness watchdog and derived points. It was audited about four weeks ago against the document that owns it, &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-watchdog-and-derived-points/" rel="noopener noreferrer"&gt;The Watchdog and Derived Points&lt;/a&gt;&lt;/em&gt;, which went out last Wednesday.&lt;/p&gt;

&lt;p&gt;That post promised this entry would say where the specification and the build disagree, and which of the two had to change. For this step the answer is usually both. In one case the code changed, then the decision that explained the code, then the document, in that order and each because of the one before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half a sentence
&lt;/h2&gt;

&lt;p&gt;The published copy says this about a stale point recovering:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An observation with &lt;code&gt;observed_at&lt;/code&gt; older than the current deadline - a buffered replay landing late - does not clear staleness, though it is still logged.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are two claims in it. A late observation must not push the point's deadline out, and it must not change the point's quality.&lt;/p&gt;

&lt;p&gt;The first was built. Ingest hands the watchdog each observation's &lt;code&gt;observed_at&lt;/code&gt;, so a replay of hours-old data schedules a deadline that is already in the past.&lt;/p&gt;

&lt;p&gt;The second was not, and could not have been where the code stood. The projection applies an observation's quality without ever seeing a deadline; the information is not available to it. A three-hour-old replay carrying &lt;code&gt;live&lt;/code&gt; moved a &lt;code&gt;stale&lt;/code&gt; point to &lt;code&gt;live&lt;/code&gt;, and the watchdog put it back on the next tick. That is two transitions and two control-log rows where the document asks for none.&lt;/p&gt;

&lt;p&gt;It survived because of the test. &lt;code&gt;test_a_late_replay_does_not_push_the_deadline_out&lt;/code&gt; is named for the rule, starts from a &lt;code&gt;live&lt;/code&gt; point, and asserts that it goes &lt;code&gt;stale&lt;/code&gt;. The sentence is about a point that is already &lt;code&gt;stale&lt;/code&gt;, and no test started there. The audit's line on it, at the time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How it survived: the test that quotes the sentence asserts the half that works.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The fix was wider than the sentence
&lt;/h3&gt;

&lt;p&gt;Holding the quality and applying everything else is what the sentence appears to ask for. The decision row records why that does not work.&lt;/p&gt;

&lt;p&gt;A host reconnecting after an outage replays many rows, oldest first. The projection wrote &lt;code&gt;observed_at&lt;/code&gt; unconditionally, so an old row moved it backwards, and every deadline is derived from &lt;code&gt;observed_at&lt;/code&gt;. Holding only the quality&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;would let the first row lower the bar the second is judged against, and a few rows in a replayed hour would read as current.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The transition published on the bus also carries the observation's own quality. A held row that was still published would tell every subscriber the point read &lt;code&gt;live&lt;/code&gt; while the projection said &lt;code&gt;stale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the reading taken was the stronger one: a late replay is written to the log and advances nothing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It is a row in the history and no part of the present.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same row names the trap in the obvious implementation. "Older than the current deadline" is true of every healthy observation; a point reporting every 30 seconds under a 45-second window is always inside its deadline. A rule keyed on lateness alone&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;would freeze the entire projection on the first observation after startup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two more rows came out of building it. One out-of-order observation could mark a healthy point &lt;code&gt;stale&lt;/code&gt;, for the same reason: &lt;code&gt;observed_at&lt;/code&gt; went backwards and took the deadline with it. The other was filed as a cosmetic note about three fields nobody reads. It turned out that a withheld row was still being treated as the predecessor of the row after it, which suppressed a real edge on the next reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then the document moved
&lt;/h3&gt;

&lt;p&gt;The decision gated its rule on the point being &lt;code&gt;stale&lt;/code&gt;, because that is the state the sentence is about. Which other states a late replay may promote was left as an open question against the document.&lt;/p&gt;

&lt;p&gt;The document's answer was to stop asking what state the point is in. The current text says a reading that was already past its own deadline when it was ingested "is expired on arrival: it is written to the log and advances nothing". It is judged on its own age, measured from &lt;code&gt;ingested_at&lt;/code&gt;, instead of against whatever deadline the point happens to hold. That closed the question and superseded part of the decision that had prompted it.&lt;/p&gt;

&lt;p&gt;One sentence in the copy published here is now six paragraphs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Criteria that passed without a core
&lt;/h2&gt;

&lt;p&gt;The first exit criterion in the published copy is specific about how to test it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Load a checkpointed projection, start the core, and assert zero &lt;code&gt;restored → stale&lt;/code&gt; transitions inside each point's grace, and the correct ones after it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What existed was six unit tests over a watchdog constructed inside a test. The integration test that does load a checkpoint and start a core asserted nothing about staleness. The second criterion was covered in three pieces, with &lt;code&gt;core.{n}.staleness-suppressed&lt;/code&gt; asserted against a stub that returns what it is given. The audit: "Nothing asserts the chain".&lt;/p&gt;

&lt;p&gt;Both got the tests the document asked for, against a started core swept by its own timer. The register row for that work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The second test found a live bug on its first run, which is what the deferral was about&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;staleness-suppressed&lt;/code&gt; is the only boolean among the core's signals about itself. The code that turns a sample into a row narrowed the value to a number and excluded booleans without putting them anywhere. So the signal the published document describes as saying "plainly that this is happening" was written on every pass as a row with no value in any column. The stub had asserted one layer above the row.&lt;/p&gt;

&lt;p&gt;The third criterion is the quality propagation table, one fixture per row. It passed. The row that records what was behind it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Derived points were validated, computed, and neither evaluated nor written. Exit criterion 3 passed on pure functions with no loop around them&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Entry one quoted a decision titled &lt;em&gt;A guard with no caller is not a guard&lt;/em&gt;, and I wrote that I had no idea how many more of those there were, presumably not zero. This was the next one, and it covered every derived point in the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring it found a collision
&lt;/h3&gt;

&lt;p&gt;A derived point's output goes through the ingest writer into the observation table, because rehydration reads that table and a chain of derived points has to be rebuildable from the log.&lt;/p&gt;

&lt;p&gt;The dedup key is the one from &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;: instance, epoch, sequence and &lt;code&gt;ingested_at&lt;/code&gt;. Every row in one derived pass shared three of the four, and the pass wrote one evaluation at a time with the sequence restarting at zero. Every point got the same key. The insert kept the first and dropped the others silently, and the symptom was a chain whose second point stayed &lt;code&gt;unknown&lt;/code&gt; for ever.&lt;/p&gt;

&lt;p&gt;No unit test of a single derived point could have seen that. It needed two points written in the same pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stale point whose checkpoint never moved
&lt;/h2&gt;

&lt;p&gt;This one was found in a running core.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ingest Path and Log Schema&lt;/em&gt; gives the checkpoint's write policy as "immediately on quality change". That write was made by the ingest pipeline, from the observation that caused the change.&lt;/p&gt;

&lt;p&gt;A point going stale is a point that has stopped sending observations. So the path never ran for it. The checkpoint row sat at the last quality the point had reported, while the core's in-memory count of stale points moved. The two disagreed for as long as the point stayed quiet.&lt;/p&gt;

&lt;p&gt;Patch coverage had flagged it. Seventeen changed lines were uncovered on the pull request, and they were the watchdog's entire wiring into the running core. Nothing acted on the flag, because the test job was the only required check. The repository's own note:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A coverage gate that reports and does not block is a gate that tells you afterwards.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The gate now sits inside the test job, and every changed line has to be covered for it to pass. I have written before about &lt;a href="https://glitchedpixel.io/blog/rule-only-as-strong-as-what-enforces-it/" rel="noopener noreferrer"&gt;a rule being only as strong as what enforces it&lt;/a&gt;, and this is the same lesson costing a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the document changed
&lt;/h2&gt;

&lt;p&gt;Three things in this step ended with the document amended. A fourth did not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fan-out
&lt;/h3&gt;

&lt;p&gt;&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; promises that a driver dying silently "is caught by its own heartbeat going stale - the core marks the instance unavailable after a grace period". The published watchdog document gives one route to &lt;code&gt;unavailable&lt;/code&gt;: an availability event from a driver. A dead driver sends no event, and a missed deadline produces &lt;code&gt;stale&lt;/code&gt;. No rule anywhere could perform the promise.&lt;/p&gt;

&lt;p&gt;The watchdog document took it, before the step started. A stale instance heartbeat now marks every point that instance owns &lt;code&gt;unavailable&lt;/code&gt;. The heartbeat itself stays &lt;code&gt;stale&lt;/code&gt;, because "it is the evidence rather than a casualty". And "there is no second grace period": the heartbeat's own interval and grace are the wait. Because the fan-out is triggered by a staleness evaluation, it inherits both suppressions, so "neither a restart nor a slow core can take a fleet &lt;code&gt;unavailable&lt;/code&gt;".&lt;/p&gt;

&lt;h3&gt;
  
  
  The propagation table
&lt;/h3&gt;

&lt;p&gt;The published copy gives six rows as a list. Two of its answers are transitions &lt;em&gt;The Semantic Model&lt;/em&gt; forbids, &lt;code&gt;unavailable → stale&lt;/code&gt; and &lt;code&gt;unknown → stale&lt;/code&gt;, and both are reachable. Row one asks whether an input "has ever been &lt;code&gt;live&lt;/code&gt;", and nothing stores history. &lt;code&gt;restored&lt;/code&gt; appears in no row, though every input is &lt;code&gt;restored&lt;/code&gt; after a restart. And the order decides the answer for an input that is &lt;code&gt;bad&lt;/code&gt; having never been &lt;code&gt;live&lt;/code&gt;, in a list that does not say it is ordered.&lt;/p&gt;

&lt;p&gt;The table is longer now, it is ordered, and it says first match wins. Where a row's answer is an illegal move, the point holds what it has and the control event carries the reason &lt;code&gt;held&lt;/code&gt;. That is the two-exit rule on &lt;code&gt;unavailable&lt;/code&gt; from entry two, applied one level up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flap damping
&lt;/h3&gt;

&lt;p&gt;The published text says transitions "are counted in a rolling window; past a threshold" further ones coalesce "until it settles". It gives no window, no threshold and no definition of settled, and underneath that it does not say whether the window is per point or global. The audit left it unbuilt on purpose:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Building to a guess would put the only absolute duration in the mechanism.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The document now says the window is per point and four times the point's own tolerated silence, the threshold is six, and settled means a whole window with no crossing. That last one is deliberately not "the point is &lt;code&gt;live&lt;/code&gt; for a window", because a dead device settles into &lt;code&gt;stale&lt;/code&gt; and would never clear.&lt;/p&gt;

&lt;p&gt;It has been built since. Building it found that the register row describing the work had named the wrong place to put it. The row said damping was a filter over the watchdog's output, which would have counted entries into &lt;code&gt;stale&lt;/code&gt;. A point can legally go &lt;code&gt;stale → bad → stale&lt;/code&gt;, so that is a different number from the &lt;code&gt;live&lt;/code&gt;/&lt;code&gt;stale&lt;/code&gt; crossings the document counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wheel
&lt;/h3&gt;

&lt;p&gt;The published copy asks for a hierarchical timer wheel. The code has a flat one, and the document still says hierarchical. The document names the structure and then says what it is for: one deadline per point, one-second resolution, O(1) reschedule. A flat array of buckets has all three at four thousand points. The difference is recorded as a decision, along with the condition under which hierarchy becomes the answer, and the document has been left as it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A seam review, pointed back
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Introduction&lt;/em&gt; now requires every step to end with a seam review, and step four was the first to end under that rule. The first run of the procedure pointed forward, at documents that had not been built yet. What it found is still reserved for the Mondays after those documents publish.&lt;/p&gt;

&lt;p&gt;The second run pointed back at this step, reading the watchdog document against its neighbours. Because the step was already built, its findings arrived with a reading already taken in code. Two of them sit between documents that are both public.&lt;/p&gt;

&lt;h3&gt;
  
  
  An escape hatch that exists in one document
&lt;/h3&gt;

&lt;p&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;, on logarithmic quantities:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The watchdog document owns the operator set. Its table lists thirteen operators and &lt;code&gt;log_mean&lt;/code&gt; is not one of them. Its &lt;code&gt;mean&lt;/code&gt; row reads "rejected when the input quantity is not &lt;code&gt;agg_safe&lt;/code&gt;", and its exit criteria ask for exactly that refusal. The name appeared in no vocabulary and nowhere in the code.&lt;/p&gt;

&lt;p&gt;The code refused unconditionally, which is what the owning document says. The review gave it no decision row, and said why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That is a choice the code should not have had to make silently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix went the generous way. The operator set gained &lt;code&gt;log_mean&lt;/code&gt;, computed as &lt;code&gt;10 * log10(mean(10^(x/10)))&lt;/code&gt;, and refused on an &lt;code&gt;agg_safe&lt;/code&gt; quantity as the mirror of &lt;code&gt;mean&lt;/code&gt;'s refusal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two columns the vocabulary never had
&lt;/h3&gt;

&lt;p&gt;The published watchdog document says a derived point declares its output quantity, and that a mismatch with "what the operator produces from the input quantity" is a validation failure at load. Checking that needs something that says what a rate over energy is, or a difference of two temperatures.&lt;/p&gt;

&lt;p&gt;The amended operator table reads two fields off the quantity vocabulary for it, &lt;code&gt;delta_quantity&lt;/code&gt; and &lt;code&gt;rate_quantity&lt;/code&gt;. &lt;em&gt;The Semantic Model&lt;/em&gt; owns that vocabulary and printed it with four columns. Neither field was one of them.&lt;/p&gt;

&lt;p&gt;The registry file declared both, with a rule no document stated: leaving one out means the operator is refused. The decision row has the reason for refusing instead of inheriting:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;20 °C minus 21 °C is −1 degree of interval, not −1 °C of anything&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;The Semantic Model&lt;/em&gt; has the two columns now. The amendment got one sentence wrong on the way in, about when an omission is refused, and it was corrected against the code before it was committed. An amendment written to close a finding about drift had drifted from the code itself, by one sentence, before it landed.&lt;/p&gt;

&lt;p&gt;A third finding from the same review waits for its document.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the step has in common
&lt;/h2&gt;

&lt;p&gt;Entry three's findings were all about placement. These are about joins.&lt;/p&gt;

&lt;p&gt;The late-replay sentence had two halves and its test held one of them. The exit criteria were met by parts that each worked, with no core running them together, and the dedup key was fine for one derived point and collided as soon as there were two. A stale point's checkpoint depended on the one write path a stale point never takes. The promise about a dead driver sat in one document while the machinery to keep it belonged to another.&lt;/p&gt;

&lt;p&gt;Each piece was right when looked at alone, which is why the unit tests passed throughout. What caught these was running the pieces together, and for the first two exit criteria that was the test the document had asked for in the first place.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This Wednesday - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-command-plane/" rel="noopener noreferrer"&gt;The Command Plane&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The specification's sixth part: where Sentinel starts writing to devices, with desired and reported held as separate values and a write followed through to a verified outcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At the start of November - the Dev Diary.&lt;/strong&gt; The diary pauses for the rest of October, along with the specification. The next entry picks up where this one leaves off: a sixth check has joined the five from last time: an audit that reads every resolved deferral against the code it says was built. Plus the register checks moving out into a tool of their own, including the one that renumbers rows when two branches claim the same id.&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>buildinpublic</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Sentinel: The Watchdog and Derived Points</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:24:29 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-the-watchdog-and-derived-points-2088</link>
      <guid>https://dev.to/virorum/sentinel-the-watchdog-and-derived-points-2088</guid>
      <description>&lt;p&gt;A door sensor that only reports when the door opens or closes can go months without sending anything. If nothing has happened, that silence is correct. If the sensor has died, the silence looks identical, and no freshness window can tell the two apart. So the registry won't accept an &lt;code&gt;on_change&lt;/code&gt; point unless it also declares a heartbeat or explicitly sets &lt;code&gt;never&lt;/code&gt;. Whoever adds the point has to say how anyone would know it had died, and the answer is recorded in a commit.&lt;/p&gt;

&lt;p&gt;A derived point can mislead in the same way. Computed naively, a 15-minute mean of a flow temperature keeps producing numbers after its sensor goes quiet, and a mean built from a single reading looks entirely reasonable on a dashboard. So the output carries a quality worked out from its inputs. A window with fewer than &lt;code&gt;min_samples&lt;/code&gt; readings comes out &lt;code&gt;bad&lt;/code&gt;, with the reason &lt;code&gt;insufficient_samples&lt;/code&gt;. A stale input makes the output stale unless the point has opted into &lt;code&gt;live_or_stale&lt;/code&gt;. And a derived point that stops emitting misses its own deadline and is marked stale by the same watchdog that watches the devices.&lt;/p&gt;

&lt;p&gt;Restarts are where silence is easiest to misread. Each point's deadline is recomputed from its last &lt;code&gt;observed_at&lt;/code&gt; when the core starts, so nothing has to be persisted. Without a startup grace, though, a fleet that reports every five minutes would go stale within seconds of the restart. A core running behind on ingest is worse: every point would pass its deadline while its data sat queued on the bus, and fail-safe interlocks would assert across the whole estate. Staleness is paused in both cases, and the lag pause is announced on its own signal so it can't hide a real fault. The slow part of a planned restart is rebuilding derived-point windows, and running those queries on a small pool, with one query shared by every derived point that reads the same input, keeps it bounded.&lt;/p&gt;

&lt;p&gt;Staleness and derived values both reach the rules engine as ordinary transitions on ordinary points, so the engine, two parts from now, only has to understand one kind of input.&lt;/p&gt;


&lt;p&gt;&lt;strong&gt;What this document owns:&lt;/strong&gt; freshness policies and the single timer wheel that enforces them, the heartbeat requirement on &lt;code&gt;on_change&lt;/code&gt; points, liveness as a freshness policy, how staleness fires, how it recovers and how it ranks against &lt;code&gt;unavailable&lt;/code&gt;, startup grace, suppression during ingest lag and the three limits on it, flap damping, the derived point descriptor and its operator set, quality propagation, evaluation order and the cap on chain depth, windows and the retention guard that bounds them, the cold-start rehydration budget, emit policy, and the restricted expression grammar with its two binding contexts. It does not own the lag mechanism, its arithmetic or the scale targets, which belong to &lt;em&gt;Core Runtime&lt;/em&gt;; the retention classes, their policy values and how each rolls up, which belong to &lt;em&gt;Retention &amp;amp; Compaction&lt;/em&gt;; the raw-table query a window rehydrates from, which is defined in &lt;em&gt;Ingest Path &amp;amp; Log Schema&lt;/em&gt;; or how a rule triggers on a transition, claims an output and uses the grammar in its &lt;code&gt;when&lt;/code&gt; clause, which belong to &lt;em&gt;Rules Engine&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;These two land together because the watchdog's output is just another observation, and a derived point with no fresh inputs is exactly the staleness problem again one level up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watchdog
&lt;/h2&gt;

&lt;p&gt;A single timer wheel in the core, not a timer per point. Every point with a freshness policy has one pending deadline; when an observation lands, the deadline is cancelled and rescheduled. A hierarchical wheel at 1 s resolution handles tens of thousands of points on one thread and stays O(1) on reschedule, which is the operation that runs constantly. At the four thousand points targeted in &lt;em&gt;Core Runtime&lt;/em&gt; this is not close to a constraint.&lt;/p&gt;

&lt;p&gt;Deadlines survive restart because they are derived, not stored: &lt;code&gt;observed_at + window + grace&lt;/code&gt;, recomputed for every point during projection load. Nothing to persist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policies&lt;/strong&gt;, from the descriptor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;periodic(interval, grace)&lt;/code&gt; — deadline at &lt;code&gt;observed_at + interval + grace&lt;/code&gt;. Grace defaults to half the interval, capped at 60 s.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;on_change(max_silence)&lt;/code&gt; — deadline at &lt;code&gt;observed_at + max_silence&lt;/code&gt;. Requires the device to heartbeat.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ttl(duration)&lt;/code&gt; — hard expiry, no grace.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;never&lt;/code&gt; — no deadline. Config values, static setpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;on_change&lt;/code&gt; without a heartbeat is the trap. A door sensor that reports only on transition is silent for months when nothing happens, and there is no window that distinguishes "quiet" from "dead". The registry rejects &lt;code&gt;on_change&lt;/code&gt; unless the point also declares &lt;code&gt;heartbeat: &amp;lt;interval&amp;gt;&lt;/code&gt; or explicitly sets &lt;code&gt;never&lt;/code&gt;. Forcing that choice at registration is the whole point: the question "how would I know if this died?" gets answered once, in a commit, rather than never.&lt;/p&gt;

&lt;p&gt;For BLE advertisement points, &lt;code&gt;periodic&lt;/code&gt; against the advertising interval is usually more honest than &lt;code&gt;on_change&lt;/code&gt;, since advertisements repeat regardless of change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Liveness is a freshness policy, not a value.&lt;/strong&gt; A point that reports "still running" every second is not a measurement; the information is in the gap, not in the sample. It is modelled as an ordinary point with a tight &lt;code&gt;periodic&lt;/code&gt; policy, with a rule triggering on &lt;code&gt;quality&lt;/code&gt; rather than on value. The system already treats absence as a first-class signal, so nothing extra is needed — and a rule watching for &lt;code&gt;true&lt;/code&gt; to become &lt;code&gt;false&lt;/code&gt; would be watching the wrong thing, since a dead process does not report &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firing&lt;/strong&gt; writes a &lt;code&gt;quality_changed&lt;/code&gt; entry to &lt;code&gt;control_event&lt;/code&gt; and publishes a transition on &lt;code&gt;state.{point_id}&lt;/code&gt;. Same envelope shape as a value transition, &lt;code&gt;changed: false&lt;/code&gt;, value untouched. To the rules engine it is indistinguishable in structure from any other transition, which is the design goal.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stale&lt;/code&gt; and &lt;code&gt;unavailable&lt;/code&gt; interact by precedence: an availability event from a driver sets &lt;code&gt;unavailable&lt;/code&gt; and &lt;strong&gt;cancels&lt;/strong&gt; the point's deadline. When availability returns, the point stays &lt;code&gt;unavailable&lt;/code&gt; until real data arrives — no synthetic promotion. This stops a flapping device from generating alternating &lt;code&gt;stale&lt;/code&gt;/&lt;code&gt;unavailable&lt;/code&gt; noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery is not automatic.&lt;/strong&gt; A point goes &lt;code&gt;stale → live&lt;/code&gt; only when an observation arrives that satisfies its policy. An observation with &lt;code&gt;observed_at&lt;/code&gt; older than the current deadline — a buffered replay landing late — does not clear staleness, though it is still logged. This is the second place &lt;code&gt;observed_at&lt;/code&gt; versus &lt;code&gt;ingested_at&lt;/code&gt; earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  When staleness is suppressed
&lt;/h2&gt;

&lt;p&gt;Two conditions pause staleness evaluation entirely. Both exist because the absence of data is sometimes a fact about the core rather than about the device, and reporting it as device staleness is a lie that causes the system to act.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Startup grace.&lt;/strong&gt; For the first N seconds after core start, &lt;code&gt;restored → stale&lt;/code&gt; transitions are suppressed, where N is the point's window plus grace. Otherwise every point in a fleet that reports every 5 minutes goes stale within seconds of a restart and floods the log. The suppression is per point, so a genuinely dead device still surfaces at the right time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingest lag.&lt;/strong&gt; While the core is behind, all staleness evaluation pauses. Freshness is computed from &lt;code&gt;observed_at&lt;/code&gt;, so a core ten minutes behind would take every point in the system past its deadline while the data sits queued and healthy on the bus — and rules with &lt;code&gt;on_input_loss: release&lt;/code&gt; would drop their claims, and fail-safe interlocks would assert, across the whole estate, because ingest was slow. That is close to the worst available response and it is entirely self-inflicted.&lt;/p&gt;

&lt;p&gt;Three constraints on the lag suppression, because withholding a signal must not become a way to hide problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The threshold is computed, not configured.&lt;/strong&gt; Suppression engages when lag exceeds the smallest &lt;code&gt;window + grace&lt;/code&gt; in the registry. Past that point staleness is unreliable for at least one point, and there is no principled reason to trust it for the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is global, not per point.&lt;/strong&gt; Unlike startup grace, the condition is a property of the core, so all staleness evaluation pauses rather than pausing selectively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The condition is loud.&lt;/strong&gt; Lag is a first-class signal, sustained lag is alertable, and &lt;code&gt;core.{n}.staleness-suppressed&lt;/code&gt; says plainly that this is happening. The system is not going quiet; it is replacing a false signal with a true one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When lag clears, deadlines are recomputed from each point's &lt;code&gt;observed_at&lt;/code&gt; and genuinely dead devices surface within one window. The lag mechanism and its arithmetic live in &lt;em&gt;Core Runtime&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flap damping.&lt;/strong&gt; A point crossing &lt;code&gt;live&lt;/code&gt;/&lt;code&gt;stale&lt;/code&gt; repeatedly is marked flapping rather than emitting every transition. Transitions are counted in a rolling window; past a threshold, a &lt;code&gt;flapping&lt;/code&gt; control event is emitted and further transitions coalesce until it settles. This is a rules-engine concern as much as a watchdog one — a rule triggering on staleness of a flapping point will fire endlessly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Derived points
&lt;/h2&gt;

&lt;p&gt;A derived point is a point. Same ID scheme, same descriptor fields, same quality states, same freshness, same bus subject. Consumers cannot tell the difference and should not be able to.&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-15m&lt;/span&gt;
&lt;span class="s"&gt;kind          measurement&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="s"&gt;subject       water&lt;/span&gt;
&lt;span class="s"&gt;subject_ref   zone:upstairs-ch&lt;/span&gt;
&lt;span class="na"&gt;derivation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;op          mean&lt;/span&gt;
  &lt;span class="s"&gt;inputs      [ boiler.ch1.flow-temp ]&lt;/span&gt;
  &lt;span class="s"&gt;window      15m&lt;/span&gt;
  &lt;span class="s"&gt;emit        on_input | interval(1m)&lt;/span&gt;
  &lt;span class="s"&gt;min_samples &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="s"&gt;input_quality  live_only | live_or_stale&lt;/span&gt;
  &lt;span class="s"&gt;freshness   periodic(1m, grace=30s)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The descriptor declares &lt;code&gt;quantity&lt;/code&gt; and &lt;code&gt;unit&lt;/code&gt; explicitly rather than inheriting them, because some operators change both. A &lt;code&gt;rate&lt;/code&gt; over energy yields power in W; a &lt;code&gt;delta&lt;/code&gt; over temperature yields &lt;code&gt;temperature_delta&lt;/code&gt;, which is a different quantity precisely because it is not affine-convertible. If the engine cannot verify that the declared output quantity matches what the operator produces from the input quantity, that is a registry validation failure at load time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operator set&lt;/strong&gt;, deliberately small:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;op&lt;/th&gt;
&lt;th&gt;notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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;count&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mean&lt;/code&gt; rejected when the input quantity is not &lt;code&gt;agg_safe&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;median&lt;/code&gt;, &lt;code&gt;p95&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;for noisy analogue sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stddev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a staleness-adjacent signal — a stuck sensor has zero variance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;last minus first in window; output quantity differs from input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;delta over time; output quantity differs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;debounce(duration)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean stable for a duration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hysteresis(on, off)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean from a numeric input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;restricted arithmetic over named inputs; grammar below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;hysteresis&lt;/code&gt; as a derived point is the most consequential of these. Thresholds belong here, not in rules. A rule that says "pump on when &lt;code&gt;flow-temp-low&lt;/code&gt; is true" is readable and testable; a rule carrying its own threshold pair is neither, and the same threshold used by three rules is three chances to get it inconsistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quality propagation&lt;/strong&gt; is where most implementations of this go wrong. The rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No input has ever been &lt;code&gt;live&lt;/code&gt; → output &lt;code&gt;unknown&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Any required input &lt;code&gt;unavailable&lt;/code&gt; → output &lt;code&gt;unavailable&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Any required input &lt;code&gt;bad&lt;/code&gt; → output &lt;code&gt;bad&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Window has fewer than &lt;code&gt;min_samples&lt;/code&gt; live samples → output &lt;code&gt;bad&lt;/code&gt;, reason &lt;code&gt;insufficient_samples&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Any required input &lt;code&gt;stale&lt;/code&gt; and policy is &lt;code&gt;live_only&lt;/code&gt; → output &lt;code&gt;stale&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Otherwise → &lt;code&gt;live&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;min_samples&lt;/code&gt; case matters more than it looks. A 15-minute mean computed from one sample is not a 15-minute mean, and it will look completely reasonable on a dashboard. &lt;code&gt;min_samples&lt;/code&gt; defaults to a figure derived from window and expected input interval — around 50% of expected — rather than to 1.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;input_quality: live_or_stale&lt;/code&gt; exists for windows long enough that a missed poll should not invalidate them. It is opt-in, and the output carries lower confidence, but it stops a 24-hour average collapsing because one reading was late.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evaluation.&lt;/strong&gt; Derived points form a DAG. Acyclicity is validated at registry load and cycles rejected there rather than detected at runtime. Evaluation is in topological order within a single transition batch, so a chain of three derived points settles in one pass rather than three bus round trips.&lt;/p&gt;

&lt;p&gt;Chain depth is capped at three or four levels. Deeper than that is a rules problem wearing a derived-points costume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows are queries, not buffers.&lt;/strong&gt; As established in the ingest design, rehydration is a &lt;code&gt;SELECT&lt;/code&gt; against the raw table for the input's retention class. The in-memory window is a cache of that query, and it can be rebuilt at any time. This is what makes derived points restart-transparent, and it is worth protecting: no derived-point state that cannot be recomputed from the log.&lt;/p&gt;

&lt;p&gt;Windows are bounded by both duration and sample count, so a misconfigured 5 Hz sensor with a 24-hour window does not consume the core.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows are bounded by the retention of their inputs
&lt;/h3&gt;

&lt;p&gt;A window longer than the retention behind it rehydrates from a truncated range: it produces a value computed over less time than it claims, which &lt;code&gt;min_samples&lt;/code&gt; will usually but not always catch.&lt;/p&gt;

&lt;p&gt;Retention differs by class, so the check is &lt;strong&gt;per input, against that input's class&lt;/strong&gt;, not against a single global figure. A derived point reading a short-retention diagnostic must satisfy the diagnostic horizon even where it would comfortably pass a measurement one. That is a strictly better check than the global version and it catches the case most likely to be wrong.&lt;/p&gt;

&lt;p&gt;Two validations, and the second is the one that will actually bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;At registry load&lt;/strong&gt;, reject any derived point whose window exceeds its input's class retention less one chunk interval, and warn above half of it. Zero slack is not enough, because a chunk dropping partway through a rehydration truncates the window silently. The check reads the retention policy from the database rather than a constant in the code — retention is a deployment parameter, and hardcoding a figure here means changing it later breaks derived points with no warning at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At retention change&lt;/strong&gt;, refuse a shortening that would orphan an existing window. Retention gets shortened when a disk fills, which is exactly the moment nobody is thinking about derived points. This is the same class of guard as refusing to remove a vocabulary member that points still reference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A window that cannot fit takes the rollup as its input instead.&lt;/strong&gt; A thirty-day mean cannot rehydrate from raw samples dropped at fourteen days, but it can read the hourly aggregate, which is kept far longer and is exactly the right granularity for a window that long. The choice is explicit in the descriptor and enforced at load, rather than discovered at the next restart when the window comes back short.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rehydration is the cold-start budget
&lt;/h3&gt;

&lt;p&gt;At the scale targets in &lt;em&gt;Core Runtime&lt;/em&gt; — a thousand or more derived points — this step is essentially all of the planned-restart time, and it is the only part of cold start that grows with configuration rather than with fleet size. Three things keep it bounded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bounded concurrency, not sequential.&lt;/strong&gt; A thousand round trips executed one at a time is minutes of latency spent waiting rather than working. Run on a small pool, the wall time collapses toward the slowest few queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share queries across derived points reading the same input.&lt;/strong&gt; With a thousand derived points over two thousand inputs, several derived points will read the same source over overlapping windows. Group by input, issue one query covering the widest window in the group, and slice in memory. This is where the largest saving is, and it costs nothing at registry load to compute the grouping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the compression boundary.&lt;/strong&gt; A window reaching past the compression age decompresses chunks to serve, which is far slower than a hot-chunk read. Windows that cross it are worth knowing about; a long window is not wrong, but it should be a deliberate choice rather than a surprise at the next restart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rehydration time is measured as a &lt;code&gt;self&lt;/code&gt; point from the first restart. It is the number that degrades quietly as the rule set grows, and the one that makes an outage worse than it needs to be, since recovery is cold start plus drain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emit policy.&lt;/strong&gt; &lt;code&gt;on_input&lt;/code&gt; recomputes and publishes on every input transition, which is right for &lt;code&gt;debounce&lt;/code&gt; and &lt;code&gt;hysteresis&lt;/code&gt; where latency matters. &lt;code&gt;interval(period)&lt;/code&gt; recomputes on a schedule, which is right for aggregates over slow-moving inputs where per-sample recomputation is wasted work. Aggregates generally use &lt;code&gt;interval&lt;/code&gt;, and the interval becomes the basis for the derived point's own freshness policy.&lt;/p&gt;

&lt;p&gt;Derived points respect their own &lt;code&gt;change_policy&lt;/code&gt; deadband exactly as device points do, so a moving average that moves by 0.001 °C does not emit a transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restricted expression grammar
&lt;/h2&gt;

&lt;p&gt;Defined here and referenced by the rules engine. There is one grammar with two binding contexts, not two grammars, and it is written down before either consumer is built because an informal grammar is how &lt;code&gt;eval&lt;/code&gt; gets in.&lt;/p&gt;

&lt;p&gt;Legal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;arithmetic &lt;code&gt;+ - * / %&lt;/code&gt; and unary minus&lt;/li&gt;
&lt;li&gt;comparison &lt;code&gt;== != &amp;lt; &amp;lt;= &amp;gt; &amp;gt;=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;boolean &lt;code&gt;and or not&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;abs&lt;/code&gt;, &lt;code&gt;clamp&lt;/code&gt;, &lt;code&gt;round&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;numeric, boolean, quality-member and enum-member literals&lt;/li&gt;
&lt;li&gt;names bound by the enclosing declaration, carrying exactly two accessors: &lt;code&gt;.value&lt;/code&gt; and &lt;code&gt;.quality&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rejected at load, not at runtime: function definitions, lambdas, loops, comprehensions, indexing, general attribute access, imports, string formatting, any call not in the list above, and any name not declared in the enclosing &lt;code&gt;inputs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.value&lt;/code&gt; and &lt;code&gt;.quality&lt;/code&gt; are an enumerated accessor pair, not general attribute access. The distinction has to be explicit, because "no attribute access" and &lt;code&gt;flow.quality == live&lt;/code&gt; otherwise contradict each other. The accessor set is closed, it has two members, and adding a third is a grammar change rather than a convenience.&lt;/p&gt;

&lt;p&gt;Quality members are legal only as operands of &lt;code&gt;==&lt;/code&gt; and &lt;code&gt;!=&lt;/code&gt;. Ordering comparison on quality is meaningless — &lt;code&gt;stale &amp;gt; live&lt;/code&gt; is not a question — and the parser rejects it rather than coercing something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Binding contexts.&lt;/strong&gt; In a derived &lt;code&gt;expression&lt;/code&gt; point, names bind to input values and a bare &lt;code&gt;x&lt;/code&gt; means &lt;code&gt;x.value&lt;/code&gt;; &lt;code&gt;.quality&lt;/code&gt; is available but rarely wanted, because quality propagation is handled by the rules above rather than inside the expression. In a rule's &lt;code&gt;when&lt;/code&gt;, names bind to the full &lt;code&gt;(value, quality)&lt;/code&gt; pair and there is no bare-name sugar: a rule that reads a value without considering its quality is usually a bug, and it should have to say so in writing.&lt;/p&gt;

&lt;p&gt;Both contexts parse to an AST at registry load, using one parser. Two parsers is how the contexts diverge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The property that keeps the rules engine small
&lt;/h2&gt;

&lt;p&gt;Derived points feed derived points, and staleness of a derived point is computed by the same watchdog with the same policies. A derived point whose inputs went quiet stops emitting, misses its own deadline, and goes &lt;code&gt;stale&lt;/code&gt; — and a rule can trigger on that without knowing or caring that the point was derived.&lt;/p&gt;

&lt;p&gt;That uniformity is what lets the rules engine stay small. Rising edge, falling edge, moving average, staleness, fresh data and bad data are each either a transition predicate or a derived point, and the engine only needs to understand transitions over points.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;No spurious edges at cold start.&lt;/strong&gt; Load a checkpointed projection, start the core, and assert zero &lt;code&gt;restored → stale&lt;/code&gt; transitions inside each point's grace, and the correct ones after it. This is the whole reason startup grace exists, and it is easy to get subtly wrong in a way that only shows up as a burst of log entries nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staleness suppression engages and clears.&lt;/strong&gt; Simulate lag past the computed threshold and assert that no staleness transitions are emitted, that &lt;code&gt;core.{n}.staleness-suppressed&lt;/code&gt; is true, and that when lag clears the deadlines are recomputed from each point's &lt;code&gt;observed_at&lt;/code&gt; so a genuinely dead device surfaces within one window. The failure mode to catch is suppression that never lifts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quality propagation, one fixture per row of the table.&lt;/strong&gt; Including the two that produce plausible wrong answers rather than errors: a window below &lt;code&gt;min_samples&lt;/code&gt; yields &lt;code&gt;bad&lt;/code&gt; with &lt;code&gt;insufficient_samples&lt;/code&gt; rather than a mean of two readings, and a non-&lt;code&gt;agg_safe&lt;/code&gt; input rejects &lt;code&gt;mean&lt;/code&gt; at load rather than computing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The window guard fails the build.&lt;/strong&gt; A derived point whose window exceeds its input's class retention is rejected at registry load, and separately, a retention shortening that would orphan an existing window is refused. Both directions, because the second is the one that happens under pressure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rehydration is measured, and the grouping demonstrably works.&lt;/strong&gt; Record the wall time, and assert that the number of queries issued is materially below the number of derived points — if it equals it, the shared-query grouping is not doing anything and cold start will degrade linearly as the rule set grows.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;On Monday - the &lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-auditing-the-watchdog/" rel="noopener noreferrer"&gt;Dev Diary&lt;/a&gt;.&lt;/strong&gt; The latest on where the specification and the build disagree as the system takes shape, and which of the two had to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The next part - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-command-plane/" rel="noopener noreferrer"&gt;The Command Plane&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; Where Sentinel starts writing to devices: desired and reported held as separate values, a durable claim set with banded arbitration, and a state machine that follows a fire-and-forget write through to a verified outcome.&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>monitoring</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Sentinel Dev Diary: Checks &amp; Balances</title>
      <dc:creator>Philip Shaw</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:03:16 +0000</pubDate>
      <link>https://dev.to/virorum/sentinel-dev-diary-checks-balances-12k4</link>
      <guid>https://dev.to/virorum/sentinel-dev-diary-checks-balances-12k4</guid>
      <description>&lt;p&gt;This entry is about the checks and balances. Nine build steps into Sentinel, five processes have grown up around the specification, and each one catches a different way the code and the documents describing it come apart. Most of the work is done by AI coding agents, but nothing in these processes depends on that. They would work the same way on a team of engineers.&lt;/p&gt;

&lt;p&gt;They all start from one assumption: things will diverge. A specification written before the code will be wrong in places. The code will move after someone has described it. A note that was true on Tuesday will be stale by Thursday. On a project that runs for months you cannot stop that, and trying to is how a team ends up trusting documents that stopped being true a while ago. The realistic goal is to find divergence soon after it happens and to correct it somewhere the next person, or the next agent, will see it.&lt;/p&gt;

&lt;p&gt;The rest of this entry lays out the five: what each one watches, what keeps it honest, and where it stops. First, an example of the kind of divergence they exist to catch, found eleven days ago by the newest of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A setting nothing read
&lt;/h2&gt;

&lt;p&gt;The part of the specification that went out last Wednesday, &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;, says this about writing to the database:&lt;/p&gt;

&lt;blockquote&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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The code had both numbers. An accumulator took &lt;code&gt;batch_max_rows&lt;/code&gt; and &lt;code&gt;batch_max_delay_ms&lt;/code&gt; from configuration, 500 and 100, and offered a method, &lt;code&gt;due(now)&lt;/code&gt;, that answered exactly the question the sentence asks: is this batch full, or old enough, to flush?&lt;/p&gt;

&lt;p&gt;Nothing in the daemon ever called it.&lt;/p&gt;

&lt;p&gt;The ingest loop passed &lt;code&gt;now=0.0&lt;/code&gt; into every row it added, so the batch could never age, and then took the whole batch unconditionally. Meanwhile the two settings travelled faithfully through the system: from the settings class into the dataclass, into &lt;code&gt;.env.example&lt;/code&gt;, into the deploy workflow, into the GitHub Environment the deploy reads from. Every layer carried them, and none of them did anything. The register row that eventually recorded it put it better than I can:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The two fields travelled from &lt;code&gt;settings.py&lt;/code&gt; into the dataclass and affected nothing, which is worse than absent: a knob that appears to work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The part I did not enjoy: &lt;code&gt;due&lt;/code&gt; did have a caller, just not in the application. The throughput benchmark had built its own loop on top of it.&lt;/p&gt;

&lt;p&gt;Entry one held up a row as the register earning its keep: &lt;strong&gt;CP-1&lt;/strong&gt;, ingest throughput, 4,369 observations a second, with a field reading &lt;em&gt;Do not pre-decide: that the margin holds&lt;/em&gt;. I was quite pleased with that row. The benchmark behind it was measuring a batching strategy this system has never run.&lt;/p&gt;

&lt;p&gt;It was honest by coincidence. The unused knob said 500 rows, and the setting that actually bounds a batch, how many messages one pull from the bus may return, also says 500. When the benchmark was repointed at the real one the figure did not move, so the reading stands. But nobody knew that until somebody looked, and nothing was looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What found it
&lt;/h2&gt;

&lt;p&gt;The tests missed it. &lt;code&gt;test_batch.py&lt;/code&gt; passed throughout, because it tested the accumulator's flush policy directly; in the words of the row that closed it, it&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;asserted a flush policy nothing consulted, which is how a suite comes to certify behaviour the system does not have.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The audits missed it, because no exit criterion asks whether a configuration field is read. So did the drift check, which reads the code against the specification, and would have found a class implementing the sentence perfectly well.&lt;/p&gt;

&lt;p&gt;It turned up when I sat down to write, from the code, a description of what the ingest path does. There is no way to write "the batch flushes when &lt;code&gt;due&lt;/code&gt; returns true" with a citation next to it and not go and look for the caller.&lt;/p&gt;

&lt;p&gt;That was on the day the fifth process, the development guide, was created. Writing the guide found five things on its first pass, and this was one of the gentler ones.&lt;/p&gt;

&lt;p&gt;I have been describing these processes one at a time for three entries, and from here on I call them instruments. Here are all five together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five instruments, five tenses
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instrument&lt;/th&gt;
&lt;th&gt;Tense&lt;/th&gt;
&lt;th&gt;Its normative home&lt;/th&gt;
&lt;th&gt;What keeps it honest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the specification&lt;/td&gt;
&lt;td&gt;what the system &lt;strong&gt;will be&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;itself&lt;/td&gt;
&lt;td&gt;nothing internal; everything else comes at it from outside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the four registers&lt;/td&gt;
&lt;td&gt;what is &lt;strong&gt;open now&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;the specification&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;test_register_integrity.py&lt;/code&gt;, for the registers' shape only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the audits&lt;/td&gt;
&lt;td&gt;what &lt;strong&gt;happened&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;the build step&lt;/td&gt;
&lt;td&gt;a person, once per step, looking back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the seam reviews&lt;/td&gt;
&lt;td&gt;what &lt;strong&gt;nobody said&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;the joins between documents&lt;/td&gt;
&lt;td&gt;a rule in &lt;em&gt;Introduction&lt;/em&gt; that every step ends with one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the development guide&lt;/td&gt;
&lt;td&gt;what &lt;strong&gt;exists today&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;the code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;test_dev_docs.py&lt;/code&gt;, and a marker on every claim&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The specification is where it starts, and it is the only one of the five that defines anything. It is also the only one with nothing inside it checking it. A document cannot audit itself; the best it can do is be written so that the others can.&lt;/p&gt;

&lt;p&gt;The registers number what the specification says, so that it can be cited, and hold what the build keeps producing that the specification has no home for. Entry one was about why they exist. They are under test, and entry three drew the edge of that: a register can be machine-checked for everything that is a statement about itself, and nothing that is a statement about the world outside.&lt;/p&gt;

&lt;p&gt;The audits are the retrospective. One per build step, in two halves: what changed during implementation, and what the step did not deliver. They are kept honest by a person, and only as far as the exit criteria reach. Entry two was the first of them finding silence; entry three was one of them failing to mention a fork in the road, because there was no exit criterion for it to fail.&lt;/p&gt;

&lt;p&gt;The seam reviews are the most recent thing the specification was amended to require, and the oldest complaint. Entry one ended on an open question, &lt;em&gt;Nothing reads the documents against each other&lt;/em&gt;, and three weeks ago it was answered by making it a step's own work: &lt;em&gt;Introduction&lt;/em&gt; now says that "every step ends with a review whose unit is the seam rather than the document". It is the only instrument whose unit is a &lt;em&gt;pair&lt;/em&gt; of documents. The one-owning-document rule optimises for consistency inside a document and provides nothing at the joins, and every cross-document gap found before it existed sat between two documents that were each perfectly consistent on their own.&lt;/p&gt;

&lt;p&gt;It has a joke in its history that I am still enjoying. The paragraph describing the drift check went on calling the seam review "standing, and currently unowned" for two days &lt;em&gt;after&lt;/em&gt; the amendment that gave it an owner. In the document that defines the drift check. The repository's own note on it at the time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a pointer is only as current as the last person to follow it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What the first run actually found waits for the documents it found things in; that one is coming.&lt;/p&gt;

&lt;p&gt;The development guide is the fifth, and it is the one that runs the other way round.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that points at the code
&lt;/h2&gt;

&lt;p&gt;Everything else in that table is about the specification. The registers point at it. The audits measure the code against it. The seam reviews read it against itself.&lt;/p&gt;

&lt;p&gt;Nothing said how the thing that exists behaves. Nine build steps in, the daemon was about 36,000 lines across two repositories, and every piece of work, in every session, was paying to reconstruct it. Since nearly all of that work is done by coding agents, "every piece of work" really does mean every one: an agent opening a session has read the repository and none of my recollection, so it rebuilt its understanding of the ingest loop from scratch each time and, presumably, slightly differently each time.&lt;/p&gt;

&lt;p&gt;(It did not help that the project's own &lt;code&gt;CLAUDE.md&lt;/code&gt;, the first file an agent reads, described two application directories as populated. Both hold a &lt;code&gt;.gitkeep&lt;/code&gt;. They always have. They are the layering from a template for a different kind of app, and this process is a daemon. Writing the guide found that too.)&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;docs/dev/&lt;/code&gt; was written as fifteen chapters, around 3,300 lines, and it defines nothing, exactly as the registers define nothing. The difference is only what it points at. A register row points at the specification and is cited by id. A chapter points at the code and cites &lt;code&gt;path.py::symbol&lt;/code&gt;. The constraint that makes that legitimate rather than a second definition was already in the specification:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prose in these notes explains them; it does not define them once the code exists.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And where the code and the specification disagree, the guide describes the code and links the register row recording the disagreement. Where there is no row, the disagreement is a finding and gets routed like any other, instead of being written around.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proved by, or unverified
&lt;/h3&gt;

&lt;p&gt;This is the part I would steal for any project.&lt;/p&gt;

&lt;p&gt;Every mechanism section in the guide ends in one of two ways. Either with &lt;code&gt;Proved by:&lt;/code&gt; and the test that would fail if the claim stopped being true, or with the bare word &lt;strong&gt;unverified&lt;/strong&gt;. There is no third option, and in particular there is no option for explaining why checking was not necessary. The reasoning, from the guide's own introduction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a marker reading "not checked" invites the check; one reading "trivially true" ends it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two days in, sixty-five claims carried a &lt;code&gt;Proved by:&lt;/code&gt;. Three were marked &lt;strong&gt;unverified&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And every chapter names the commit it was derived from,&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;because four pull requests means four different truths about &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A chapter is as current as that line says, and no more.&lt;/p&gt;

&lt;p&gt;Then there is a test, &lt;code&gt;test_dev_docs.py&lt;/code&gt;, which asserts that every cited path exists and defines the symbol it names, that a list marked as an enum's members holds exactly that enum's members, and that a table marked as a mapping parses to exactly that mapping.&lt;/p&gt;

&lt;p&gt;That makes it the fifth place in the repository where a document is coupled to the code by a test, and the first that runs this way round. The other four assert that the specification still describes the code. This one asserts that the &lt;em&gt;documentation&lt;/em&gt; still describes the code, which sounds like a small distinction until you notice that nothing checked that before.&lt;/p&gt;

&lt;h2&gt;
  
  
  It rotted in two days
&lt;/h2&gt;

&lt;p&gt;Of course it did.&lt;/p&gt;

&lt;p&gt;Two days after the guide was written, it was audited against the code, the way the audits check a build step. Eleven commits had touched it in the meantime, and not with the same care.&lt;/p&gt;

&lt;p&gt;Eleven chapters claimed a currency they did not have. Every edited chapter still carried its original derived-from commit, in the layer whose own README makes that line the definition of how current a chapter is. Three described code that had changed under them. One citation credited a function with publishing messages when all it does is format a subject string; the claim underneath was true, the citation resolved, so the test passed. A citation hung on a symbol that made the sentence look checked.&lt;/p&gt;

&lt;p&gt;That last one is the docs test's edge, in exactly the way entry three found the register test's. It can prove the symbol exists. It cannot prove the symbol is the one doing the thing.&lt;/p&gt;

&lt;p&gt;My favourite goes straight back to the batching. The ingest chapter had to say what really decides a batch, now that the knob was gone: one pull from the bus is one batch is one transaction. It cited &lt;code&gt;test_ingest_pipeline.py&lt;/code&gt; for that. Nothing in &lt;code&gt;test_ingest_pipeline.py&lt;/code&gt; asserts it. So the audit demoted it, and the section now ends:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For the cadence itself - that &lt;code&gt;run_once&lt;/code&gt; takes &lt;strong&gt;once per fetch&lt;/strong&gt;, unconditionally - &lt;strong&gt;unverified&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;followed by the test that would close it.&lt;/p&gt;

&lt;p&gt;I like that more than any &lt;code&gt;Proved by:&lt;/code&gt; in the guide. It is the marker working in reverse: a claim that had a check pinned to it losing the check, and saying so, where the reader will trip over it. The specification described a batching rule that never ran. The guide describes the one that does, and is honest that nothing yet proves it. That is the right way round for those two documents to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different ways to rot
&lt;/h2&gt;

&lt;p&gt;Every one of the five goes stale in its own way, including the checks themselves. The tenses are the useful way to hold all this, because each one decays differently, and so each needs something different watching it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The specification rots by being overtaken. It is amended as the build discharges its open items, which is why the copies published here are a frozen snapshot, and why the gap between the snapshot and the current text is what this diary is for.&lt;/li&gt;
&lt;li&gt;The registers rot by saying things about the world they cannot see, like entry three's sentence placing a test somewhere it had never been, which stood for three pull requests.&lt;/li&gt;
&lt;li&gt;The audits do not rot so much as squint. They are history, but they only see as far as the exit criteria reach.&lt;/li&gt;
&lt;li&gt;The seam reviews rot like any pointer: the rule was two days old before the paragraph describing it caught up.&lt;/li&gt;
&lt;li&gt;The guide rots by the code moving underneath it, and the derived-from commit is the only reason anyone can tell.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them can do another's job. The register tests cannot look outside the registers. The audits cannot see a fork that no criterion asks about. The drift check reads the code against the prose, but only from one side. The seam review reads document against document and never touches code. The guide reads code into prose, and the one thing it cannot do is say what the code &lt;em&gt;should&lt;/em&gt; do; that stays with the specification, which is where this started.&lt;/p&gt;

&lt;p&gt;Every one of the five was added because the one before it had an edge, and something had just fallen off it. I had assumed the newest would spend its first week finding small things in the newest code. Instead, on its first day, it found a hole under entry one's proudest number.&lt;/p&gt;

&lt;p&gt;That is the pattern I would take to any long-running project, with or without agents. Assume the documents and the code will drift. Give each kind of drift something that looks for it, and when one of those checks finds its own edge, add the next one.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This Wednesday - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-watchdog-and-derived-points/" rel="noopener noreferrer"&gt;The Watchdog and Derived Points&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The specification's fifth part: one timer wheel for every freshness deadline, and what a derived point is allowed to say when its inputs go stale.&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-auditing-the-watchdog/" rel="noopener noreferrer"&gt;Auditing the Watchdog&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; What the build sent back to the specification from step four: a sentence with two halves and a test asserting the one that worked, and a stale point whose checkpoint never moved.&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>agents</category>
      <category>ai</category>
      <category>documentation</category>
      <category>softwaredevelopment</category>
    </item>
    <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 &lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-checks-and-balances/" rel="noopener noreferrer"&gt;Dev Diary&lt;/a&gt;.&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;The next part - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-watchdog-and-derived-points/" rel="noopener noreferrer"&gt;The Watchdog and Derived Points&lt;/a&gt;&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;The next entry - &lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-dev-diary-checks-and-balances/" rel="noopener noreferrer"&gt;Checks &amp;amp; Balances&lt;/a&gt;&lt;/em&gt;.&lt;/strong&gt; The five processes that keep code and documents aligned laid out whole, including the seam review that filled the hole entry one ended on, and the newest of them finding a batching setting nothing read on the day it was written. What the seam review's first run found still 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;&lt;a href="https://glitchedpixel.io/blog/sentinel-watchdog-and-derived-points/" rel="noopener noreferrer"&gt;Watchdog &amp;amp; Derived Points&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://glitchedpixel.io/blog/sentinel-command-plane/" rel="noopener noreferrer"&gt;Command Plane&lt;/a&gt;&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>
  </channel>
</rss>
