nx_iot_watchdog.nx source
↩ module page · 316 lines · 12214 B
1// nx_iot_watchdog.nx -- per-device link healer (closes the blink loop).
2//
3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 5 (HEAL). The rung that turns
4// "the light dropped + blinks + I re-pair it" into a fully automatic
5// detect -> re-provision -> re-adopt cycle, with a MEASURED count of the
6// manual re-pairs it spared the operator.
7//
8// THE PROBLEM THIS CLOSES (operator-reported):
9// The anchor (WHO) gives a fixture one stable identity for life.
10// The provision state machine (nx_iot_provision_softap) auto-pairs a
11// fixture found in SoftAP mode with no manual step. This organ is the
12// LOOP that ties them together over TIME: it watches each device's
13// reachability, declares it down after a bounded run of missed
14// heartbeats, recognises when it has reverted to pairing mode (the
15// blink), kicks off re-provisioning, and marks it healthy again when
16// the anchor re-adopts it. The blink loop is closed without a human.
17//
18// WHAT THIS ORGAN IS (and is NOT):
19// A pure, deterministic per-device HEALTH state machine. It takes a
20// reachability OBSERVATION each tick (seen / missed / found-in-pairing /
21// re-adopted) and returns the ACTION the daemon should take (probe /
22// mark-unreachable / re-provision / mark-current). It does NOT itself
23// touch the network or the devices -- the daemon wires its actions to
24// nx_iot_provision_softap (re-provision) and nx_iot_anchor (soft-delete
25// / revive), keeping each primitive thin (feedback-hub-primitive-thin-
26// per-site-wiring) + single-responsibility (CLAUDE.md #9).
27//
28// MEASURED-EXCEED HOOK (feedback-no-wave-measured-exceed; mirrors the
29// anchor's duplicates_prevented): recover_count is the number of times
30// the watchdog drove a fixture from RECOVERING back to HEALTHY by
31// re-provisioning it -- i.e. the exact count of MANUAL re-pairs the
32// operator did NOT have to do. The gate asserts it.
33//
34// NEVER-BRICK (CLAUDE.md #26): every action this organ emits is brick-safe
35// -- probe is read-only, mark-unreachable/mark-current touch only the
36// anchor REGISTRY (soft-delete/revive, additive #13), and re-provision
37// delegates to nx_iot_provision_softap whose action alphabet is proven
38// firmware-write-free. No watchdog action writes device firmware.
39//
40// genealogy_id: NISHI_IOT_HUB_ROADMAP.md Epoch 5 + project-iot-hub-multi-vendor-kickoff-2026-05-16
41// license_tier: ORIGINAL
42//
43// nx_capability_claims:
44// needs: [pointer_arithmetic]
45// provides: [device_health_state_machine, bounded_miss_threshold,
46// auto_reprovision_on_pairing, measured_recover_count,
47// brick_safe_action_alphabet]
48// safety: [no_unchecked_deref, no_floating_point, no_syscall,
49// bounded_iteration, no_firmware_write_by_construction,
50// additive_registry_only, kind_isolated]
51// verdict: [sealed_enum_health, sealed_enum_action, sealed_enum_obs,
52// no_silent_failure]
53// license: ORIGINAL
54// kind: iot_runtime_primitive
55// sss: [S2 (liveness -- bounded detection), S6 (no cloud),
56// S7 (sealed-enum verdict)]
57
58// No imports. Pure caller-buffer; the daemon maps actions to the anchor
59// + provision organs, so the watchdog stays self-contained + portable.
60
61// ---- Sealed enum: device health state -----------------------------
62
63const WD_ST_HEALTHY: i64 = 0 // seen recently, reachable
64const WD_ST_MISSED: i64 = 1 // missed >=1 heartbeat (transient)
65const WD_ST_UNREACHABLE: i64 = 2 // missed >= threshold -> declared down
66const WD_ST_RECOVERING: i64 = 3 // found in pairing mode -> re-provisioning
67const WD_ST_N: i64 = 4
68
69func nx_iot_wd_state_is_valid(v: i64) -> i64 {
70 if v < 0 { return 0 }
71 if v >= WD_ST_N { return 0 }
72 return 1
73}
74
75// ---- Sealed enum: observation (what the reachability check reported) -
76
77const WD_OBS_NONE: i64 = 0
78const WD_OBS_SEEN: i64 = 1 // device replied / present on the LAN
79const WD_OBS_MISS: i64 = 2 // no reply this tick
80const WD_OBS_PAIRING: i64 = 3 // device found back in open pairing mode (the blink)
81const WD_OBS_READOPTED: i64 = 4 // provision+anchor re-adopted it (healed)
82const WD_OBS_N: i64 = 5
83
84func nx_iot_wd_obs_is_valid(v: i64) -> i64 {
85 if v < 0 { return 0 }
86 if v >= WD_OBS_N { return 0 }
87 return 1
88}
89
90// ---- Sealed enum: action (what the daemon should do) --------------
91//
92// Brick-safe alphabet (CLAUDE.md #26): none writes device firmware.
93
94const WD_ACT_NONE: i64 = 0
95const WD_ACT_PROBE: i64 = 1 // actively re-probe the device
96const WD_ACT_MARK_UNREACHABLE: i64 = 2 // anchor soft-delete (additive)
97const WD_ACT_REPROVISION: i64 = 3 // kick nx_iot_provision_softap
98const WD_ACT_MARK_CURRENT: i64 = 4 // anchor revive (re-adopt healthy)
99const WD_ACT_N: i64 = 5
100
101func nx_iot_wd_action_is_valid(v: i64) -> i64 {
102 if v < 0 { return 0 }
103 if v >= WD_ACT_N { return 0 }
104 return 1
105}
106
107// FAIL-SAFE firmware-write classifier (same discipline as provision):
108// 0 only for the explicit safe allow-list, 1 (brick risk) otherwise, so a
109// future un-reviewed action defaults RED at the never-brick gate.
110func nx_iot_wd_action_writes_firmware(act: i64) -> i64 {
111 if act == WD_ACT_NONE { return 0 }
112 if act == WD_ACT_PROBE { return 0 }
113 if act == WD_ACT_MARK_UNREACHABLE { return 0 } // anchor registry, not device
114 if act == WD_ACT_REPROVISION { return 0 } // delegates to never-brick provision
115 if act == WD_ACT_MARK_CURRENT { return 0 } // anchor registry, not device
116 return 1
117}
118
119func nx_iot_wd_action_is_brick_safe(act: i64) -> i64 {
120 if nx_iot_wd_action_is_valid(act) != 1 { return 0 }
121 if nx_iot_wd_action_writes_firmware(act) != 0 { return 0 }
122 return 1
123}
124
125// ---- Miss threshold (data, not a magic number -- CLAUDE.md #11) ----
126
127const NX_IOT_WD_MISS_THRESHOLD: i64 = 3
128
129// ---- Per-device watchdog record (8 i64 = 64 bytes) ----------------
130
131struct IotWatchdog {
132 logical_id: i64, // the anchored device this watches
133 health: i64, // sealed health state
134 consec_misses: i64, // consecutive missed heartbeats
135 miss_threshold: i64, // misses before declaring unreachable (data-driven)
136 last_seen_ms: i64, // wall-ms of last reachable observation
137 last_action: i64, // action emitted by the most recent observe
138 recover_count: i64, // MEASURED auto-heals (manual re-pairs spared)
139 tick_count: i64, // total valid observations processed
140}
141
142// ---- Init ---------------------------------------------------------
143//
144// Returns 0 on success, -1 on a null record. miss_threshold <= 0 takes
145// the default. A fresh device starts HEALTHY (it was just anchored).
146
147func nx_iot_wd_init(w: *IotWatchdog, logical_id: i64,
148 miss_threshold: i64, now_ms: i64) -> i64 {
149 if w == (0 as *IotWatchdog) { return -1 }
150 var mt: i64 = miss_threshold
151 if mt <= 0 { mt = NX_IOT_WD_MISS_THRESHOLD }
152 w.logical_id = logical_id
153 w.health = WD_ST_HEALTHY
154 w.consec_misses = 0
155 w.miss_threshold = mt
156 w.last_seen_ms = now_ms
157 w.last_action = WD_ACT_NONE
158 w.recover_count = 0
159 w.tick_count = 0
160 return 0
161}
162
163// ---- Observe: the transition ---------------------------------------
164//
165// Feed the reachability observation for this tick; get back the action.
166// Dispatch is on a snapshot of the health state (h), so a block that
167// advances w.health cannot fall through into a later block. Unhandled
168// (state,observation) pairs are a defensive no-op.
169
170func nx_iot_wd_observe(w: *IotWatchdog, obs: i64, now_ms: i64) -> i64 {
171 if w == (0 as *IotWatchdog) { return WD_ACT_NONE }
172 if nx_iot_wd_obs_is_valid(obs) != 1 {
173 w.last_action = WD_ACT_NONE
174 return WD_ACT_NONE
175 }
176 w.tick_count = w.tick_count + 1
177
178 let h: i64 = w.health
179 var act: i64 = WD_ACT_NONE
180
181 // ---- HEALTHY ---------------------------------------------------
182 if h == WD_ST_HEALTHY {
183 if obs == WD_OBS_SEEN {
184 w.consec_misses = 0
185 w.last_seen_ms = now_ms
186 act = WD_ACT_NONE
187 }
188 if obs == WD_OBS_MISS {
189 w.consec_misses = 1
190 w.health = WD_ST_MISSED
191 act = WD_ACT_PROBE
192 }
193 if obs == WD_OBS_PAIRING {
194 // a healthy fixture suddenly in pairing mode -> it dropped + reverted
195 w.health = WD_ST_RECOVERING
196 act = WD_ACT_REPROVISION
197 }
198 }
199
200 // ---- MISSED (transient) ----------------------------------------
201 if h == WD_ST_MISSED {
202 if obs == WD_OBS_SEEN {
203 w.consec_misses = 0
204 w.last_seen_ms = now_ms
205 w.health = WD_ST_HEALTHY
206 act = WD_ACT_NONE
207 }
208 if obs == WD_OBS_MISS {
209 w.consec_misses = w.consec_misses + 1
210 if w.consec_misses >= w.miss_threshold {
211 w.health = WD_ST_UNREACHABLE
212 act = WD_ACT_MARK_UNREACHABLE
213 } else {
214 act = WD_ACT_PROBE
215 }
216 }
217 if obs == WD_OBS_PAIRING {
218 w.health = WD_ST_RECOVERING
219 act = WD_ACT_REPROVISION
220 }
221 }
222
223 // ---- UNREACHABLE (declared down) -------------------------------
224 if h == WD_ST_UNREACHABLE {
225 if obs == WD_OBS_SEEN {
226 // came back on its own (no re-provision needed) -> revive
227 w.consec_misses = 0
228 w.last_seen_ms = now_ms
229 w.health = WD_ST_HEALTHY
230 act = WD_ACT_MARK_CURRENT
231 }
232 if obs == WD_OBS_PAIRING {
233 // the blink: reverted to pairing -> auto-heal
234 w.health = WD_ST_RECOVERING
235 act = WD_ACT_REPROVISION
236 }
237 if obs == WD_OBS_MISS {
238 act = WD_ACT_NONE // already down; nothing new to do
239 }
240 }
241
242 // ---- RECOVERING (re-provisioning in flight) --------------------
243 if h == WD_ST_RECOVERING {
244 if obs == WD_OBS_READOPTED {
245 // provision_softap re-provisioned + anchor re-adopted = HEALED.
246 // This is the manual re-pair the operator did NOT have to do.
247 w.recover_count = w.recover_count + 1
248 w.consec_misses = 0
249 w.last_seen_ms = now_ms
250 w.health = WD_ST_HEALTHY
251 act = WD_ACT_MARK_CURRENT
252 }
253 if obs == WD_OBS_SEEN {
254 // re-provision brought it back on the LAN
255 w.recover_count = w.recover_count + 1
256 w.consec_misses = 0
257 w.last_seen_ms = now_ms
258 w.health = WD_ST_HEALTHY
259 act = WD_ACT_MARK_CURRENT
260 }
261 if obs == WD_OBS_PAIRING {
262 act = WD_ACT_REPROVISION // still pairing -> keep re-provisioning
263 }
264 if obs == WD_OBS_MISS {
265 act = WD_ACT_NONE // waiting for re-provision to complete
266 }
267 }
268
269 w.last_action = act
270 return act
271}
272
273// ---- Observers (for the daemon / UI / gate) -----------------------
274
275func nx_iot_wd_state(w: *IotWatchdog) -> i64 {
276 if w == (0 as *IotWatchdog) { return -1 }
277 return w.health
278}
279
280func nx_iot_wd_action(w: *IotWatchdog) -> i64 {
281 if w == (0 as *IotWatchdog) { return -1 }
282 return w.last_action
283}
284
285func nx_iot_wd_consec_misses(w: *IotWatchdog) -> i64 {
286 if w == (0 as *IotWatchdog) { return -1 }
287 return w.consec_misses
288}
289
290func nx_iot_wd_recover_count(w: *IotWatchdog) -> i64 {
291 if w == (0 as *IotWatchdog) { return -1 }
292 return w.recover_count
293}
294
295func nx_iot_wd_tick_count(w: *IotWatchdog) -> i64 {
296 if w == (0 as *IotWatchdog) { return -1 }
297 return w.tick_count
298}
299
300func nx_iot_wd_logical_id(w: *IotWatchdog) -> i64 {
301 if w == (0 as *IotWatchdog) { return -1 }
302 return w.logical_id
303}
304
305func nx_iot_wd_is_healthy(w: *IotWatchdog) -> i64 {
306 if w == (0 as *IotWatchdog) { return -1 }
307 if w.health == WD_ST_HEALTHY { return 1 }
308 return 0
309}
310
311func nx_iot_wd_is_down(w: *IotWatchdog) -> i64 {
312 if w == (0 as *IotWatchdog) { return -1 }
313 if w.health == WD_ST_UNREACHABLE { return 1 }
314 if w.health == WD_ST_RECOVERING { return 1 }
315 return 0
316}