| … | … | @@ -0,0 +1,355 @@ |
| 1 | # Cthuloid collaborative control — design |
| 2 | |
| 3 | Status: **APPROVED** (2026-10-02). Decisions D1–D10. Build follows §15. |
| 4 | |
| 5 | ## 1. Goal |
| 6 | |
| 7 | Several people control the same lights at once, from the web UI, an Android app |
| 8 | (same UI), and Blender, with: |
| 9 | |
| 10 | - a **shared durable state** everyone sees: each unit's slot colours and settings, |
| 11 | and reusable **colour presets**; |
| 12 | - a **live layer** for dragging, where a slot being dragged is held by exactly one |
| 13 | person (first drag, first served) and everyone else sees it move and greyed out; |
| 14 | - no single required server: any controller on the LAN can drive the lights with |
| 15 | the others down, and a phone off the LAN reaches the lights through any LAN peer. |
| 16 | |
| 17 | ## 2. Decisions already made (with the user) |
| 18 | |
| 19 | | # | Decision | |
| 20 | |---|---| |
| 21 | | D1 | Durable state lives in an **autobee**; live drags never touch it. | |
| 22 | | D2 | Per-**slot** exclusive grants, first drag first served. | |
| 23 | | D3 | The **light unit is the referee** (see §3). | |
| 24 | | D4 | Grants end on release, ~2 s without movement, a hard 30 s maximum, or disconnect — timers in the **firmware**. | |
| 25 | | D5 | The UI refuses to drag a slot someone else holds (greyed, "held by X"); the unit also enforces it. | |
| 26 | | D6 | Grant numbers order durable commits: the stored value is the one with the highest grant, not the latest in the log. | |
| 27 | | D7 | Looks become **reusable colour presets** (apply to any unit or all ticked units). | |
| 28 | | D8 | **Units sign** what becomes durable (ed25519, §5.7); apps verify in `apply`. | |
| 29 | | D9 | **Open LAN for now**: no per-rig secret on the firmware (threat T1 accepted). | |
| 30 | | D10 | Blender's changes become durable only while some bee controller is connected to that unit — accepted. | |
| 31 | |
| 32 | ## 3. Why the referee is the light unit (audit summary) |
| 33 | |
| 34 | Four placements were audited against source and our own history: |
| 35 | |
| 36 | | | Unit firmware | Elected peer | No referee | Fixed server (stortron) | |
| 37 | |---|---|---|---|---| |
| 38 | | Enforces (not just advises) | **yes** — every write passes through it | no — any socket can send raw `pwm` | no | no | |
| 39 | | Works with any controller down | **yes** | re-election gap | yes | **no** | |
| 40 | | Split-brain | **impossible** (one owner per slot) | yes on partition — our own `atproto-room/lib/signer-presence.js:10-12` admits it | by design | no | |
| 41 | | Blender can take part | **yes** (text lines) | no (can't join Hyperswarm presence) | yes | yes | |
| 42 | | Cost | firmware + OTA | Bare code | lowest | low | |
| 43 | |
| 44 | The `atproto-room` history points the same way: its lease leaked ~20 MB/day by |
| 45 | putting liveness into the log, then its presence election forked on partition and |
| 46 | froze when an elected peer was behind; the final fix **removed the election** and |
| 47 | let the outcome come from the one thing that already decides it. For lights that |
| 48 | thing exists: the lamp. Hyperswarm/protomux have no presence primitive to lean on |
| 49 | (only transport keepalive), and keet's call heartbeat (5 min) is far too coarse. |
| 50 | |
| 51 | ## 4. Architecture |
| 52 | |
| 53 | ``` |
| 54 | Android app ──┐ (WebView → loopback HTTP → Bare worklet) |
| 55 | web UI page ──┼─► controller (Bare): autobee + live relay + unit links ─TCP 1337─► ESP32 units |
| 56 | Blender ──────┘ ▲ (referee, grants, fades) |
| 57 | │ (direct TCP to units, grant protocol)│ hyperswarm (protomux-rpc live channel, |
| 58 | └──────────────────────────────────────┘ autobee replication) between controllers |
| 59 | ``` |
| 60 | |
| 61 | - **Controller** = the Bare process. Same code on stortron (as `cthuloid-web`, |
| 62 | pgh) and inside the Android app (worklet). It holds a corestore with the rig's |
| 63 | autobee, joins the swarm, keeps TCP links to the units it can reach, and serves |
| 64 | the page on HTTP + SSE. |
| 65 | - **Page** = today's web UI, extended. Identical on desktop and phone. |
| 66 | - **Blender** stays Python and talks to units directly using the grant protocol. |
| 67 | It is not a bee writer — its changes become durable via release broadcasts (§6.3). |
| 68 | |
| 69 | ### 4.1 Two separate links |
| 70 | |
| 71 | The autobee lives **in the apps** (every controller holds a replica); the ESP32s |
| 72 | never run it and never see it. There are two kinds of link, and the autobee is |
| 73 | never on the link to the lights: |
| 74 | |
| 75 | | Link | Transport | Carries | |
| 76 | |---|---|---| |
| 77 | | app ↔ light | **plain TCP to port 1337** (as today); mDNS (UDP) only to find addresses | firmware text lines: `grant`, `slot`, `release`, `pwm`, `state` → and back `held`, `free`, state changes | |
| 78 | | app ↔ app | **Hyperswarm** (encrypted, over UDP) | autobee replication (shared memory), the live relay for off-LAN phones | |
| 79 | |
| 80 | Apps **read** the bee and **send TCP** to lights; lights **answer over TCP**; apps |
| 81 | **write** what happened into the bee. |
| 82 | |
| 83 | ### 4.2 Walkthrough: one drag |
| 84 | |
| 85 | 1. Alice's phone (on the LAN) sends `grant 1 alice` over TCP straight to the lamp, |
| 86 | then `slot 1 …` values as she drags. |
| 87 | 2. The lamp broadcasts `held 1 alice …` over TCP to every app connected to it; they |
| 88 | all grey out slot 1 and mirror its value. |
| 89 | 3. She lets go → the lamp broadcasts `free 1 … <final value> release`. |
| 90 | 4. Whichever apps receive that write the final value into the autobee (§6.3). |
| 91 | 5. The bee syncs to every other app over Hyperswarm. |
| 92 | |
| 93 | Off the LAN, steps 1–3 go phone → Hyperswarm → a LAN app (e.g. stortron) → TCP → |
| 94 | lamp (§7). Everything else is identical. |
| 95 | |
| 96 | ## 5. Firmware: command surface v3 |
| 97 | |
| 98 | Bump `CTHULOID_PROTO` to `3`; controllers check it before using grants. |
| 99 | |
| 100 | ### 5.1 New state |
| 101 | - `bootEpoch` (uint32, NVS): incremented **once per boot** (never per grant — no |
| 102 | flash wear). Refuse to grant if the NVS write failed. |
| 103 | - per slot: `holderSocket`, `holderId` (≤ 32 chars), `grantNo` (uint32, RAM), |
| 104 | `lastMoveMs`, `grantedMs`. |
| 105 | - **per-channel fade state** (today one unit-wide `pwm2Fade`; two people on |
| 106 | different slots would cancel each other's fades). |
| 107 | |
| 108 | ### 5.2 Commands |
| 109 | | Command | Effect | |
| 110 | |---|---| |
| 111 | | `grant <slot\|all> <holderId>` | if free (or held by this socket) → `granted <slot> <epoch> <n>`; else `busy <slot> <holderId>`. `all` is atomic: all four or none. | |
| 112 | | `slot <s> <epoch> <n> <b> <g> <r> [fadeMs]` | applied **only** if this socket holds slot `s` with grant `(epoch,n)`; resets the idle timer. Else `ERR notheld <s>`. | |
| 113 | | `release <slot\|all>` | ends the grant. | |
| 114 | | `epochfloor <e>` | if `bootEpoch < e`: `bootEpoch = e + 1` (NVS) — recovery after reflash/NVS erase (§14 T2). | |
| 115 | | `state` | adds `held <s> <holderId> <epoch> <n>` lines for held slots. | |
| 116 | | legacy `pwm` / `pwm2` | still work, but only change **unheld** slots. | |
| 117 | |
| 118 | ### 5.3 Broadcasts (to every connected socket) |
| 119 | - `held <s> <holderId> <epoch> <n>` |
| 120 | - `free <s> <epoch> <n> <b> <g> <r> <why>`, where `why` = `release|idle|max|disconnect|blackout` |
| 121 | — carries the **final value** so anyone can commit it (§6.3). |
| 122 | - `values <v1> … <v12>` — the pins changed (from any source, fades included), |
| 123 | sent **at most every 250 ms**, so controllers never need to poll `state` (§9). |
| 124 | - greeting becomes `<name> ready proto=3 epoch=<e>`. |
| 125 | |
| 126 | ### 5.4 Timers (in `loop()`, `millis()` unsigned subtraction — wrap-safe) |
| 127 | - idle: no `slot` from the holder for **2 s** → free (`idle`). |
| 128 | - hard: **30 s** after grant → free (`max`). |
| 129 | - holder socket gone → free (`disconnect`). Don't rely on `connected()` alone |
| 130 | (it lies after FIN; firmware comment) — the idle timer catches half-open sockets. |
| 131 | - `stopfade`/blackout → frees every slot (`blackout`). |
| 132 | |
| 133 | ### 5.5 Client slots |
| 134 | `MAX_CLIENTS` 4 → **6**. When full, evict the least-recently-active socket that |
| 135 | holds **no** grant; refuse the connection if all hold grants. |
| 136 | |
| 137 | ### 5.6 Self-protection against chatter |
| 138 | - **Coalesce:** within one `loop()` pass only the latest pending `slot` line per |
| 139 | slot is applied. |
| 140 | - **Per-socket cap:** a socket sending more than 60 lines/s has the excess dropped |
| 141 | (counted, reported in `state`), so no client can monopolise the loop. |
| 142 | - **Serial console quiet by default:** today every received byte is echoed to the |
| 143 | UART and each `pwm` prints 12 "Set pin" lines — roughly 30 ms of blocked loop per |
| 144 | command at 115200 baud (estimate from the code). v3 defaults `plevel` to `ack` |
| 145 | and `verbose` to 0 with no byte echo; both stay switchable at runtime for |
| 146 | debugging. (Debug output only — the INVERT and red-cap defines are untouched.) |
| 147 | |
| 148 | ### 5.7 Signing (D8) |
| 149 | - On first boot the unit makes an ed25519 keypair (libsodium, bundled with |
| 150 | arduino-esp32 3.3.8) and stores it in NVS; the greeting and `state` carry the |
| 151 | public key: `<name> ready proto=3 epoch=<e> pk=<hex>`. |
| 152 | - **What is signed: the `free` broadcast**, i.e. the final value of a grant: |
| 153 | `free <s> <epoch> <n> <b> <g> <r> <why> <sig>` with |
| 154 | `sig = Sign(sk, "cthuloid/free/1" ‖ unitId ‖ s ‖ epoch ‖ n ‖ b ‖ g ‖ r)`. |
| 155 | That is the only thing that becomes durable, so signing it means no app can |
| 156 | store a value the lamp didn't actually end on, nor invent a grant number to lock |
| 157 | a slot. `held` lines are live-only and stay unsigned. |
| 158 | - Cost (measured on studiolight, §16 Q1): one sign ≈ 9.4 ms per drag release, |
| 159 | never per drag frame. Verification (≈ 19.5 ms on the ESP32) never runs on the |
| 160 | unit — apps verify with sodium-native. |
| 161 | |
| 162 | ### 5.8 Testing |
| 163 | Grant/timer/fade logic goes in a header (`grants.h`) with no Arduino |
| 164 | dependencies, compiled into a **host-side test** (g++) that drives it with a fake |
| 165 | clock. The sketch includes it. Then OTA to studiolight and an end-to-end test. |
| 166 | |
| 167 | ## 6. Durable state (autobee) |
| 168 | |
| 169 | ### 6.1 Setup |
| 170 | - One autobee per **rig** (the whole lighting setup, not per unit), replicated in |
| 171 | every controller app — never in firmware. `optimistic: false` (with the default `true`, anyone who |
| 172 | knows the key can write — measured). |
| 173 | - One `store.namespace()` per autobee (two on one namespace deadlock — |
| 174 | favela-peer `lib/namespace.js:544`). |
| 175 | - `apply` uses an in-memory overlay and flushes **once** per batch (the view is a |
| 176 | hyperbee; `view.get` doesn't see unflushed puts; double flush is destructive). |
| 177 | - `apply` never reads a clock; any time comes from the op. |
| 178 | |
| 179 | ### 6.2 Keys (hyperbee, JSON values) |
| 180 | | Key | Value | |
| 181 | |---|---| |
| 182 | | `roster/<writerKey>` | `{ name, addedBy, hlc }` — app-level roster; apply ignores ops from non-roster writers | |
| 183 | | `meta/founder` | founding marker (not `host.genesis` — it doesn't flip mid-batch) | |
| 184 | | `unit/<unitId>` | `{ label, addr, port, redMax, fadeMs, gamma, freq, proto, hlc }` | |
| 185 | | `unitkey/<unitId>` | `{ pk, by }` — the unit's public key, trust-on-first-use; changed only by an explicit `rekey` op (after a reflash that wiped NVS) | |
| 186 | | `epoch/<unitId>` | highest boot epoch seen (for `epochfloor` recovery) | |
| 187 | | `slot/<unitId>/<n>` | `{ r, g, b, level, epoch, grant, by }` — never deleted | |
| 188 | | `preset/<name>` | `{ slots: [4 × {r,g,b,level}], by, hlc }` or tombstone `{ deleted: true, hlc }` | |
| 189 | | `intent/<id>` | `{ unit, slots, values, basedOn, by, hlc, state: pending\|done\|skipped, result }` (§6.4) | |
| 190 | |
| 191 | ### 6.3 Ops and apply rules |
| 192 | - **`slot-commit {unit, slot, value, epoch, grant, by}`** → keep if `(epoch, grant)` |
| 193 | > stored; on an exact tie keep the stored one (a tie can only be the same grant |
| 194 | committed twice, which carries the same value). Deterministic under reorg — |
| 195 | verified: three partitioned writers converged on the highest grant. |
| 196 | - **Who commits:** any bee writer connected to the unit that sees a `free` |
| 197 | broadcast commits its final value. Several will — duplicates are idempotent. |
| 198 | This is what makes Blender's changes durable, and survives a phone dying |
| 199 | mid-release. |
| 200 | - **`unit-set`, `preset-put`, `preset-del`** → last-writer-wins by a **hybrid |
| 201 | logical clock in the op** (autobee's own order is weight-then-writer-clock, so a |
| 202 | weight-2 server would otherwise beat a later phone edit). |
| 203 | - **`admit {key, name}`** (only from a roster writer) → `host.addWriter(key)` + roster |
| 204 | entry. **`remove {key}`** → `host.removeWriter` + roster tombstone. |
| 205 | - **Signature check (D8, §5.7):** a `slot-commit` must carry the unit's signature over |
| 206 | `(unit, slot, epoch, grant, value)`, verified against `unitkey/<unit>`; anything |
| 207 | else is ignored. A forged grant number or a value the lamp never ended on can't |
| 208 | be stored. The first `slot-commit` for a unit with no `unitkey` records the key |
| 209 | carried in the op (trust on first use). |
| 210 | - **`rekey {unit, pk}`** (roster writers only) replaces a unit's key after its NVS |
| 211 | was wiped; old commits stay valid (they were checked when applied). |
| 212 | |
| 213 | ### 6.4 Intents — "set and forget" from anywhere |
| 214 | |
| 215 | Live forwarding (§7) needs both your device and a LAN peer online at the same |
| 216 | time. An **intent** doesn't: you write the change into the bee and can go offline |
| 217 | as soon as it has synced; whichever LAN controller sees it first carries it out. |
| 218 | |
| 219 | - Ops: `intent {id, unit, slots, values (or preset name), basedOn, by, hlc}`, |
| 220 | `intent-done {id, epoch, grant}`, `intent-skipped {id, reason}`. |
| 221 | - **Execution:** a LAN controller connected to the unit takes the grant |
| 222 | (`grant all`, or the listed slots), sets the values with the unit's fade, |
| 223 | releases, and appends `intent-done` with the grant number. The value itself is |
| 224 | committed by the normal release-broadcast path. |
| 225 | - **No double execution:** carrying it out needs the grant, so only one controller |
| 226 | can be doing it at a time; others get `busy` or see `intent-done` and skip. If |
| 227 | two still run it, they set the same target value — harmless. |
| 228 | - **No stale overrides:** `basedOn` is the slot's `(epoch, grant)` when the intent |
| 229 | was made. If the slot has moved past that since, the intent is **skipped** |
| 230 | (`intent-skipped stale`), not applied — a phone offline for hours can't clobber |
| 231 | newer work. |
| 232 | - Intents are **not** for drags (latency, log growth). Drags stay on the live layer. |
| 233 | - Pending intents older than a configurable limit (default 1 h) are skipped. |
| 234 | |
| 235 | ### 6.5 Growth |
| 236 | No compaction exists in autobee. Measured ~465 B per write → a few hundred |
| 237 | releases per evening ≈ 0.15 MB, ~50 MB/year per peer. Acceptable. |
| 238 | |
| 239 | ## 7. Live layer (ephemeral) |
| 240 | |
| 241 | - Transport between controllers: **hyperswarm** on the rig's discovery key |
| 242 | (same-LAN peers connect directly via hyperdht's LAN shortcut; an off-LAN phone |
| 243 | holepunches to a LAN peer; `blind-relay` held in reserve for hard NATs). |
| 244 | - One **protomux-rpc** channel `cthuloid/live/1` per connection: |
| 245 | - `hello {device, reaches: [unitId…]}`; |
| 246 | - `relay {unit, line}` → the LAN peer forwards a firmware line (`grant`, `slot`, |
| 247 | `release`) on its own unit socket and returns the reply; |
| 248 | - event `unit {unit, line}` → fan-out of every `held` / `free` / `state` line. |
| 249 | - A controller on the LAN talks to units directly; it relays only for peers that |
| 250 | can't reach a unit. Nothing in this layer is ever written to the bee. |
| 251 | - **Unit addresses:** LAN controllers on Linux use avahi (`_cthuloid._tcp`); |
| 252 | Android has no avahi, so the phone uses `unit/<id>.addr` from the bee and its |
| 253 | peers' `hello`. |
| 254 | |
| 255 | ## 8. Re-asserting after a unit reboot |
| 256 | |
| 257 | A reboot changes the epoch in the greeting. Each connected controller then waits a |
| 258 | short random delay (0–500 ms), asks `state` once, and pushes the durable value |
| 259 | only for **unheld** slots that **differ** from what the lamp shows. The first |
| 260 | controller fixes the lamp; the rest find it already matching and send nothing. |
| 261 | |
| 262 | ## 9. Load on the lights |
| 263 | |
| 264 | The firmware is single-threaded; every message costs it loop time. Rules, by source: |
| 265 | |
| 266 | | Source of traffic | Guard | |
| 267 | |---|---| |
| 268 | | A drag (any app) | latest value only, ≤ 25 sends/s per unit, identical frames skipped, intermediate frames dropped — never queued (already in `webui/lib/unit.js`) | |
| 269 | | Several apps connected to one unit | no polling: apps ask `state` **once** on connect, then follow `held` / `free` / `values` broadcasts (§5.3) | |
| 270 | | Re-push after a unit reboot | check first, push only what differs, random delay (§8) | |
| 271 | | Intents | executing needs the grant → one executor at a time (§6.4) | |
| 272 | | Off-LAN relay | the forwarding peer applies the same latest-only / 25 Hz / dedupe rules before anything reaches the lamp | |
| 273 | | Keepalive | one `hostname` per 60 s per socket, only when otherwise idle | |
| 274 | | A buggy or flooding client | firmware coalescing + per-socket cap (§5.6) | |
| 275 | | Serial console | quiet by default (§5.6) | |
| 276 | | autobee writes | none — the bee never talks to a light | |
| 277 | |
| 278 | ## 10. The page (web + Android) |
| 279 | |
| 280 | - One page, served by the local controller. |
| 281 | - Per slot: swatch → Blender-style picker (done in 0.1.1). On pointer-down the |
| 282 | control moves at once and requests the grant; `busy` → snap back, show |
| 283 | "held by X". While held by someone else: greyed, live-mirrored values. |
| 284 | - Per unit: Blackout, fade, settings, "apply preset". |
| 285 | - **Presets:** save the four slots of a unit as a named preset; apply to one unit |
| 286 | or all ticked units (takes `grant all` per unit, sets with a fade, releases). |
| 287 | - Pairing screen (invite code / QR), device list, remove device. |
| 288 | |
| 289 | ## 11. Android app |
| 290 | |
| 291 | Template: our **favela-surface-android** — react-native 0.86 + |
| 292 | react-native-bare-kit 0.14.5 + react-native-webview 14; worklet packed with |
| 293 | `bare-pack --preset android`; the worklet runs the controller with HTTP bound to |
| 294 | loopback; the WebView loads `http://127.0.0.1:<port>`; reuse its `lib/host/` |
| 295 | lifecycle and OTA code. (Unix sockets are blocked by SELinux there — loopback TCP.) |
| 296 | |
| 297 | ## 12. Pairing |
| 298 | |
| 299 | `blind-pairing` in the autopass pattern, adapted to autobee: an existing device |
| 300 | creates an invite (stored via an op); the new device's candidate request is |
| 301 | checked against it in `onadd`; the member appends `admit`; `confirm` returns the |
| 302 | bee key. Drain swarm connections before `close()` or the confirm frame never |
| 303 | leaves (favela-peer trap). |
| 304 | |
| 305 | ## 13. Modules |
| 306 | |
| 307 | autobee ^2.1 (2.1.1 proven on Bare 1.28.7 in favela-peer; 2.11.x unverified), |
| 308 | corestore ^7.12, hyperswarm ^4.17, protomux ^3.11, protomux-rpc ^1.10, |
| 309 | blind-pairing ^2.3, compact-encoding, b4a; Android: react-native-bare-kit 0.14.5, |
| 310 | react-native-webview 14, bare-pack. Keep the Bare 1.28 `overrides`; check |
| 311 | `npm ls` for engines > 1.28 before every publish. |
| 312 | |
| 313 | ## 14. Threats and limits |
| 314 | |
| 315 | - **T1 LAN abuse:** anyone on the LAN can open TCP 1337, grab slots, spoof holder |
| 316 | ids or fill client slots. Grants stop honest collisions, not attackers. (Q2) |
| 317 | - **T2 Epoch regression:** a reflash or NVS erase resets `bootEpoch`, so new grants |
| 318 | would lose to old commits forever. Fix: controllers send `epochfloor` with the |
| 319 | bee's `epoch/<unit>` on connect. |
| 320 | - **T3 Mixed versions:** a proto-2 unit has no grants — controllers fall back to |
| 321 | today's behaviour for it and show "no shared control". |
| 322 | - **T4 Off-LAN control** needs at least one LAN controller up (inherent). |
| 323 | |
| 324 | ## 15. Build phases (each tested and verified before the next) |
| 325 | |
| 326 | 1. **Firmware v3** — `grants.h` + host tests; per-channel fades; `values` |
| 327 | broadcasts; coalescing + per-socket cap; quiet serial default; OTA studiolight; e2e. |
| 328 | 2. **Web UI live grants** — per-slot `grant`/`slot`/`release`, greying, "held by"; |
| 329 | stop `state` polling (follow broadcasts); Blender addon speaks the grant |
| 330 | protocol. LAN only, no bee yet. |
| 331 | 3. **autobee** in the controller — durable slots via release broadcasts, presets, |
| 332 | HLC, roster, `epochfloor`, check-first re-push, intents. |
| 333 | 4. **Hyperswarm + live relay + pairing** — two controllers, then off-LAN phone |
| 334 | path (relay applies the same send limits). |
| 335 | 5. **Android app** from the favela-surface-android template. |
| 336 | |
| 337 | ## 16. Questions (answered) |
| 338 | |
| 339 | - **Q1 Grant signing.** → **Sign** (D8, §5.7): the unit signs the final value of each grant. |
| 340 | **Feasibility verified 2026-10-02:** arduino-esp32 3.3.8 bundles libsodium for the |
| 341 | ESP32 (`libespressif__libsodium.a`, on the default link line; the four |
| 342 | `crypto_sign_ed25519_*` symbols present). Measured **on studiolight** (240 MHz, a |
| 343 | temporary OTA bench sketch, then the real firmware flashed back): keypair 8.7 ms |
| 344 | (once, first boot), **sign 9.4 ms avg** (9.35–9.69, n=40), verify 19.5 ms (apps |
| 345 | only, never on the unit), 0 bad. The unit signs once per grant, at its release — |
| 346 | never per drag frame. The bench build (WiFi + OTA + libsodium) was |
| 347 | 1,089,055 bytes (83 % of the app partition); v3's real size is measured at build. |
| 348 | - **Q2 LAN auth.** → **Open LAN for now** (D9). |
| 349 | - **Q3 Blender durable writes.** → **Accepted** (D10). |
| 350 | |
| 351 | ## 17. Knowledge-base corrections found during the audit |
| 352 | |
| 353 | The `autobee` skill says the apply `view` is a batch (it is the working hyperbee; |
| 354 | `view.write()` gives a batch) and that `node.from.key` is wrong (it works; `from` |
| 355 | is the core). To fix in `.claude/skills/autobee/SKILL.md`. |