From c8c4a2c3c8e770d5b4c32e78273508c8dc0e222c Mon Sep 17 00:00:00 2001
From: Simon Novak <25969+snovak7@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:32:21 +0200
Subject: [PATCH 1/2] story 10-4-firmware-learning-reference: implemented and
reviewed via bmad-loop
---
.github/workflows/docs.yml | 47 +++-
README.md | 2 +
.../implementation-artifacts/deferred-work.md | 8 +
.../spec-10-4-firmware-learning-reference.md | 230 ++++++++++++++++++
.../sprint-status.yaml | 2 +-
apps/rs/README.md | 3 +
apps/rs/hub/README.md | 3 +-
apps/rs/node/README.md | 3 +-
docs/check-commands.sh | 19 +-
docs/firmware/README.md | 74 ++++++
docs/firmware/ble-setup.md | 216 ++++++++++++++++
docs/firmware/buttons.md | 130 ++++++++++
docs/firmware/deep-sleep.md | 172 +++++++++++++
docs/firmware/espnow.md | 169 +++++++++++++
docs/firmware/identity.md | 183 ++++++++++++++
docs/firmware/radio-coexistence.md | 152 ++++++++++++
docs/firmware/sealing.md | 175 +++++++++++++
docs/test.sh | 9 +
packages/crypto-spec/scripts/lib/spec.ts | 2 +-
packages/cs/crypto/Generated/CryptoSpec.g.cs | 2 +-
.../coldframe/core/crypto/CryptoSpec.kt | 2 +-
packages/proto/coldframe/setup/v1/setup.proto | 8 +
packages/rs/README.md | 4 +
packages/rs/crypto/src/spec.rs | 2 +-
packages/rs/protocol/build.rs | 7 +-
packages/rs/protocol/src/lib.rs | 22 +-
packages/rs/sensing/src/lib.rs | 3 +-
27 files changed, 1619 insertions(+), 30 deletions(-)
create mode 100644 _bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md
create mode 100644 docs/firmware/README.md
create mode 100644 docs/firmware/ble-setup.md
create mode 100644 docs/firmware/buttons.md
create mode 100644 docs/firmware/deep-sleep.md
create mode 100644 docs/firmware/espnow.md
create mode 100644 docs/firmware/identity.md
create mode 100644 docs/firmware/radio-coexistence.md
create mode 100644 docs/firmware/sealing.md
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 75449af1..02e1f69b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,8 +1,10 @@
-# Checks the adopter docs (Story 10.2): every relative link and referenced repository path
-# resolves, and every command block of the build guide (docs/build/*.md) either runs against the
-# firmware crates (``, builds only) or, when it needs a board
-# (``), names only real espflash subcommands. Nothing is ever flashed, and no
-# external link is fetched. Not a required status check.
+# Checks the adopter docs (Stories 10.2 and 10.4): every relative link and referenced repository
+# path resolves; every command block of the build guide and the firmware reference
+# (docs/build/*.md, docs/firmware/*.md) either runs against the firmware crates
+# (``, builds only) or, when it needs a board (``), names
+# only real espflash subcommands; and the API docs of the packages/rs crates build without a
+# rustdoc warning, a public item without docs included. Nothing is ever flashed, and no external
+# link is fetched. Not a required status check.
#
# Actions are pinned to a commit; the comment names the release. The firmware setup of the
# `commands` job is copied from the `firmware` job of ci.yml: keep the two in step.
@@ -16,6 +18,9 @@ on:
- "hardware/**"
- "README.md"
- "LICENSE"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "rust-toolchain.toml"
- ".gitignore"
- "apps/**"
- "packages/**"
@@ -31,6 +36,9 @@ on:
- "hardware/**"
- "README.md"
- "LICENSE"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "rust-toolchain.toml"
- ".gitignore"
- "apps/**"
- "packages/**"
@@ -135,3 +143,32 @@ jobs:
run: |
docs/check-commands.sh --list
docs/check-commands.sh
+
+ # Story 10.4: the firmware crates are a learning reference, so their API docs build without a
+ # single warning (a missing doc, a broken or ambiguous intra-doc link, a bare URL). It lives here
+ # rather than in ci.yml, whose required `rust` job already runs clippy with `missing_docs`,
+ # because any ci.yml edit makes every ci.yml job run.
+ rustdoc:
+ name: Rust docs
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - name: Check out
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ # Installs the toolchain named in rust-toolchain.toml.
+ - name: Set up Rust
+ run: rustup toolchain install
+
+ - name: Cache
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+
+ - name: Build the API docs
+ env:
+ RUSTDOCFLAGS: -D warnings
+ run: >-
+ cargo doc --no-deps --locked --features coldframe-hal/mock
+ -p coldframe-hal -p coldframe-crypto -p coldframe-protocol -p coldframe-setup
+ -p coldframe-uplink -p coldframe-sensing -p coldframe-transport
diff --git a/README.md b/README.md
index 5c9725e8..385b7a10 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@ and deployment all live in this repository.
- [Build guide](docs/build/README.md): build, flash and add your own Node and Hub.
- [Deploy guide](docs/deploy/README.md): run Coldframe on your home server, then
[build your own apps](docs/build/apps.md).
+- [Firmware reference](docs/firmware/README.md): learn ESP-NOW, BLE provisioning, eFuse keys and
+ deep sleep from the Node and Hub firmware.
- [Hardware design](hardware/README.md): schematics, BOM, wiring, enclosure and probe sealing.
- [Hub radio coexistence spike](docs/spikes/hub-radio-coexistence.md): what the Hub's radio can do at once.
diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md
index 80bd2b1a..7aecf0ba 100644
--- a/_bmad-output/implementation-artifacts/deferred-work.md
+++ b/_bmad-output/implementation-artifacts/deferred-work.md
@@ -946,3 +946,11 @@ source_spec: `spec-10-1-releases-with-one-version-and-firmware-binaries.md`
severity: medium
reason: dry_run is true for every non-push event, so these steps are skipped on PRs; only a real v* tag exercises them. Owed as an operator action (cut the first release and check it).
status: open
+
+### DW-122: Hub comments still claim the eFuse adapter holds the Hub's only unsafe code, but board/rtc.rs has an unsafe block for the mbedTLS clock hooks.
+origin: spec-deferred d5a00e9af471
+location: apps/rs/hub/src/board/efuse.rs:7
+source_spec: `spec-10-4-firmware-learning-reference.md`
+severity: low
+reason: apps/rs/hub/src/board/efuse.rs module doc line 7 and apps/rs/hub/Cargo.toml:60 say only efuse.rs may use unsafe; apps/rs/hub/src/board/rtc.rs:40-41 has #[allow(unsafe_code)] unsafe { ... }. Pre-existing, not caused by story 10-4; docs/firmware/identity.md already states the code's truth.
+status: open
diff --git a/_bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md b/_bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md
new file mode 100644
index 00000000..81b6ca7b
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md
@@ -0,0 +1,230 @@
+---
+title: 'Firmware learning reference'
+type: 'feature'
+created: '2026-10-20'
+status: 'done'
+baseline_revision: 'a5bae8573c50692249adf99c9ce494e96ff36dd0'
+review_loop_iteration: 0
+followup_review_recommended: false
+context:
+ - '{project-root}/_bmad-output/implementation-artifacts/epic-10-context.md'
+warnings: [oversized]
+deferred:
+ - summary: >-
+ Hub comments still claim the eFuse adapter holds the Hub's only unsafe code, but board/rtc.rs has an unsafe block for the mbedTLS clock hooks.
+ evidence: |-
+ apps/rs/hub/src/board/efuse.rs module doc line 7 and apps/rs/hub/Cargo.toml:60 say only efuse.rs may use unsafe; apps/rs/hub/src/board/rtc.rs:40-41 has #[allow(unsafe_code)] unsafe { ... }. Pre-existing, not caused by story 10-4; docs/firmware/identity.md already states the code's truth.
+ location: >-
+ apps/rs/hub/src/board/efuse.rs:7
+ severity: low
+---
+
+
+
+## Intent
+
+**Problem:** A maker who wants to learn ESP-NOW, BLE provisioning and deep sleep from the Coldframe firmware has no entry point. The knowledge is spread over crate docs, `apps/rs/*/README.md`, ARCHITECTURE-SPINE AD texts and the spike report, and `docs/firmware/` does not exist. `cargo doc` over `packages/rs/*` is not run in CI. Today it emits 2 rustdoc warnings (a bare URL in the generated `packages/rs/crypto/src/spec.rs`, an ambiguous `[`env`]` link in `packages/rs/sensing/src/lib.rs:10`). The re-exported micropb wire types in `coldframe-protocol` have no docs at all, because `missing_docs` is allowed on the generated module.
+
+**Approach:** Add `docs/firmware/`: an index page plus one page per AC topic. Each page explains the mechanism in prose and points to the code by repo path and symbol. Make every public item of `packages/rs/*` documented, including the generated wire types, by carrying the proto comments into the generated code. Fix the 2 rustdoc warnings at their source. Add a `cargo doc` job with `RUSTDOCFLAGS=-D warnings` to the Docs workflow.
+
+## Boundaries & Constraints
+
+**Always:**
+- Take facts from the code, not from the planning docs. Where the two disagree, document the code. Examples: the Hub only rescans after the link drops, and there is no RSSI-degrade re-evaluation; the burned fuse purpose is `HMAC_UP` and the peripheral mode is `ToUser`.
+- Point to code as backticked repo-root paths (`check-links.sh` verifies they exist) plus symbol names. Link to READMEs, AD texts and checklists instead of copying their tables.
+- State these facts: eFuse burns are irreversible, and any non-`dev-mode` first boot burns, with a `> [!WARNING]`. The ~100 µA average sleep-current budget is not yet measured. WPA3-only Wi-Fi is unsupported. A short press means report now, a long press (≥3 s) means BLE setup. Never erase `cf_frame` on a Node that has sent frames.
+- Match the existing page style: one `#` title, an intro that links neighbouring pages, `##`/`###` sections, GitHub alerts, no front matter.
+- Every bash/sh block in `docs/firmware/` carries a `ci:` marker.
+- New CI actions are pinned by SHA with a release comment, copied from `ci.yml`.
+
+**Never:**
+- Edit `.github/workflows/ci.yml`. That forces run-all, including the slow Images job (precedent: specs 10-2 and 10-3).
+- Make the Docs workflow a required check, or fetch external links.
+- Edit `sprint-status.yaml`.
+- Change firmware behaviour, wire bytes, crypto-spec values or proto field numbers. Comment-only proto changes are fine.
+- Hand-edit the generated `packages/rs/crypto/src/spec.rs`. Change the generator input and regenerate.
+- Add a docs site generator.
+- Add `#[allow(missing_docs)]` or `#[allow(rustdoc::…)]` to silence a gap.
+
+
+
+## Code Map
+
+- `_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md:175-390` -- AD-9/10/11/12/17/24/25 texts; Deferred section at line 577. Link target; read-only.
+- `_bmad-output/specs/spec-coldframe/device-hardware.md:12-21,57-64` -- H-1..3, N-1..3, power budget. Read-only.
+- **ESP-NOW and channel following:**
+ - `packages/rs/transport/src/transport.rs`: module doc 1-42; constants `ACK_WINDOW_MS`/`PROBE_WAIT_MS`/`RESCAN_AFTER_MISSES`/`SCAN_BACKOFF_WAKES`; `probe`, `find_hub`, `listen`, `run`
+ - `packages/rs/transport/src/link.rs` (`LinkState`, `CFLK`)
+ - `packages/rs/protocol/src/radio.rs`
+ - `packages/rs/hal/src/datagram.rs`
+ - `apps/rs/node/src/board/espnow.rs`; `apps/rs/hub/src/board/espnow.rs` (`channel: None`)
+ - `packages/rs/uplink/src/relay.rs`
+- **BLE setup and PoP:**
+ - `packages/rs/crypto/src/setup.rs` (`derive_keys`, `SetupSession`)
+ - `packages/rs/setup/src/{lib,code,framing,session,store,service,node,app}.rs`
+ - `packages/rs/crypto/src/hpke.rs`
+ - `apps/rs/node/src/main.rs::setup_mode`; `apps/rs/{hub,node}/src/board/ble.rs`
+ - `packages/proto/coldframe/setup/v1/setup.proto`
+- **Identity:**
+ - `packages/rs/crypto/src/{keys,identity}.rs` (`provision_efuse`, `provision_dev`, `CFID`)
+ - `packages/rs/hal/src/{efuse,hmac}.rs`
+ - `apps/rs/{hub,node}/src/board/{efuse,hmac}.rs`, which are the only `unsafe` code
+ - `compile_error!` dev-mode guard in `apps/rs/node/src/main.rs:62-65`
+- **Sealing:**
+ - `packages/rs/crypto/src/frame.rs` (`nonce`, `aad`, `seal`, `open_checked`, `ReplayWindow`)
+ - `packages/rs/transport/src/frame.rs` (`seal_uplink`, `open_downlink`)
+ - `packages/rs/sensing/src/counter.rs` (`ReservedCounter`)
+ - `packages/rs/transport/src/buffer.rs`
+ - `packages/proto/coldframe/device/v1/envelope.proto`
+- **Deep sleep:**
+ - `apps/rs/node/src/main.rs` (`WATCHDOG_MS`, `sleep`, `fail`, 80 MHz)
+ - `apps/rs/node/src/board/{sleep,rtc,button}.rs`
+ - `packages/rs/sensing/src/wake.rs` (`WAKE_PERIOD_MS`, `sleep_ms`, `run_wake`)
+ - `docs/build/power.md` (link)
+- **Buttons:** `packages/rs/sensing/src/button.rs` (`DEBOUNCE_MS` 50, `LONG_PRESS_MS` 3000, `classify_press`, `wake_plan`, `sleep_after`); `apps/rs/node/src/board/pins.rs` (GPIO7).
+- **Coexistence:** `docs/spikes/hub-radio-coexistence.md`, with the verdict at about line 400 and findings F-3..F-7. Code: `packages/rs/uplink/src/bssid.rs::select_bssid`, `apps/rs/hub/src/board/wifi.rs::security`, `apps/rs/hub/src/main.rs` (heap, BLE before Wi-Fi). Read-only.
+- `apps/rs/node/README.md`, `apps/rs/hub/README.md`, `apps/rs/README.md`, `packages/rs/README.md` -- existing crate and board docs; link to them rather than duplicating.
+- **Rustdoc fixes:**
+ - `packages/crypto-spec/scripts/lib/spec.ts:209` -- `sensorIdNamespace` doc string embeds the bare URL. Regenerate with `pnpm --filter @coldframe/crypto-spec run generate`, which rewrites the Rust, C# and Kotlin outputs. `run check` is the CI drift gate.
+ - `packages/rs/sensing/src/lib.rs:10` -- the `[`env`]` link is ambiguous.
+ - `packages/rs/protocol/build.rs` -- has `compiler.include_source_info(false)`. Setting it to `true` makes micropb-gen 0.6.0 emit proto comments as docs (verified).
+ - `packages/rs/protocol/src/lib.rs:26-45` -- `missing_docs` is allowed on `mod generated`. `pub use …::v1_ as setup_v1`/`device_v1` makes the undocumented `v1_` package modules public.
+ - With source info on and the allow removed, exactly 10 gaps remain: the 2 `v1_` modules, plus 8 enum values without a comment (`DEVICE_KIND_*`, `WIFI_SECURITY_*` in setup.proto:79-104, `WIFI_STATUS_UNSPECIFIED` 143, `SETUP_ERROR_CODE_UNSPECIFIED` 199).
+- `.github/workflows/docs.yml` -- add the job here. The `Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2` pin is at `ci.yml:183`. The host toolchain comes from `rustup toolchain install` reading `rust-toolchain.toml`.
+- `docs/check-links.sh` -- already scans `docs/**`, so new pages are covered. `docs/check-commands.sh:45-49` -- default glob is `docs/build/*.md` only.
+- `README.md:10-17` ("Start here") -- the docs index.
+
+## Tasks & Acceptance
+
+**Execution:**
+- `packages/proto/coldframe/setup/v1/setup.proto` -- add a one-line comment to each undocumented enum value. Comment-only change; buf lint and breaking must stay green.
+- `packages/rs/protocol/build.rs` -- `include_source_info(true)`, and update the module doc to say proto comments become rustdoc.
+- `packages/rs/protocol/src/lib.rs` -- drop `missing_docs` from the generated module's allow list. Expose `setup_v1`/`device_v1` as documented modules that glob re-export the generated package modules (`pub mod setup_v1 { pub use super::generated::…::v1_::*; }`), keeping every existing path such as `setup_v1::SetupMessage_::Body` valid. Update the crate doc lines 6-10 to match.
+- `packages/crypto-spec/scripts/lib/spec.ts` -- put the namespace name in a code span so no bare URL reaches the docs. Then regenerate the Rust, C# and Kotlin outputs.
+- `packages/rs/sensing/src/lib.rs` -- link the module unambiguously (`mod@env`).
+- `docs/firmware/README.md` -- the index. It gives:
+ - a reading order
+ - a crate map (the 7 `packages/rs` crates and the 2 firmware crates, one line each, linking the existing READMEs)
+ - the two toolchains: host 1.97 versus esp, with `rust-version` 1.95
+ - how to open the API docs (`cargo doc --no-deps -p coldframe-… --open`, in a `` block)
+ - links to the 7 topic pages
+- `docs/firmware/espnow.md`, `ble-setup.md`, `identity.md`, `sealing.md`, `deep-sleep.md`, `buttons.md`, `radio-coexistence.md` -- one page per AC topic. Each page has "How it works", the key constants and timings, and a "Where to read the code" list (path plus symbol), and links the governing AD/N/H items.
+- `docs/check-commands.sh` -- add `docs/firmware/*.md` to the default files, and update the header comment. Then any shell block there must carry a marker; check `docs/test.sh` still passes.
+- `.github/workflows/docs.yml` -- add a `rustdoc` job ("Rust docs"). It checks out, runs `rustup toolchain install` and rust-cache, then runs `cargo doc --no-deps --locked -p coldframe-hal -p coldframe-crypto -p coldframe-protocol -p coldframe-setup -p coldframe-uplink -p coldframe-sensing -p coldframe-transport` with `RUSTDOCFLAGS: -D warnings`. Give it a reason comment citing Story 10.4, and update the header comment.
+- `README.md` -- add a "Firmware reference" bullet under "Start here". Also add a link line in `apps/rs/README.md` and `packages/rs/README.md`.
+
+**Acceptance Criteria:**
+- Given `docs/firmware/`, when a maker reads it, then each of the 7 AC topics has a page that explains the mechanism with backticked repo paths and symbols, and every one of those paths passes `docs/check-links.sh`.
+- Given the 7 firmware crates, when `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked -p …` runs, then it succeeds, and the generated `setup_v1`/`device_v1` items carry docs (no `missing_docs` allow remains in `packages/rs`).
+- Given a PR touching `packages/**` or `docs/**`, when the Docs workflow runs, then the new job runs that `cargo doc` command and fails on any rustdoc warning.
+- Given the crypto-spec change, when `pnpm --filter @coldframe/crypto-spec run check` runs, then nothing is stale.
+
+## Spec Change Log
+
+## Review Triage Log
+
+### 2026-10-20 — Review pass
+- verdicts: 18 findings — high 0, medium 4, low 13, false 1, maybe-false 0
+- findings:
+ - `[low]` `[patch]` identity.md calls the Hub `board/efuse.rs` "the same file" as the Node's, but the module comments differ — fixed: it now says same code, only the module comment differs. The Hub file's "only `unsafe` code of the Hub" comment and the `apps/rs/hub/Cargo.toml:60` lint comment are stale since `board/rtc.rs:41` has an `unsafe` block; that is pre-existing, so it is deferred.
+ - `[medium]` `[patch]` ble-setup.md constants table: `SERVICE_UUID` shown as the text UUID, but it is a `u128` (`SERVICE_UUID_STR` is the text, `packages/rs/setup/src/lib.rs:45,54`), and `MAX_PAYLOAD` lists only the Hub copy (`apps/rs/node/src/board/ble.rs:35` also has one) — fixed: the row now names `SERVICE_UUID_STR` and the u128 form, and lists both ble.rs files.
+ - `[low]` `[reject]` the rustdoc job lists the 7 crates by hand, so an 8th crate could miss the gate — `ci.yml`'s required `rust` job lists the same crates the same way. A new crate is rare, and the fix (`--workspace` plus excludes, or cargo metadata) is just another list or adds complexity.
+ - `[low]` `[reject]` the firmware crates `apps/rs/{node,hub}` have no `cargo doc` in CI — the story AC scopes the gate to "public items in `packages/rs/*`". Building esp-toolchain docs in CI is new CI surface beyond the AC.
+ - `[low]` `[reject]` the README's `cargo doc --open` block is `ci:manual` and widens `ci:manual` to "a browser" — this is a documentation choice. A `ci:run` would run the host toolchain inside the esp-only `commands` job, which adds risk for a cosmetic gain.
+ - `[low]` `[patch]` the `docs.yml` header and the `check-commands.sh` line 2 header only described the build guide — fixed: both headers were reworded to cover `docs/build` and `docs/firmware`, and the `--help` line range was adjusted.
+ - `[low]` `[reject]` the cited symbols and constant values are not machine-checked — a new checker adds complexity, and the 10-2 review already rejected this ("no automated check of documented facts").
+ - `[low]` `[reject]` there is no page on the Hub→Server uplink (heartbeats, `hub-auth/v1` signing) — the story AC enumerates 7 topics and this is not one of them. A reader meets the gap only beyond the story's promise.
+ - `[low]` `[reject]` there is no page on the Node measurement path (probe, BME680, battery) — this is outside the 7 AC topics, for the same reason as the row above.
+ - `[low]` `[patch]` constants tables use different column layouts — fixed: `buttons.md` was reordered to `Constant | Value | Where`. The descriptive table in `radio-coexistence.md` was left as is, because its rows are findings, not symbols.
+ - `[low]` `[patch]` the `packages/rs/protocol/build.rs` doc says an uncommented proto item "fails `cargo doc`", but `missing_docs` is only `warn` — fixed: it now says the item warns, and fails under CI's `-D warnings` (clippy, rustdoc).
+ - `[low]` `[reject]` every page links the dated `_bmad-output` spine, and there is no ID glossary — the links resolve and `check-links` guards them. Adding a glossary is new content with little everyday harm.
+ - `[medium]` `[patch]` the `docs.yml` path filters omit the root `Cargo.toml`, `Cargo.lock` and `rust-toolchain.toml`, so the Rust docs job skips lint, dependency or toolchain-only PRs — fixed: the three paths were added to both the `pull_request` and the `push` lists.
+ - `[low]` `[patch]` the `coldframe-hal` `mock` feature items are not covered by the rustdoc gate — fixed: `--features coldframe-hal/mock` was added to the job. It was verified locally with `-D warnings`.
+ - `[medium]` `[patch]` the new default `docs/firmware/*.md` glob in `check-commands.sh` had no test — fixed: a fixture case was added to `docs/test.sh` (50/50 pass). A mutation run that reverted the glob made the test fail (49/1).
+ - `[medium]` `[patch]` (same root cause as the path-filter row) the rustdoc job does not run when only root Cargo files change — fixed by the path-filter patch above.
+ - `[low]` `[reject]` intent audit: the spec reads "the firmware crates" as the 7 `packages/rs` crates, which is narrower than `apps/rs` — this is the same as the "no `cargo doc` for `apps/rs/{node,hub}`" row above, and the AC text itself says `packages/rs/*`.
+ - `[false]` `[reject]` intent audit: the work is uncommitted — this is the review stage. The commit happens at finalization through gzp-pipeline (the On Complete hook owns commit, push and PR).
+
+### 2026-10-20 — Review pass
+- verdicts: 20 findings — high 0, medium 0, low 15, false 5, maybe-false 0
+- findings:
+ - `[false]` `[reject]` the rustdoc gate cannot block a merge but the docs say it does — `docs/firmware/README.md:63` says only "CI builds the docs with warnings as errors", which the Docs job does; no page claims the check is required, and the intent forbids making it one.
+ - `[low]` `[patch]` the new `WIFI_SECURITY_WPA2_PERSONAL` proto comment says "WPA2 with a passphrase", but `security()` in `apps/rs/hub/src/board/wifi.rs:41-45` also maps `Wpa` and `WpaWpa2Personal` to it — fixed: the comment-only proto change now reads "WPA or WPA2 with a passphrase, including mixed WPA/WPA2"; the regenerated rustdoc of `WifiSecurity` shows it.
+ - `[low]` `[patch]` `deep-sleep.md` calls AD-17 "bounded radio time", but the spine titles AD-17 "Frame sequence and replay" (the spine's own traceability table at line 571 uses the other label) — fixed: the page now gives the real title and says the spine also cites it for bounded radio time.
+ - `[low]` `[reject]` the spec's Tasks, AC and Verification command omit `--features coldframe-hal/mock` that the job runs — the fix is an edit to this build's spec; the job's wider check is already recorded in the first pass.
+ - `[low]` `[reject]` the spec's Code Map still says `board/{efuse,hmac}.rs` are the only `unsafe` code — the fix is an edit to this build's spec; the code-side staleness is already deferred (DW-122).
+ - `[false]` `[reject]` the spec front matter is stale (`in-review`, iteration 0) — `in-review` is the correct state during this review stage, and `review_loop_iteration` counts bad_spec loopbacks, of which there were none.
+ - `[low]` `[patch]` `identity.md` says both crates refuse dev-mode in release, then quotes only the Node's `compile_error!` text — fixed: the lead-in now says the excerpt is the Node's guard and the Hub's says "a release Hub" (`apps/rs/hub/src/main.rs:38-40`).
+ - `[low]` `[reject]` code excerpts copied into the pages (`sleep_ms`, `derive_keys`, `compile_error!`) have no drift check — the excerpts match the code today, and a drift checker is the same new complexity the first pass and the 10-2 review rejected.
+ - `[low]` `[reject]` the pages hard-code esp-hal 1.2.2, esp-radio 1.0.0-beta.1, Rust 1.97 and 1.95 — all four match `apps/rs/*/Cargo.lock`, `rust-toolchain.toml` (1.97.1), `packages/rs/*/Cargo.toml` (`rust-version = "1.95"`) and `flash.md`; the drift is hypothetical and the README already points to `flash.md` for CI pins.
+ - `[low]` `[patch]` the `buttons.md` constants table lists `take_pins` as if it were a constant — fixed: the row now names `take_pins!` (macro); GPIO7 is set in that macro at `apps/rs/node/src/board/pins.rs:86`.
+ - `[low]` `[reject]` no test pins the `check-commands.sh --help` range `2,24p` or the "or a browser" wording — the edge-case layer confirmed the range matches the 24-line header; a test for help text adds surface for a cosmetic output.
+ - `[low]` `[patch]` `ble-setup.md` says payloads are ATT MTU minus 3 while the `coldframe-setup` crate docs say a fragment is at most ATT MTU − 4 — both are right (`max_payload` subtracts 3 at `apps/rs/hub/src/board/ble.rs:345`; the 1-byte header sits inside it) — fixed: one sentence now explains the difference.
+ - `[low]` `[patch]` `apps/rs/node/README.md` and `apps/rs/hub/README.md` do not link to the new reference — fixed: each README's opening paragraph now links `docs/firmware/README.md`.
+ - `[low]` `[reject]` intent audit: no check that cited symbols, constants and behaviours are true of the code — carried from the first pass ("cited symbols and constant values are not machine-checked"); this pass also checked by script that every backticked identifier in `docs/firmware/` exists in `apps`, `packages` or `tests` (only `HMAC_UP`, the eFuse purpose name, is prose; the page pairs it with `KeyPurpose::HmacUp`).
+ - `[low]` `[reject]` intent audit: the firmware crates `apps/rs/{node,hub}` are outside the rustdoc gate — carried from the first pass; the AC scopes the gate to `packages/rs/*`.
+ - `[low]` `[reject]` intent audit: the gate covers only the hand-listed crates and features — carried from the first pass ("the rustdoc job lists the 7 crates by hand"); `mock` is the only non-default feature today.
+ - `[false]` `[reject]` intent audit: `-D warnings` proves an item has a doc, not that the doc is useful — no defect at a cited location and no named harm; the reviewed wire docs describe each value.
+ - `[false]` `[reject]` intent audit: the AC says "no warnings" but the check is not required — the intent's Never list forbids making the Docs workflow required, so the advisory job is the only allowed reading (same claim as the first row).
+ - `[low]` `[reject]` intent audit: the README's `cargo doc --open` block is `ci:manual`, widening that marker to "a browser" — carried from the first pass (same location and claim).
+ - `[false]` `[reject]` intent audit: the working tree has an uncommitted `sprint-status.yaml` edit (10-4 → done), against the Never rule — that edit is the orchestrator's own bookkeeping, not part of this change; this run neither wrote nor reverted it.
+
+## Design Notes
+
+**Why the gate lives in `docs.yml`:** the `rust` job in `ci.yml` is the required check. But any `ci.yml` edit forces run-all, which includes the Images job that is known to be slow and flaky, and specs 10-2 and 10-3 both forbid editing it. `docs.yml` is already path-filtered on `packages/**`. The AC only asks that `cargo doc` runs in CI. Clippy `-D warnings` in the required `rust` job already enforces `missing_docs` on hand-written items.
+
+**Why glob re-export modules:** a `#[doc]` on `pub use … as setup_v1` cannot document the original `v1_` module. A wrapper module is documented, makes `v1_` unreachable publicly (so the lint no longer fires on it), and keeps every item path.
+
+**Line numbers drift:** docs name symbols, and use `path:line` only where a symbol does not exist. `check-links.sh` strips `:N` anyway.
+
+## Verification
+
+**Commands:**
+- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked -p coldframe-hal -p coldframe-crypto -p coldframe-protocol -p coldframe-setup -p coldframe-uplink -p coldframe-sensing -p coldframe-transport` -- expected: success, no warnings
+- `cargo clippy --workspace --all-targets --locked -- -D warnings && cargo test --workspace --locked` -- expected: green
+- `pnpm --filter @coldframe/crypto-spec run check` -- expected: nothing stale
+- `docs/test.sh && docs/check-links.sh && docs/check-commands.sh --list` -- expected: pass, with the new pages listed
+- `buf lint` / `packages/proto/check-compat.sh` if `buf` is installed -- expected: pass
+- `actionlint .github/workflows/docs.yml` if available -- expected: clean
+- If the esp toolchain is available: `cargo build --release` in `apps/rs/node` and `apps/rs/hub` -- expected: still builds after the protocol module change
+
+## Auto Run Result
+
+Status: done
+
+**Summary.** Follow-up review pass on the firmware learning reference (first pass merged as PR #126). Four review layers ran over the full diff since the baseline: blind hunter, edge-case hunter, verification gap and intent alignment. The edge-case and verification-gap layers found nothing. The edge-case layer also re-checked the cited constants named as the open risk by the first pass (scan back-off, `sleep_ms`, payload clamp, setup watchdog, `unsafe` locations) and found them correct. A script check confirmed that every backticked identifier in `docs/firmware/` exists in the code. Six low-severity wording fixes were applied; no high or medium finding survived verification.
+
+**Files changed.**
+- `packages/proto/coldframe/setup/v1/setup.proto` -- the `WIFI_SECURITY_WPA2_PERSONAL` comment now covers WPA and mixed WPA/WPA2 (comment-only).
+- `docs/firmware/deep-sleep.md` -- AD-17 is cited by its real title.
+- `docs/firmware/identity.md` -- the release-guard excerpt is labelled as the Node's.
+- `docs/firmware/ble-setup.md` -- one sentence reconciles "ATT MTU minus 3" with the crate's "ATT MTU − 4".
+- `docs/firmware/buttons.md` -- the `take_pins!` row is named as a macro.
+- `apps/rs/node/README.md`, `apps/rs/hub/README.md` -- link to the firmware reference.
+
+**Review findings.** 20 findings: high 0, medium 0, low 15, false 5.
+- **Patches applied (6, all low):** WPA2 proto comment, AD-17 title, Node guard label, MTU sentence, `take_pins!` row, README links.
+- **Deferred:** none new.
+- **Rejected:**
+ - Spec Verification command lacks `--features coldframe-hal/mock`, and the spec Code Map's stale `unsafe` claim: both fixes edit this build's spec.
+ - Copied code excerpts without a drift check: they match the code today, and a checker was already rejected.
+ - Hard-coded versions: all four match the lockfiles and toolchain files.
+ - No test for the `--help` range: the range was confirmed correct, and the output is cosmetic.
+ - Carried from the first pass: facts not machine-checked, `apps/rs` not gated, hand-listed crates, `ci:manual` for the browser block.
+ - False: the gate claimed as blocking (no page says that), stale front matter (correct for the stage), doc usefulness (no named harm), required-check AC reading (forbidden by the intent), and the `sprint-status.yaml` edit (orchestrator bookkeeping).
+
+**Follow-up review recommended: false.** This is a follow-up pass and no high finding was patched. Patched: high 0, medium 0, low 6.
+
+**Verification.** All of the following passed:
+- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked --features coldframe-hal/mock -p` over the 7 crates: no warnings; the new proto comment appears in the `WifiSecurity` rustdoc.
+- `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings`: clean.
+- `cargo test --workspace --locked`: 350 passed, 0 failed.
+- `docs/check-links.sh`: 821 links and paths in 35 files resolve.
+- `docs/check-commands.sh --list`: OK.
+- `docs/test.sh`: 50 passed, 0 failed.
+
+`buf` and `actionlint` are not installed locally, so buf lint/breaking and actionlint did not run. The proto change is comment-only. The esp toolchain build was not re-run; no firmware code changed.
+
+**Residual risks.**
+- The `Rust docs` job is still not a required check.
+- Cited constants and code excerpts can drift from the code; only paths and identifier existence are checked.
diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml
index c09ccfb9..510a7f5f 100644
--- a/_bmad-output/implementation-artifacts/sprint-status.yaml
+++ b/_bmad-output/implementation-artifacts/sprint-status.yaml
@@ -115,6 +115,6 @@ development_status:
10-1-releases-with-one-version-and-firmware-binaries: awaiting-operator
10-2-build-a-node-and-a-hub-from-the-docs: awaiting-operator
10-3-deploy-coldframe-at-home-and-build-my-own-apps: awaiting-operator
- 10-4-firmware-learning-reference: backlog
+ 10-4-firmware-learning-reference: done
10-5-someone-else-reproduces-coldframe: backlog
epic-10-retrospective: optional
diff --git a/apps/rs/README.md b/apps/rs/README.md
index 255a1b7e..aadf7ab8 100644
--- a/apps/rs/README.md
+++ b/apps/rs/README.md
@@ -23,4 +23,7 @@ cargo build --features dev-mode
./check-image.sh
```
+To learn how the firmware works (ESP-NOW, BLE setup, eFuse keys, sealing, deep sleep), read the
+[firmware reference](../../docs/firmware/README.md).
+
On-device behaviour is checked by hand against the checklists in [`docs/bench`](../../docs/bench).
diff --git a/apps/rs/hub/README.md b/apps/rs/hub/README.md
index e6a578ce..8a29da04 100644
--- a/apps/rs/hub/README.md
+++ b/apps/rs/hub/README.md
@@ -1,6 +1,7 @@
# coldframe-hub
-Hub firmware for the ESP32-S3, in `no_std` Rust on esp-hal.
+Hub firmware for the ESP32-S3, in `no_std` Rust on esp-hal. The
+[firmware reference](../../../docs/firmware/README.md) explains its mechanisms topic by topic.
Story 3.2 brings the chip up with a hardware-bound identity (AD-12):
diff --git a/apps/rs/node/README.md b/apps/rs/node/README.md
index 05c6b5ed..ef406aa4 100644
--- a/apps/rs/node/README.md
+++ b/apps/rs/node/README.md
@@ -1,6 +1,7 @@
# coldframe-node
-Node firmware for the ESP32-S3, in `no_std` Rust on esp-hal.
+Node firmware for the ESP32-S3, in `no_std` Rust on esp-hal. The
+[firmware reference](../../../docs/firmware/README.md) explains its mechanisms topic by topic.
Story 4.1 gives the Node its wake cycle: every 15 minutes it wakes, takes one Reading per Sensor
with one shared `measured_at` and a `reading_seq` that never repeats, reads its battery and charger,
diff --git a/docs/check-commands.sh b/docs/check-commands.sh
index ae7570fc..8215856a 100755
--- a/docs/check-commands.sh
+++ b/docs/check-commands.sh
@@ -1,18 +1,21 @@
#!/usr/bin/env bash
-# Runs the commands the build guide documents (Story 10.2). Firmware builds only, never flashing.
+# Runs the commands the build guide (Story 10.2) and the firmware reference (Story 10.4) document.
+# Firmware builds only, never flashing.
#
# docs/check-commands.sh [--list] [file...]
#
-# The default files are docs/build/*.md, relative to the repository root unless absolute. Every
-# fenced bash/sh block must be preceded by a marker (blank lines between them are allowed):
+# The default files are docs/build/*.md and docs/firmware/*.md (Story 10.4), relative to the
+# repository root unless absolute. Every fenced bash/sh block must be preceded by a marker (blank
+# lines between them are allowed):
#
# the block runs in that directory, after
# `source ~/export-esp.sh` when it exists (the guide's
# install block creates it), with errexit and pipefail;
# the block needs a board: only `espflash --help`
# runs, for every espflash subcommand in it;
-# the block needs a phone, Xcode, the Android SDK or a cluster
-# (Story 10.3): nothing runs; --list shows it as manual.
+# the block needs a phone, Xcode, the Android SDK, a cluster
+# (Story 10.3) or a browser (Story 10.4): nothing runs;
+# --list shows it as manual.
#
# An unmarked block, a marker that no bash/sh block follows, an unknown ci: marker or a ci:run
# directory that does not exist fails. --list prints every block with its marker and runs nothing.
@@ -31,7 +34,7 @@ for arg in "$@"; do
case ${arg} in
--list) list=true ;;
-h | --help)
- sed -n '2,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ sed -n '2,24p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
-*)
@@ -43,7 +46,7 @@ for arg in "$@"; do
esac
done
if [[ ${#files[@]} -eq 0 ]]; then
- for file in "${repo}"/docs/build/*.md; do
+ for file in "${repo}"/docs/build/*.md "${repo}"/docs/firmware/*.md; do
[[ -f ${file} ]] && files+=("${file}")
done
fi
@@ -199,7 +202,7 @@ for file in "${files[@]}"; do
echo "${where}: ci:manual"
indent "${block}"
else
- echo "==> ${where}: ci:manual (not run: needs a phone, Xcode, the Android SDK or a cluster)"
+ echo "==> ${where}: ci:manual (not run: needs a phone, Xcode, the Android SDK, a cluster or a browser)"
fi
;;
esac
diff --git a/docs/firmware/README.md b/docs/firmware/README.md
new file mode 100644
index 00000000..79877efc
--- /dev/null
+++ b/docs/firmware/README.md
@@ -0,0 +1,74 @@
+# Firmware reference
+
+The Coldframe firmware is written in Rust for the ESP32-S3, and it is meant to be read. This
+reference explains how the Node and the Hub use ESP-NOW, BLE provisioning, eFuse keys, frame
+sealing and deep sleep, and points to the code that does it. To build and flash the firmware, use
+the [build guide](../build/README.md) instead; this reference assumes you have the repository open
+next to it.
+
+Each page explains one mechanism in prose, lists its key constants and timings, and ends with
+**Where to read the code**: repository paths plus the symbol to look for. The architecture
+decisions (AD-n) are in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md),
+and the Node and Hub requirements (N-n, H-n) in the
+[device hardware spec](../../_bmad-output/specs/spec-coldframe/device-hardware.md). Where those
+planning documents and the code disagree, the pages describe the code and say so.
+
+## Reading order
+
+1. [Identity](identity.md): the eFuse root key, the HKDF purpose keys and the Device ID.
+2. [Sealing](sealing.md): ChaCha20-Poly1305 frames, the frame counter and the replay window.
+3. [ESP-NOW](espnow.md): how a Node finds its Hub, sends a burst and follows the Hub's channel.
+4. [Deep sleep](deep-sleep.md): the Node's wake cycle and the power budget.
+5. [Buttons](buttons.md): the setup button, short press and long press.
+6. [BLE setup](ble-setup.md): the custom setup protocol and its proof-of-possession code.
+7. [Radio coexistence](radio-coexistence.md): Wi-Fi, ESP-NOW and BLE on one Hub radio.
+
+Identity and sealing come first because every later page sends or stores something sealed with
+those keys.
+
+## Crate map
+
+The logic lives in host-testable crates under `packages/rs`, behind the hardware traits of
+`coldframe-hal` (AD-24). Only the `src/board` module of a firmware crate names esp-hal types.
+[`packages/rs/README.md`](../../packages/rs/README.md) and
+[`apps/rs/README.md`](../../apps/rs/README.md) describe each crate in full.
+
+| Crate | Folder | What to learn from it |
+| --- | --- | --- |
+| `coldframe-hal` | `packages/rs/hal` | The hardware traits (radio, eFuse, HMAC, flash, BLE, Wi-Fi) and their host mocks |
+| `coldframe-crypto` | `packages/rs/crypto` | The key hierarchy, frame sealing, the replay window, HPKE enrolment, the setup session keys |
+| `coldframe-protocol` | `packages/rs/protocol` | The wire messages generated from `packages/proto`, and the ESP-NOW message codec |
+| `coldframe-setup` | `packages/rs/setup` | The BLE setup service: setup code, framing, session state machine, stored records |
+| `coldframe-uplink` | `packages/rs/uplink` | The Hub's Wi-Fi uplink: access-point choice, heartbeats, the relay of Node frames |
+| `coldframe-sensing` | `packages/rs/sensing` | The Node's wake cycle, the button, the flash-reserved counter |
+| `coldframe-transport` | `packages/rs/transport` | The Node's ESP-NOW transport, report buffer and channel following |
+| `coldframe-hub` | `apps/rs/hub` | The Hub firmware: boot order, board drivers. See [its README](../../apps/rs/hub/README.md) |
+| `coldframe-node` | `apps/rs/node` | The Node firmware: one wake per boot, board drivers. See [its README](../../apps/rs/node/README.md) |
+
+## Two toolchains
+
+- **Host, Rust 1.97.** The Cargo workspace in the repository root holds the seven `packages/rs`
+ crates and their tests in `tests/rs`. `rust-toolchain.toml` pins the host toolchain, and the
+ workspace runs clippy, the tests and the API docs with it.
+- **esp, Rust 1.95.** The firmware crates in `apps/rs/hub` and `apps/rs/node` build for
+ `xtensa-esp32s3-none-elf` with the `esp` toolchain from `espup`, so they stay outside the
+ workspace. Because the firmware links the `packages/rs` crates, those crates declare
+ `rust-version = "1.95"` and must not use anything newer. [flash.md](../build/flash.md#toolchain)
+ has the versions CI pins.
+
+## Open the API docs
+
+Every public item of the `packages/rs` crates is documented, including the wire types generated
+from the `.proto` comments. CI builds the docs with warnings as errors. To browse one crate's docs
+from the repository root:
+
+
+
+```sh
+cargo doc --no-deps -p coldframe-transport --open
+```
+
+Replace `coldframe-transport` with any crate of the map above that lives in `packages/rs`. The
+firmware crates themselves need the esp toolchain: run `cargo doc --no-deps --open` in `apps/rs/node` or
+`apps/rs/hub` after `source ~/export-esp.sh`.
diff --git a/docs/firmware/ble-setup.md b/docs/firmware/ble-setup.md
new file mode 100644
index 00000000..6a38b4af
--- /dev/null
+++ b/docs/firmware/ble-setup.md
@@ -0,0 +1,216 @@
+# BLE setup and the proof-of-possession code
+
+A new Hub or Node knows nothing about your Wi-Fi, your Site or your Server. The app tells it over
+Bluetooth Low Energy, through a small custom GATT service. Before any setting crosses the air, the
+two sides agree on session keys, and the Device's setup code, the proof-of-possession (PoP) code, is
+mixed into those keys. The code itself is never sent. This page follows one setup session from the
+first BLE write to the stored record. It is part of the [firmware reference](README.md); the
+previous page, [buttons.md](buttons.md), shows how a long press opens the Node's setup window, and
+the next, [radio-coexistence.md](radio-coexistence.md), covers BLE, Wi-Fi and ESP-NOW on one radio.
+
+The rules come from AD-25 (the setup protocol) and AD-12 (the key hierarchy and enrolment) in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md).
+To try it on a bench, use the [Hub setup checklist](../bench/hub-setup-checklist.md) and the
+[Node setup checklist](../bench/node-setup-checklist.md).
+
+## How it works
+
+### The setup code
+
+On first boot the Device draws 40 bits from its TRNG and writes them as 8 characters of the
+Crockford base32 alphabet (digits and uppercase letters without I, L, O and U). See
+`packages/rs/setup/src/code.rs` (`SetupCode::generate`). The code goes into the `cf_setup` flash
+partition once and is then read back on every boot. It is shown in one place only: a plain serial
+line `setup code=XXXXXXXX`, printed with `esp_println`, never as a log record.
+
+- The Hub prints it on every boot while it is unprovisioned, and never once it is provisioned.
+- The Node draws it on its first long press and prints it each time it enters setup mode.
+
+`SetupCode` deliberately implements neither `Debug` nor `Display`, so it cannot leak into a log by
+accident. A corrupt code record is never regenerated, because the old code may already be on a
+sticker: the Hub halts, and the Node logs an error and skips setup mode.
+
+### The GATT service and framing
+
+The service UUID is `c01d0001-5e70-4c0d-8f00-00000000c0de`. The app writes to the `…0002`
+characteristic and the Device answers with notifications on `…0003` (`SERVICE_UUID` and friends in
+`packages/rs/setup/src/lib.rs`). The Device advertises the service UUID, with the name
+`Coldframe Hub XXXX` or `Coldframe Node XXXX` (the first four Device ID hex digits) in the scan
+response. The BLE address is a static random address derived from the Device ID.
+
+A protocol frame can be larger than one BLE write, so each write or notification is
+`header(1) ‖ fragment`. Header `0x01` marks the last fragment of a frame, `0x00` says more follow.
+`packages/rs/setup/src/framing.rs` splits frames (`fragments`) and joins them again
+(`Reassembler`). A frame that grows past 1152 bytes is dropped, together with its remaining
+fragments. The board adapter sizes payloads from the connection's ATT MTU minus 3, between 20 and 248
+bytes (`max_payload` in `apps/rs/hub/src/board/ble.rs`). The header byte sits inside that payload,
+so a fragment carries at most ATT MTU − 4 bytes, as the `coldframe-setup` crate docs say.
+
+### Opening the session
+
+The first frame on a connection is a plaintext `SessionHello`: protocol version 1 and the app's
+ephemeral X25519 public key. The Device draws its own ephemeral private key from the TRNG, answers
+with a plaintext `SessionHelloReply` carrying its public key, and both ends now compute the same
+X25519 shared secret. A malformed hello, a wrong version or a key that is not 32 bytes closes the
+connection without a reply.
+
+### Mixing in the PoP code
+
+`packages/rs/crypto/src/setup.rs` (`derive_keys`) turns the shared secret into two keys:
+
+```rust
+// salt = the setup code, uppercased; info = label ‖ app_pub ‖ device_pub
+let mut extract = HkdfExtract::::new(Some(salt));
+extract.input_ikm(&shared);
+hkdf.expand_multi_info(&[SETUP_LABEL.as_bytes(), app_public, device_public], &mut okm)
+```
+
+The 64 output bytes split in two: bytes 0..32 seal app → Device, bytes 32..64 seal Device → app.
+The X25519 secret alone would give an eavesdropper-proof channel, but not a proof that the app is
+talking to *your* Device. With the code as HKDF salt, only someone who read the code from that
+Device's serial line (or sticker) derives the right keys. Both public keys go into `info`, so the
+keys belong to this one exchange.
+
+### Sealed messages and counters
+
+Every later frame is a `SealedSetupMessage`: protocol version, a `u64` counter and a
+ChaCha20-Poly1305 ciphertext of one `SetupMessage`. `SetupSession` in the same file keeps one
+counter per direction, both starting at 0. The nonce is four zero bytes followed by the counter in
+big-endian; the associated data is the one-byte protocol major. Because each direction has its
+own key, the two counter sequences never share a nonce. `SetupSession::open` refuses a counter
+that is not above every counter it opened before, so a replayed message does not open twice.
+
+### A wrong code
+
+If the user types the wrong code, the two sides derive different keys and the Device cannot open
+the app's first sealed message. `SetupSession::open` reports that first failure as
+`WrongSetupCode`. The Device then still sends one `SetupError` (code `MALFORMED_MESSAGE`), sealed
+under its own keys, and disconnects. The app cannot open that reply either, and that is how it
+knows the code was wrong (`AppError::WrongSetupCode` in `packages/rs/setup/src/app.rs`). Neither
+side learns the other's code. A failure on any *later* message means tampering or replay: the
+Device closes the connection without a word.
+
+> [!NOTE]
+> There is no retry counter or lock-out in the firmware. A wrong code costs one connection; the
+> app reconnects and starts a fresh exchange with fresh keys.
+
+### The message flow
+
+`packages/rs/setup/src/session.rs` (`Session`) is the Device side as a state machine without I/O.
+It takes whole frames and answers with an `Action`: send, close, scan, join, check the Server,
+leave, or persist. `packages/rs/setup/src/service.rs` (`run_setup`) carries those actions out on
+real hardware. The message set lives in `packages/proto/coldframe/setup/v1/setup.proto`.
+
+For a **Hub**, a typical session runs:
+
+1. `IdentityRequest` → `Identity`: the Device ID, kind `HUB` and the firmware version.
+2. `WifiScanRequest` → `WifiScanList`: one entry per SSID (its strongest access point), strongest
+ first, at most 16 networks, each with its security.
+3. `SiteBinding`: a Site ID and the Server URL (`https://` plus a host). No reply on success.
+4. `EnrolmentRequest` → `EnrolmentResponse` (see below).
+5. `WifiConfig` (SSID and passphrase) → `WifiResult`. Accepted only after a binding and an
+ enrolment in the same session. The Hub scans again and joins the strongest *supported* access
+ point for that SSID (`select_bssid` in `packages/rs/uplink/src/bssid.rs`).
+
+After a successful join the Hub checks the Server before it stores anything: DHCP, SNTP and one
+signed heartbeat, all within 40 s (`check_server`). Only an accepted heartbeat stores the
+provisioning record and answers `CONNECTED`. Anything else makes the Hub leave the network, store
+nothing and answer `NO_SERVER`; the session stays open so the user can retry. Once the record is
+stored, a second binding, enrolment or Wi-Fi config is `UNEXPECTED_MESSAGE`.
+
+> [!WARNING]
+> WPA3-only Wi-Fi networks are unsupported. They appear in the scan list as
+> `WIFI_SECURITY_WPA3_ONLY`, but they are not selectable: joining one answers
+> `UNSUPPORTED_SECURITY`. Open, WPA2-Personal and
+> WPA2/WPA3 transition networks work; a transition network is joined with WPA2.
+
+A **Node** (`Session::node`) never touches Wi-Fi: `WifiScanRequest` and `WifiConfig` are
+`UNEXPECTED_MESSAGE`. Its `SiteBinding` must carry a Site ID and a Lot ID and no Server URL, and
+it is only validated: the Server, not the Node, holds the Lot assignment (AD-18). What matters for
+a Node is the enrolment.
+
+### Enrolment: K_dev to the Server
+
+The app fetches the Server's X25519 enrolment key over TLS and sends it in `EnrolmentRequest`,
+with its SHA-256 fingerprint in hex. The Device refuses a mismatch (`FINGERPRINT_MISMATCH`). This
+is an integrity check only, since the fingerprint travels with the key; authenticity comes from
+the app's TLS fetch.
+
+The Device then seals its device key `K_dev` to that key with HPKE (RFC 9180 base mode,
+DHKEM(X25519, HKDF-SHA256), ChaCha20-Poly1305), `info = "coldframe/enrolment/v1"` and the Device
+ID as associated data. See `packages/rs/crypto/src/hpke.rs` (`seal_enrolment`). The ephemeral
+HPKE key comes from 32 fresh TRNG bytes. The app relays the `EnrolmentResponse` to the Server
+unread: it cannot open it, and neither can anyone listening on BLE.
+
+### What is stored
+
+The `cf_setup` partition (`packages/rs/setup/src/store.rs`) has two records, each in its own
+4 KiB sector, each with a magic, a version and a 4-byte SHA-256 check:
+
+- `CFPC` at `0x0000`: the setup code. Written once, on first boot.
+- `CFWP` at `0x1000`: the Hub's provisioning record, version 2: SSID, passphrase, Site ID and
+ Server URL. Written only after a join and a passed Server check. An erased or corrupt sector
+ means unprovisioned. An older version-1 record (no Server address) also counts as corrupt, so
+ such a Hub is set up again.
+
+The Node stores nothing during setup except its setup code: no Site, no Lot and no Wi-Fi settings.
+
+### The Node's setup window
+
+A long press starts setup mode on the Node (`setup_mode` in `apps/rs/node/src/main.rs`). It
+starts the BLE controller, raises the RTC watchdog to the window plus 30 s, and runs
+`run_node_setup` (`packages/rs/setup/src/node.rs`). The window lasts 180 s from the moment it
+starts advertising, measured on the RTC uptime. Each connection idles out after 60 s without a
+write, and every wait is bounded by what is left of the window. After any connection that handed
+out the sealed `K_dev`, the window ends as `enrolled`; otherwise the Node advertises again until
+the deadline. Then the BLE controller is dropped and the Node finishes its normal wake.
+
+The Hub has no window: it advertises for as long as it is unprovisioned, and a connection idles
+out after 300 s.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `CODE_LENGTH` | 8 Crockford base32 characters (40 bits) | `packages/rs/setup/src/code.rs` |
+| `SERVICE_UUID_STR` | `c01d0001-5e70-4c0d-8f00-00000000c0de` (`SERVICE_UUID` is the `u128` form) | `packages/rs/setup/src/lib.rs` |
+| `MAX_FRAME` | 1152 bytes per reassembled frame | `packages/rs/setup/src/lib.rs` |
+| `HEADER_LAST` / `HEADER_MORE` | `0x01` / `0x00` | `packages/rs/setup/src/framing.rs` |
+| `MAX_PAYLOAD` | 248 bytes, the largest BLE payload | `apps/rs/hub/src/board/ble.rs`, `apps/rs/node/src/board/ble.rs` |
+| `SETUP_LABEL` | `coldframe/setup/v1` (HKDF info prefix) | `packages/rs/crypto/src/spec.rs` |
+| `SETUP_OKM_LENGTH` | 64 bytes, two 32-byte direction keys | `packages/rs/crypto/src/spec.rs` |
+| `SETUP_FIRST_COUNTER` | 0, per direction | `packages/rs/crypto/src/spec.rs` |
+| `ENROLMENT_INFO` | `coldframe/enrolment/v1` (HPKE info) | `packages/rs/crypto/src/spec.rs` |
+| `SESSION_IDLE_TIMEOUT_MS` | 300 000 ms (Hub) | `packages/rs/setup/src/lib.rs` |
+| `SETUP_WINDOW_MS` | 180 000 ms (Node) | `packages/rs/setup/src/node.rs` |
+| `NODE_SESSION_IDLE_TIMEOUT_MS` | 60 000 ms (Node) | `packages/rs/setup/src/node.rs` |
+| `SERVER_CHECK_TIMEOUT_MS` | 40 000 ms (Hub Server check) | `packages/rs/uplink/src/lib.rs` |
+| `MAX_NETWORKS` | 16 networks per scan list | `packages/rs/setup/src/lib.rs` |
+| `PROVISIONING_VERSION` | 2 (`CFWP` record) | `packages/rs/setup/src/store.rs` |
+
+## Where to read the code
+
+- `packages/rs/crypto/src/setup.rs` (`derive_keys`, `SetupSession`): the key derivation with the
+ PoP code as salt, the per-direction counters and the wrong-code signal.
+- `packages/rs/crypto/src/hpke.rs` (`seal_enrolment`, `fingerprint`): HPKE sealing of `K_dev` to
+ the Server's enrolment key.
+- `packages/rs/setup/src/lib.rs`: the crate overview and the transport contract.
+- `packages/rs/setup/src/code.rs` (`SetupCode`): drawing, parsing and hiding the setup code.
+- `packages/rs/setup/src/framing.rs` (`fragments`, `Reassembler`): frames over BLE payloads.
+- `packages/rs/setup/src/session.rs` (`Session`): the Device-side state machine; its module docs
+ list every Hub and Node rule.
+- `packages/rs/setup/src/service.rs` (`run_setup`): the Hub's loop that drives link, Wi-Fi, Server
+ check and flash.
+- `packages/rs/setup/src/node.rs` (`run_node_setup`): the Node's time-bounded window.
+- `packages/rs/setup/src/store.rs` (`load_or_create_code`, `store_provisioning`): the `cf_setup`
+ records.
+- `packages/rs/setup/src/app.rs` (`AppClient`): the app side in Rust, used by host tests and the
+ desktop bench client in `tests/rs/setup-client`.
+- `apps/rs/node/src/main.rs` (`setup_mode`): how a long press brings up BLE and the window.
+- `apps/rs/hub/src/board/ble.rs` and `apps/rs/node/src/board/ble.rs` (`BoardSetupLink`): the
+ TrouBLE GATT server and advertising behind the `SetupLink` trait.
+- `packages/proto/coldframe/setup/v1/setup.proto`: the message set, with the wrong-code rule in
+ its header comment.
+- Hub and Node overviews: [apps/rs/hub/README.md](../../apps/rs/hub/README.md),
+ [apps/rs/node/README.md](../../apps/rs/node/README.md),
+ [packages/rs/README.md](../../packages/rs/README.md).
diff --git a/docs/firmware/buttons.md b/docs/firmware/buttons.md
new file mode 100644
index 00000000..e714ec7c
--- /dev/null
+++ b/docs/firmware/buttons.md
@@ -0,0 +1,130 @@
+# The setup button
+
+The Node has one button. A short press asks for a report now; a long press opens the BLE setup
+window. This page shows how a sleeping chip notices the press, how the firmware tells a bounce, a
+short press and a long press apart, and how the Node goes back to sleep afterwards. It is part of
+the [firmware reference](README.md). Read [deep-sleep.md](deep-sleep.md) first, because every press
+is a wake from deep sleep. The setup window itself is on the next page, [ble-setup.md](ble-setup.md).
+
+> [!NOTE]
+> Only the Node has a button. The Hub firmware (`apps/rs/hub/src/main.rs`) reads no button: it
+> advertises the setup service from boot until it is provisioned, and never after.
+
+## How it works
+
+### Wiring: one switch to ground
+
+The button is a momentary switch from GPIO7 to ground. It is active low: the internal pull-up holds
+the pin high while it is released, and pressing it pulls the pin low. GPIO7 is one of the ESP32-S3's
+low-power (RTC) pads, so it can wake the chip from deep sleep. The full pin table is in
+`apps/rs/node/src/board/pins.rs` and in the [device hardware spec](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+### Waking on the button
+
+The Node spends almost all its time in deep sleep, with the CPU and RAM off. Just before it sleeps,
+it arms two wake sources: the RTC timer for the next scheduled measurement, and a low level on the
+button. `BoardButton::arm_wake` asks esp-hal for a wakeup with the low-power path and listens for
+`Event::LowLevel`. esp-hal then assigns the pad to the `ext0` or `ext1` wake source at sleep entry,
+holds the pad and keeps its pull-up on through the sleep.
+
+Every wake from deep sleep is a reset into `main`. `wake_cause` reads why the chip woke: `ext0` or
+`ext1` means the button, the timer means a scheduled wake, and anything else is a cold boot.
+
+There is one guard. `arm_button_wake` arms the button only while it reads released. A stuck or
+still-held button would otherwise wake the Node again at once, in a loop that drains the cell. In
+that case the Node sleeps with the timer wake only and logs `setup button still held; timer wake
+only`.
+
+> [!NOTE]
+> The button is only looked at on a button wake. A press while the Node is already awake (during a
+> scheduled measurement) does nothing, and a button held at power-on is not timed either.
+
+### Timing the press
+
+A press can only be timed while the finger is still on the button. So on a button wake, `main`
+calls `classify_press` before any flash or identity work. Only the bootloader delay eats into the
+press.
+
+`classify_press` polls the pin every 10 ms and remembers the time of the last poll that saw the
+button held. Then:
+
+- **Released before 50 ms**: a bounce, or a tap already over by the first poll. That is
+ `Press::Spurious`.
+- **Released before 3 s**: `Press::Short`, "report now".
+- **Still held at 3 s**: `Press::Long`. It returns at once, without waiting for the release, so the
+ Node enters setup mode while the finger is still on the button.
+- **A pin read error**: `Press::Spurious`.
+
+The debounce is a floor, not a filter: the first poll that reads the button released ends the
+timing. A contact that opens for a moment and is caught by a poll ends the press there, so anything
+shorter than 50 ms counts as nothing rather than as a short press.
+
+### What each press does
+
+`wake_plan` turns the wake cause and the press into one of four plans, logged as
+`wake plan=…`:
+
+| Wake | Plan | What happens |
+| --- | --- | --- |
+| Timer or cold boot | `measure` | The scheduled measurement and send. |
+| Button, short press | `report-now` | Measure and send at once, outside the 15-minute schedule (N-3). |
+| Button, long press (>= 3 s) | `setup` | The BLE setup window, then a measurement that is buffered, not sent. |
+| Button, bounce | `sleep-again` | No measurement; straight back to sleep. |
+
+A short press is how you get a fresh value during calibration in seconds instead of waiting up to
+15 minutes. It does not start BLE.
+
+A long press is the only path that ever starts BLE. `setup_mode` loads the setup code from
+`cf_setup` (drawing it on the first long press), prints it as `setup code=…`, raises the RTC
+watchdog to the window plus 30 s, and runs the setup window for at most 180 s. The window starts
+when advertising starts, not at the press. It ends early once an enrolment session's connection has
+ended. After the window the Node measures as usual, but the report only goes into the flash buffer:
+it is sent at the next wake. [ble-setup.md](ble-setup.md) covers the window and the protocol (AD-25).
+
+### Sleeping again
+
+`sleep_after` decides how long the next sleep is. A scheduled `measure` wake sleeps for the rest of
+its period, so the schedule keeps its rhythm. Every press (`report-now`, `setup` and
+`sleep-again`) restarts the schedule: the Node sleeps a full 15-minute period from the end of the
+wake. The log line `sleep ms=900000 button_armed=true` shows it.
+
+Before sleeping, `arm_button_wake` checks the button again. After a long press the finger is
+normally off the button by the time the window has run, so the button wake is armed again.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `PRESS_POLL_MS` | Poll interval 10 ms | `packages/rs/sensing/src/button.rs` |
+| `DEBOUNCE_MS` | Debounce floor 50 ms | `packages/rs/sensing/src/button.rs` |
+| `LONG_PRESS_MS` | Long press 3 000 ms | `packages/rs/sensing/src/button.rs` |
+| `SETUP_WINDOW_MS` | Setup window 180 000 ms (3 min) | `packages/rs/setup/src/node.rs` |
+| `NODE_SESSION_IDLE_TIMEOUT_MS` | Idle connection dropped after 60 000 ms | `packages/rs/setup/src/node.rs` |
+| `SETUP_WATCHDOG_MS` | Watchdog in setup mode: window + 30 s | `apps/rs/node/src/main.rs` |
+| `WAKE_PERIOD_MS` | Full sleep after a press 900 000 ms (15 min) | `packages/rs/sensing/src/wake.rs` |
+| `take_pins!` (macro) | Button pin GPIO7, active low | `apps/rs/node/src/board/pins.rs` |
+
+## Where to read the code
+
+- `packages/rs/sensing/src/button.rs` (`classify_press`): the press timing, host-tested against a
+ fake pin and timer in `tests/rs/sensing/tests/button.rs`.
+- `packages/rs/sensing/src/button.rs` (`wake_plan`, `sleep_after`): the four plans and the sleep
+ each one takes; BLE is reachable only from `WakePlan::Setup`.
+- `packages/rs/sensing/src/button.rs` (`arm_button_wake`): the stuck-button guard.
+- `apps/rs/node/src/board/button.rs` (`BoardButton`): the pull-up and the deep-sleep wake on a low
+ level.
+- `apps/rs/node/src/board/sleep.rs` (`wake_cause`, `deep_sleep`): how `ext0`/`ext1` becomes a
+ button wake, and where the wake is armed.
+- `apps/rs/node/src/main.rs` (`main`, `setup_mode`): the press timed first, then the plan, the
+ setup window and the final sleep.
+- `packages/rs/setup/src/node.rs` (`run_node_setup`): the window's deadline and how it ends.
+
+Related reading:
+
+- [Node setup bench checklist](../bench/node-setup-checklist.md): the hand test of each press,
+ including the shortest press that still counts.
+- [Node README](../../apps/rs/node/README.md): the wake sequence as a whole.
+- [Device hardware spec](../../_bmad-output/specs/spec-coldframe/device-hardware.md): N-3 (report
+ now) and the Story 4.2 button and setup-mode notes.
+- [Architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md):
+ AD-25, the BLE setup protocol the long press opens.
diff --git a/docs/firmware/deep-sleep.md b/docs/firmware/deep-sleep.md
new file mode 100644
index 00000000..b597f692
--- /dev/null
+++ b/docs/firmware/deep-sleep.md
@@ -0,0 +1,172 @@
+# Deep sleep and the wake cycle
+
+A Coldframe Node spends almost all of its life in deep sleep. It wakes every 15 minutes, measures,
+sends, and sleeps again. This page explains how that cycle is built on the ESP32-S3: why every wake
+is a reboot, where the Node keeps its state between wakes, what bounds a wake, and how the sleep
+time is computed. It follows [ESP-NOW transport](espnow.md) and comes before
+[the setup button](buttons.md); the index is [README.md](README.md). For panel placement and the
+power budget in numbers, read [Power and placement](../build/power.md).
+
+## How it works
+
+### Every wake is a reboot
+
+Deep sleep on the ESP32-S3 powers down the CPU and RAM. Only the RTC domain keeps running: the RTC
+timer, the wake sources and the held pads. When the timer fires, the chip resets and the bootloader
+starts the firmware again from the top. There is no "resume" after the sleep call.
+
+So `apps/rs/node/src/main.rs` (`main`) is written as one straight run from top to bottom, ending in
+a call that never returns (`-> !`). The module comment at the top of that file lists the ten steps
+of a wake in order. The deep-sleep call itself is in `apps/rs/node/src/board/sleep.rs`
+(`deep_sleep`): it sets an RTC timer deadline and calls esp-hal's `sleep_deep`.
+
+Because the wake is a reset, the firmware first has to find out why it is running.
+`apps/rs/node/src/board/sleep.rs` (`wake_cause`) reads the chip's wakeup cause:
+
+- `ext0`/`ext1` (the setup button) gives `WakeCause::Button`;
+- the RTC timer gives `WakeCause::Timer`;
+- anything else, such as power-on, a watchdog reset or a flash, is `WakeCause::ColdBoot`.
+
+A cold boot raises the boot ID; a timer or button wake reuses it
+(`packages/rs/sensing/src/wake.rs`, `boot_id`). The boot ID and the RTC uptime together let the
+Server place Readings in time before the Node's clock has been set (AD-11).
+
+### Where the state lives
+
+Nothing in RAM survives the sleep. The Node keeps its state in two places:
+
+- **Flash partitions.** The boot counter (`cf_boot`), the `reading_seq` counter (`cf_seq`), the
+ frame counter (`cf_frame`), the link state (`cf_link`) and the report buffer (`cf_buf`) are all
+ in flash. Their names are in `apps/rs/node/src/board/flash.rs`.
+- **The RTC timer.** It keeps counting through deep sleep, so the uptime read in
+ `apps/rs/node/src/board/rtc.rs` (`BoardRtc::uptime_millis`) grows across the wakes of one boot.
+
+The firmware does not use RTC RAM. The planning notes in
+[device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md) allow either RTC
+RAM or flash for the buffer; the code uses flash, which also survives a flat battery.
+
+The wall clock is a good example. `BoardRtc` holds the clock as an offset from the uptime, but that
+offset is lost at every reset. The transport stores it in `cf_link` together with the boot ID it
+belongs to. At the start of each wake, `packages/rs/transport/src/transport.rs` (`begin`) hands it
+back, so the Readings can be stamped `Synced`. A cold boot has a new boot ID, so the old offset no longer
+applies, and the Readings are `Unsynced` until a sealed downlink sets the clock again (AD-11).
+
+### A short, cheap wake
+
+Two choices keep the active current of a wake low:
+
+- **80 MHz CPU clock.** `main` starts esp-hal with `CpuClock::_80MHz`. The comment there gives the
+ reason: it cuts the active current, and 80 MHz is also esp-radio's minimum, so the radio can
+ still start when needed.
+- **Switched loads.** The soil probe and the battery divider sit behind high-side switches.
+ `packages/rs/sensing/src/wake.rs` (`run_wake`) turns each switch on only for its own
+ measurement: probe on, settle 100 ms, eight samples, probe off; divider on, settle 10 ms, four
+ samples, divider off. A switch is turned off on every path out, errors included. The BME680 is
+ not switched: one forced measurement returns it to sleep by itself.
+
+The radio is dropped before the sleep, so it is off while the Node sleeps.
+
+### The watchdog bounds every wake
+
+A Node that hangs awake would drain its cell in hours. So the first thing `main` does after
+`esp_hal::init` is arm the RTC watchdog: `apps/rs/node/src/board/rtc.rs` (`arm_watchdog`). Stage 0
+resets the whole chip if the wake has not reached deep sleep within `WATCHDOG_MS` (30 s).
+
+The watchdog is never fed during a wake, and it pauses in sleep. It is fed once when armed, so the
+count starts from zero rather than from what the previous boot left behind. It also catches a
+panic: `esp-backtrace` halts instead of resetting, and the watchdog turns that halt into a reset.
+A watchdog reset is not a timer or button wake, so the next run is a cold boot with a new boot ID.
+
+Setup mode is the one long wake. While the BLE window is open, `setup_mode` raises the watchdog to
+`SETUP_WATCHDOG_MS` (the window plus 30 s) and restores 30 s afterwards. A normal wake is far
+inside 30 s; the module comment in `main.rs` works out the transport's worst case at about 6.2 s.
+
+### The `fail` path: sleep, do not park
+
+If the Node cannot get a boot ID, an identity or the `reading_seq` partition, it cannot produce a
+valid report. Staying awake to retry would only cost battery. Instead `fail` in
+`apps/rs/node/src/main.rs` logs `wake failed stage=… error=…` and deep-sleeps one full period
+(`WAKE_PERIOD_MS`). The next wake tries again from the top. A failed Sensor or a failed transport
+is not a `fail`: the wake goes on, and the report stays buffered for the next wake.
+
+### How long to sleep
+
+The Node aims for one wake every `WAKE_PERIOD_MS`, measured from the start of one wake to the
+start of the next. `main` records the RTC uptime right after the watchdog is armed
+(`wake_start_ms`). After the transport it takes the elapsed time and calls `sleep_ms`:
+
+```rust
+pub fn sleep_ms(elapsed_ms: u64) -> u32 {
+ let rest = u64::from(WAKE_PERIOD_MS).saturating_sub(elapsed_ms);
+ u32::try_from(rest.max(u64::from(MIN_SLEEP_MS))).unwrap_or(WAKE_PERIOD_MS)
+}
+```
+
+So a wake that took 1.2 s sleeps 898.8 s. A wake that somehow ran a whole period still sleeps at
+least `MIN_SLEEP_MS`. The time before `wake_start_ms` (the bootloader and `esp_hal::init`) is not
+counted, so the real period is slightly longer than 15 minutes.
+
+`run_wake` also returns a `sleep_ms`, but it is measured before the transport. `main` does not use
+it; it computes the sleep again after the radio work, so the radio time is included.
+
+A press changes the schedule. `sleep_after` in `packages/rs/sensing/src/button.rs` keeps the
+measured rest only for a scheduled wake (`WakePlan::Measure`). After a short press, a long press
+or a bounce, the Node sleeps a full period from now. The [buttons page](buttons.md) covers the
+presses.
+
+### Waking on the button
+
+`sleep` in `main.rs` arms the setup button as a second wake source, next to the timer, but only if
+the button reads released (`arm_button_wake`). A button held or stuck low would otherwise wake the
+Node again at once, in a loop. In that case the Node logs a warning and sleeps on the timer alone.
+`apps/rs/node/src/board/button.rs` (`arm_wake`) enables the pad's low-power wake path, which keeps
+the pull-up through the sleep. Every sleep logs `sleep ms= button_armed=`.
+
+## Power budget
+
+The design budget is an average sleep current of about 100 µA or less, with short wakes. The
+board options and the arithmetic behind it are in [Power and placement](../build/power.md) and in
+the power budget of [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+> [!WARNING]
+> The ~100 µA average sleep-current budget is **not yet measured**. No reference Node has been
+> through the bench run. Until it has, treat every current figure as a design estimate.
+
+To measure it, follow the [Node power checklist](../bench/node-power-checklist.md). Any build
+without `dev-mode` burns an eFuse key block on first boot, and that cannot be undone
+([identity.md](identity.md)); the checklist says how to prepare the board.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `WAKE_PERIOD_MS` | 900 000 ms (15 min) | `packages/rs/sensing/src/wake.rs` |
+| `MIN_SLEEP_MS` | 1 000 ms | `packages/rs/sensing/src/wake.rs` |
+| `PROBE_SETTLE_MS` | 100 ms | `packages/rs/sensing/src/wake.rs` |
+| `DIVIDER_SETTLE_MS` | 10 ms | `packages/rs/sensing/src/wake.rs` |
+| `WATCHDOG_MS` | 30 000 ms | `apps/rs/node/src/main.rs` |
+| `SETUP_WATCHDOG_MS` | `SETUP_WINDOW_MS` + 30 s = 210 s | `apps/rs/node/src/main.rs` |
+| `CpuClock::_80MHz` | 80 MHz CPU clock | `apps/rs/node/src/main.rs` (`main`) |
+
+## Where to read the code
+
+- `apps/rs/node/src/main.rs` (module comment, `main`): the ten steps of a wake, the 80 MHz
+ setting and the sleep computed after the transport.
+- `apps/rs/node/src/main.rs` (`sleep`, `fail`): the single exit of every wake, and the failure
+ path that sleeps a full period.
+- `apps/rs/node/src/board/sleep.rs` (`wake_cause`, `deep_sleep`): reading the wake cause and
+ entering deep sleep with a timer deadline.
+- `apps/rs/node/src/board/rtc.rs` (`arm_watchdog`, `BoardRtc`): the RTC watchdog, and the uptime
+ that keeps counting through sleep.
+- `apps/rs/node/src/board/button.rs` (`arm_wake`): the setup button as a deep-sleep wake source.
+- `packages/rs/sensing/src/wake.rs` (`run_wake`, `sleep_ms`, `boot_id`): the measurement order,
+ the switched loads, the sleep arithmetic and the boot ID. Host-tested, no hardware needed.
+- `packages/rs/sensing/src/button.rs` (`sleep_after`, `arm_button_wake`): how a press resets the
+ schedule, and why a held button is not armed.
+- [`apps/rs/node/README.md`](../../apps/rs/node/README.md): boot steps, partitions, pins and log
+ lines.
+
+Governing items: AD-11 (time, boot ID and uptime) and AD-17 (frame sequence and replay, which the spine also cites for bounded radio time) in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md);
+the power budget (NFR-4) in
+[device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
diff --git a/docs/firmware/espnow.md b/docs/firmware/espnow.md
new file mode 100644
index 00000000..56deec62
--- /dev/null
+++ b/docs/firmware/espnow.md
@@ -0,0 +1,169 @@
+# ESP-NOW transport and channel following
+
+A Node never joins your Wi-Fi. It talks to the Hub over ESP-NOW: short, connectionless radio
+datagrams between two ESP32-S3 chips. The Hub is on the Wi-Fi network and relays what it hears to
+the Server. This page shows how a Node finds the Hub, sends its reports and learns that they
+arrived, and how it follows the Hub when the router changes channel. Read
+[sealing.md](sealing.md) first for what is inside a frame; [deep-sleep.md](deep-sleep.md) comes
+next and covers the rest of the wake. The index is [README.md](README.md).
+
+The governing rules are AD-9 (ingestion and acknowledgement), AD-11 (time) and AD-17 (frame
+sequence) in the [architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md),
+and N-1 and N-2 in [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+## How it works
+
+### The radio as a datagram port
+
+The transport logic does not know about ESP-NOW. It uses a small trait, `DatagramRadio`, in
+`packages/rs/hal/src/datagram.rs`: set a channel, send one payload (at most 250 bytes) to a MAC
+address or to broadcast, and receive with a timeout. The Node's adapter in
+`apps/rs/node/src/board/espnow.rs` (`BoardEspNow`) takes ESP-NOW from a Wi-Fi controller that
+never associates. The Node creates it after the measurement and drops it before deep sleep, so
+the radio is only on during the transport step.
+
+The key lesson of this layer is N-1: **the send status is not delivery**. Under Wi-Fi and BLE
+coexistence the radio often reports a failed send for a datagram that did arrive. So the
+transport ignores the result of every `send`. Only a sealed downlink from the Server counts as
+proof of delivery.
+
+### Four messages
+
+`packages/rs/protocol/src/radio.rs` (`Message`) defines the whole radio protocol. The first byte
+is the kind:
+
+- **Probe** (Node to broadcast): an 8-byte nonce. It asks "is a Hub on this channel?".
+- **Probe reply** (Hub to Node): the nonce echoed, plus `pending`, the number of downlinks the
+ Hub kept for this Node (0 to 8).
+- **Uplink** (Node to Hub): a sealed frame.
+- **Downlink** (Hub to Node): a sealed acknowledgement from the Server.
+
+Probes and replies are not authenticated. A forged reply can waste one burst, but it cannot
+delete a Reading or reset the miss count: only a downlink that opens under the `ack/v1` key
+can.
+
+### One wake, step by step
+
+`packages/rs/transport/src/transport.rs` (`run`) does the transport for one wake. Its module
+documentation lists the steps; in short:
+
+1. **Buffer** the wake report in flash.
+2. **Find a Hub** (`find_hub`). Probe the channel a Hub last answered on and wait up to 120 ms
+ for a reply with the same nonce (`probe`).
+3. **Collect kept downlinks.** If the reply says `pending > 0`, listen up to 120 ms for them.
+ These are late acknowledgements of the last wake's frames.
+4. **Send a burst.** Reserve one frame counter per report in flash first, then seal and send up
+ to 8 buffered reports, oldest first, unicast to the Hub's MAC address.
+5. **Listen** for up to 300 ms after the last send (`listen`), or until every frame of the
+ burst is acknowledged.
+6. **Remember** the outcome in the link state.
+
+A downlink deletes exactly the Readings it lists. It is *fresh* when it acknowledges a frame of
+this wake's burst or the previous one that no downlink has acknowledged before
+(`Burst::acknowledge` in `packages/rs/transport/src/link.rs`). Only a fresh downlink resets the
+miss count and sets the clock (AD-11).
+
+A frame that is never acknowledged is not resent in the same wake. Its report stays in the
+buffer and goes out again in the next wake's burst, under a new frame counter.
+
+### Channel following
+
+ESP-NOW only works when both chips are on the same Wi-Fi channel. The Node does not choose the
+channel; **the Hub's access point does**. The Hub is a Wi-Fi station, so its radio sits on the
+router's channel. Its ESP-NOW peers are added with `channel: None`
+(`apps/rs/hub/src/board/espnow.rs`, `send`), which means "use the station's current channel". If
+the router moves to another channel, the Hub moves with it, and the Node has to find it again.
+
+The Node does this with the link state, a small record in flash (`LinkState`, magic `CFLK`, in
+`packages/rs/transport/src/link.rs`). Every wake is a reboot, so the channel, the miss count and
+the scan hold-off must live in flash. `find_hub` uses them like this:
+
+- **Known channel first.** If a channel is stored, it is always probed first, every wake.
+- **A miss** is a wake without a fresh downlink. The count goes up by one on each miss and back
+ to 0 on the first fresh downlink.
+- **Scan.** With no stored channel, or after 3 consecutive misses (`RESCAN_AFTER_MISSES`), the
+ Node probes the known channel and then every other channel from 1 to 13. The first channel
+ where a Hub answers becomes the stored channel.
+- **Back-off.** A full scan that finds no Hub sets `scan_holdoff` to 4 (`SCAN_BACKOFF_WAKES`).
+ The next three wakes that want to scan only count it down and probe the known channel. The
+ fourth wake scans again. This stops a Node far from the Hub from scanning on every wake.
+
+> [!NOTE]
+> The Node re-evaluates its channel **only after the link drops**, that is, after 3 misses in a
+> row. There is no re-scan when the signal gets weaker (no RSSI check). A Node keeps using a
+> channel for as long as a Hub answers there.
+
+A lost or corrupt link state is harmless. It reads as the default state, which costs one scan
+and an unsynced clock, never a Reading.
+
+### The Hub relay
+
+The Hub side is a radio task, `relay_task` in `apps/rs/hub/src/board/espnow.rs`, around a plain
+state machine, `Relay` in `packages/rs/uplink/src/relay.rs`. The Hub reads only the kind byte:
+it never opens an envelope.
+
+- **Probes** are answered at once from the relay state, without asking the Server. So a reply
+ never waits for an HTTPS request in flight. A probe is answered only while the Hub can relay
+ (`Relay::set_relaying`): the station is associated and the clock is set. Otherwise a Node
+ would spend its frame counters on frames the Hub would drop.
+- **Uplinks** go into a queue of 8. The uplink task posts them to the Server as one batch. An
+ envelope already queued is not queued twice.
+- **Downlinks** from the Server are sent once, straight away. With a fresh TLS session per
+ request the Node is usually asleep by then. So the Hub also keeps them in RAM, up to 8 per
+ Node out of a pool of 24, and sends them once more right after its reply to that Node's next
+ probe (`Relay::take_kept`). That is the `pending` count in the reply.
+
+A Hub peer entry lives for one send only, so ESP-NOW's limit of 20 peers never fills up.
+
+> [!NOTE]
+> AD-9 says the Hub may keep "the latest" downlink per Node. The code keeps up to 8 per Node
+> (one burst), so a whole burst can be acknowledged at the next wake.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `PROBE_WAIT_MS` | 120 ms per channel | `packages/rs/transport/src/transport.rs` |
+| `PENDING_WAIT_MS` | 120 ms for kept downlinks | `packages/rs/transport/src/transport.rs` |
+| `ACK_WINDOW_MS` | 300 ms after the last send (N-2) | `packages/rs/transport/src/transport.rs` |
+| `BURST_MAX` | 8 frames per wake | `packages/rs/transport/src/transport.rs` |
+| `RESCAN_AFTER_MISSES` | 3 consecutive misses | `packages/rs/transport/src/transport.rs` |
+| `SCAN_BACKOFF_WAKES` | 4 wakes after a failed scan | `packages/rs/transport/src/transport.rs` |
+| `FIRST_CHANNEL`, `LAST_CHANNEL` | channels 1 to 13 | `packages/rs/transport/src/transport.rs` |
+| `MAX_PAYLOAD` | 250 bytes per message | `packages/rs/protocol/src/radio.rs` |
+| `PENDING_MAX` | 8 kept downlinks per Node | `packages/rs/protocol/src/radio.rs` |
+| `SEND_TIMEOUT` (Node) | 200 ms per send | `apps/rs/node/src/board/espnow.rs` |
+| `SEND_TIMEOUT` (Hub) | 60 ms per send | `apps/rs/hub/src/board/espnow.rs` |
+| `UPLINK_QUEUE`, `DOWNLINK_POOL` | 8 uplinks, 24 kept downlinks | `packages/rs/uplink/src/relay.rs` |
+
+The Hub's 60 ms send timeout is deliberately shorter than the Node's 120 ms probe wait: a send to
+a Node that is already asleep must not delay the reply to the next probe. The worst-case wake
+(a full scan, 8 frames and the window) is worked out in the module documentation of
+`apps/rs/node/src/main.rs`: about 2.1 s, and 6.2 s if every send times out.
+
+## Where to read the code
+
+- `packages/rs/transport/src/transport.rs`: the module documentation, then `run` for the order
+ of one wake.
+- `packages/rs/transport/src/transport.rs` (`find_hub`, `probe`): the known channel, the scan
+ and the back-off.
+- `packages/rs/transport/src/transport.rs` (`listen`, `Session::apply`): the acknowledgement
+ window and what makes a downlink fresh.
+- `packages/rs/transport/src/link.rs` (`LinkState`, `Burst`): what the Node remembers between
+ wakes, and the flash record layout.
+- `packages/rs/protocol/src/radio.rs` (`Message`): the four radio messages and their encoding.
+- `packages/rs/hal/src/datagram.rs` (`DatagramRadio`): the radio port, and why the send status
+ is ignored.
+- `apps/rs/node/src/board/espnow.rs` (`BoardEspNow`): ESP-NOW without a Wi-Fi association.
+- `apps/rs/hub/src/board/espnow.rs` (`relay_task`, `send`): the Hub's radio task and the
+ `channel: None` peers.
+- `packages/rs/uplink/src/relay.rs` (`Relay`): the uplink queue, the kept downlinks and when
+ probes are answered.
+
+To see it on real boards, follow the
+[Node transport checklist](../bench/node-transport-checklist.md). The radio findings behind N-1
+and the `channel: None` peer scheme (F-7) are in
+[hub-radio-coexistence.md](../spikes/hub-radio-coexistence.md) and
+[radio-coexistence.md](radio-coexistence.md). The build-side READMEs are
+[apps/rs/node/README.md](../../apps/rs/node/README.md) and
+[apps/rs/hub/README.md](../../apps/rs/hub/README.md).
diff --git a/docs/firmware/identity.md b/docs/firmware/identity.md
new file mode 100644
index 00000000..42a752b4
--- /dev/null
+++ b/docs/firmware/identity.md
@@ -0,0 +1,183 @@
+# Device identity: the eFuse root and the key hierarchy
+
+Every Coldframe Device, Hub or Node, has one secret root key. Every other key and the Device ID
+are derived from it. This page shows how the firmware creates that root on first boot, keeps it in
+a read-protected eFuse block, and derives the rest with HMAC and HKDF. It is the first page of the
+[firmware reference](README.md); the next one, [sealing.md](sealing.md), shows how the purpose keys
+seal frames. The rule behind it all is AD-12 in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md).
+
+> [!WARNING]
+> **eFuse burns are irreversible.** Any build without `--features dev-mode`, debug or release,
+> burns a key block on its first boot, including a plain `cargo run`. Check the key purposes
+> before you flash ([flash.md](../build/flash.md)) and follow the
+> [Hub identity checklist](../bench/hub-identity-checklist.md), which applies to the Node too.
+
+## How it works
+
+### One root, one derived key
+
+The root is 32 random bytes. It derives exactly one key, the device key `K_dev`:
+
+```text
+K_dev = HMAC-SHA256(key = root, msg = "coldframe/device/v1")
+```
+
+Everything else comes from `K_dev` with HKDF-SHA256 (empty salt, `K_dev` as input key material,
+the label as `info`):
+
+- `seal/v1`, 32 bytes: seals uplink frames (see [sealing.md](sealing.md)).
+- `ack/v1`, 32 bytes: seals downlinks from the Server.
+- `hub-auth/v1`, 32 bytes: the Hub signs its HTTP requests with it.
+- `device-id/v1`, 8 bytes: the Device ID, shown as 16 lowercase hex digits.
+
+`DeviceKeys::from_device_key` computes all of them in one go. `K_dev` itself is kept too, because
+BLE enrolment seals it with HPKE to the Server, which then derives the same purpose keys (see
+[ble-setup.md](ble-setup.md)). The Server never sees the root.
+
+The Device ID is derived, not stored. Two Devices with different roots get different IDs, and the
+same chip always gets the same ID back, boot after boot.
+
+### Release builds: the root lives in an eFuse key block
+
+The ESP32-S3 has six 256-bit eFuse key blocks, `KEY0` to `KEY5`. Each has a 4-bit purpose, a
+read-disable bit and a write-disable bit. A fuse bit, once set, stays set. Coldframe burns the root
+into one block with purpose `HMAC_UP` (raw value 8). The chip then read-protects the block: no
+software can read the key any more. Only the HMAC peripheral can use it.
+
+`provision_efuse` runs on every boot and does this:
+
+1. It looks for a block whose purpose is `HMAC_UP`. If there is exactly one and it is
+ read-protected, that is the identity; nothing is burned (`existing KEYn` in the log).
+2. It refuses to go on, and burns nothing, if two blocks have `HMAC_UP`
+ (`AmbiguousIdentity`), if the one block is not read-protected (`IdentityNotProtected`), or if no
+ identity exists and no block is unused (`NoFreeKeyBlock`).
+3. On first boot it turns the radio on, because the radio is the TRNG's entropy source. Without it
+ `BoardTrng` refuses to run rather than return pseudo-random bytes. It draws 32 bytes and rejects
+ all-zero or all-`0xFF` output (`BadEntropy`).
+4. It burns the bytes into the first unused block with purpose `HMAC_UP`, then re-reads the block
+ and checks the purpose and the read protection (`BurnNotVerified` otherwise).
+5. It asks the HMAC peripheral for `K_dev` and derives the rest.
+
+The root sits in RAM only between the TRNG draw and the burn. It is held in a small `Wiped`
+buffer that zeroes itself when dropped, on every return path. The crate forbids `unsafe`, so it
+uses `core::hint::black_box` instead of a volatile write to stop the compiler removing the zeroing.
+
+On the existing-identity path, which is every boot after the first, the radio is never started for
+the identity at all.
+
+### The HMAC peripheral in upstream mode
+
+The eFuse purpose and the HMAC peripheral mode have different names for the same idea. The burned
+purpose is `HMAC_UP` (`KeyPurpose::HmacUp`); `BoardHmac` configures the esp-hal peripheral with
+`HmacPurpose::ToUser`. In that mode software supplies the message and reads the 32-byte result,
+while the key stays in the fuse. If the block does not have purpose `HMAC_UP`, the peripheral
+refuses and the adapter reports `KeyPurposeMismatch`.
+
+> [!NOTE]
+> AD-12 says the root is burned with "HMAC purpose ToUser". In the code, `ToUser` is the
+> peripheral mode and the burned eFuse purpose is `HMAC_UP`. This page follows the code.
+
+### Hardware behind traits
+
+`packages/rs/crypto` never touches a register. `provision_efuse` takes four traits from
+`packages/rs/hal`: `Radio`, `Trng`, `Efuse` and `HmacPeripheral`. The `Efuse` trait deliberately has
+no way to read a key. This keeps the whole flow testable on the host with fakes, and the firmware
+only supplies thin adapters in `board/`.
+
+`BoardEfuse` can only read eFuses through esp-hal 1.2.2, so it burns through the ESP32-S3 ROM
+routine `ets_efuse_write_key`. That routine applies the Reed-Solomon coding of key blocks and sets
+data, purpose, write protection and (for a protected purpose) read protection in one call.
+Afterwards `ets_efuse_read` reloads the read registers so the re-check sees the real fuses.
+`BoardEfuse::take` hands out at most one adapter, so only one owner can program fuses.
+
+These ROM calls are the only `unsafe` code of the Node. Each `unsafe` block carries a `SAFETY`
+comment, and `unsafe_code = "deny"` in the firmware `Cargo.toml` makes any other use fail the
+build. `board/hmac.rs` needs no `unsafe`: it uses the safe esp-hal HMAC driver. The Hub has one more
+`unsafe` block outside the eFuse adapter, in `apps/rs/hub/src/board/rtc.rs`, which installs the
+mbedTLS clock hooks for TLS.
+
+### What can go wrong during a burn
+
+If power fails mid-burn, a block can end up with data but purpose `USER`. The chip then reports
+it as used, so the next boot burns a different free block: one block is lost, but no key is reused.
+A burn can also set `HMAC_UP` without read protection; that board then halts with
+`IdentityNotProtected` on every boot and can no longer hold an identity. The
+[Hub README](../../apps/rs/hub/README.md) covers both cases under "Identity modes".
+
+On an identity failure the Hub logs the stage and error and parks. The Node logs it and deep-sleeps
+one period instead, so a broken identity does not drain the battery.
+
+### Dev mode: a software root in flash
+
+A build with `--features dev-mode` uses `provision_dev` instead and compiles no eFuse or HMAC code
+at all. The root is a TRNG-drawn software key in the `cf_ident` flash partition, stored as a
+41-byte record: the magic `CFID`, version `0x01`, the 32-byte root, and the first 4 bytes of
+SHA-256 over the rest as a check.
+
+- An erased partition (all `0xFF`) means first boot: draw a root, write the record, read it back.
+- A valid record is used as it is.
+- Anything else is `CorruptDevIdentity`. A corrupt identity is never replaced silently; erasing the
+ partition is your decision ([flash.md](../build/flash.md), "Recovery: erase a region").
+
+Both modes end in `DeviceKeys::from_device_key`, so a given root gives the same keys whether
+`K_dev` comes from the HMAC peripheral or from `derive_device_key` in software.
+
+### The release guard
+
+A dev-mode root is readable from flash, so a release Device must never use it. Both firmware
+crates refuse the combination at compile time. The Node's guard reads (the Hub's says "a release Hub"):
+
+```rust
+#[cfg(all(feature = "dev-mode", not(debug_assertions)))]
+compile_error!(
+ "the `dev-mode` feature is refused in release builds: a release Node uses its eFuse identity"
+);
+```
+
+The guard works only in one direction. A debug build *without* `dev-mode` is an eFuse build and
+burns on first boot, as the warning above says.
+
+## Key constants and timings
+
+All from the generated `packages/rs/crypto/src/spec.rs` (source:
+`packages/crypto-spec/crypto-spec.json`) unless noted.
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `ROOT_KEY_LENGTH` | 32 bytes | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_KEY_LABEL` | `coldframe/device/v1` | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_KEY_LENGTH`, `PURPOSE_KEY_LENGTH` | 32 bytes each | `packages/rs/crypto/src/spec.rs` |
+| `SEAL_LABEL`, `ACK_LABEL`, `HUB_AUTH_LABEL` | `seal/v1`, `ack/v1`, `hub-auth/v1` | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_ID_LABEL` | `device-id/v1` | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_ID_LENGTH`, `DEVICE_ID_HEX_LENGTH` | 8 bytes, 16 hex digits | `packages/rs/crypto/src/spec.rs` |
+| `KeyPurpose::HmacUp` | raw purpose 8 | `packages/rs/hal/src/efuse.rs` |
+| `FIRST_KEY_BLOCK` | 4 (`KEYn` is eFuse block 4 + n) | `apps/rs/node/src/board/efuse.rs` |
+| `DEV_RECORD_MAGIC`, `DEV_RECORD_VERSION` | `CFID`, 1 | `packages/rs/crypto/src/identity.rs` |
+| `DEV_RECORD_LENGTH` | 41 bytes | `packages/rs/crypto/src/identity.rs` |
+| `cf_ident` partition | Node `0xD000`, Hub `0xE000`, 4 KiB each | `apps/rs/node/partitions.csv`, `apps/rs/hub/partitions.csv` |
+
+There are no timings here: provisioning runs once per boot, before anything else needs a key.
+
+## Where to read the code
+
+- `packages/rs/crypto/src/identity.rs` (`provision_efuse`): the release flow, find or burn, then
+ derive.
+- `packages/rs/crypto/src/identity.rs` (`provision_dev`, `DEV_RECORD_MAGIC`): the dev-mode `CFID`
+ record in flash.
+- `packages/rs/crypto/src/identity.rs` (`device_keys_from_hmac`, `Wiped`): `K_dev` from the
+ peripheral, and how the root is wiped.
+- `packages/rs/crypto/src/keys.rs` (`DeviceKeys`, `derive_purpose_key`, `DeviceId`): the HKDF
+ hierarchy.
+- `packages/rs/hal/src/efuse.rs` (`Efuse`, `KeyPurpose`): the eFuse model, with no read-key method.
+- `packages/rs/hal/src/hmac.rs` (`HmacPeripheral`): the upstream-mode HMAC trait.
+- `apps/rs/node/src/board/efuse.rs` (`BoardEfuse`): the ROM burn, the only `unsafe` code of the
+ Node. `apps/rs/hub/src/board/efuse.rs` has the same code (only its module
+ comment differs).
+- `apps/rs/node/src/board/hmac.rs` (`BoardHmac`): the esp-hal HMAC driver in `ToUser` mode;
+ `apps/rs/hub/src/board/hmac.rs` is identical.
+- `apps/rs/node/src/board/rng.rs` (`BoardTrng`): why the radio must be on before the TRNG runs.
+- `apps/rs/node/src/main.rs` (`compile_error!`, `MODE`): the release guard and the provisioning
+ call; `apps/rs/hub/src/main.rs` has the same guard.
+- [Node README](../../apps/rs/node/README.md) and [Hub README](../../apps/rs/hub/README.md),
+ "Identity modes": the operator view and the log lines.
diff --git a/docs/firmware/radio-coexistence.md b/docs/firmware/radio-coexistence.md
new file mode 100644
index 00000000..51ce3109
--- /dev/null
+++ b/docs/firmware/radio-coexistence.md
@@ -0,0 +1,152 @@
+# Radio coexistence on the Hub
+
+The Hub runs a Wi-Fi station, ESP-NOW and a BLE peripheral on the one 2.4 GHz radio of an ESP32-S3,
+with a TLS client on top. Before any Hub code was written, a spike tested whether that works at
+all. This page summarises what the spike found and shows how the Hub firmware applies it. It is the
+last page of the firmware reference: the previous page is [ble-setup.md](ble-setup.md), and the
+full list is in the [index](README.md).
+
+The spike report is [`hub-radio-coexistence.md`](../spikes/hub-radio-coexistence.md). Its results
+and criteria tables live there; this page does not repeat them. The requirements it produced are
+H-1 to H-3 and N-1 to N-2 in [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+The relay itself is governed by AD-9 and AD-17 in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md).
+
+## What the spike found
+
+The spike firmware ran Wi-Fi STA, ESP-NOW, a BLE GATT peripheral and an HTTPS client as concurrent
+embassy tasks on one core, with a Node sending a frame every second and a phone writing over BLE
+every 10 s. The verdict (section 10 of the report) is **GO: keep the ESP32-S3**. Over about 3 h 45 min
+of runs on two boards there was no crash, ESP-NOW loss stayed well under 1 %, the heap was flat and
+channel following recovered on its own. The single radio is time-shared by the Espressif coexistence
+arbiter; the firmware does not schedule airtime itself.
+
+The build-time findings that shape the Hub code are:
+
+- **F-3, certificate dates.** mbedTLS checks validity dates only when it is built with a wall-clock
+ hook, and that build needs cmake and ninja. This became H-2.
+- **F-4, one TLS connection.** The default TLS record buffers cost about 32 KiB of heap per
+ connection. Budget one connection and never open two at once.
+- **F-5, heap size.** The Hub heap is 144 KiB of regular RAM plus 64 KiB of reclaimed bootloader
+ RAM, 208 KiB in total. The runtime check is the lowest free heap since boot.
+- **F-6, no WPA3-SAE.** esp-radio 1.0.0-beta.1 has no WPA3-SAE station configuration. This became
+ H-3.
+- **F-7, ESP-NOW comes from the Wi-Fi controller.** There is no separate ESP-NOW init: it is taken
+ from the running `WifiController`, and a peer added with `channel: None` follows the station's
+ channel.
+
+## How it works
+
+### Boot order: heap, then BLE, then Wi-Fi
+
+`main` in `apps/rs/hub/src/main.rs` sets up the two heap regions first, with the spike's sizes
+(F-5): `heap_allocator!` with `#[ram(reclaimed)]` for 64 KiB, then a regular 144 KiB.
+
+It then creates the BLE connector (`BleConnector::new`) *before* the Wi-Fi controller
+(`WifiController::new`), as the esp-radio coexistence examples require. If either fails, the Hub
+logs the stage and halts. The Wi-Fi controller is also the radio the TRNG draws entropy from, so it
+is on before the identity is provisioned.
+
+A provisioned Hub does not advertise: `main` drops the BLE connector and logs
+`setup provisioned earlier; not advertising`. An unprovisioned Hub spawns `ble_task` and runs BLE
+setup while Wi-Fi joins in the same session ([ble-setup.md](ble-setup.md)). So BLE and Wi-Fi
+overlap on the Hub only during setup.
+
+### Heap budget
+
+The budget is at least 32 KiB free at the lowest point (spike criterion C5). `heap_min_free` in
+`main.rs` computes it as heap size minus the peak usage. The Hub logs it twice: once after setup,
+when BLE, Wi-Fi and the Server check's TLS have all run, and every 60 s in `uptime_task`. Nothing
+enforces the budget at run time; the log line is how you check it on the bench.
+
+### ESP-NOW on the access point's channel
+
+ESP-NOW has no channel of its own on the Hub. `BoardRadio::esp_now` in
+`apps/rs/hub/src/board/radio.rs` takes it from the running controller, and `send` in
+`apps/rs/hub/src/board/espnow.rs` adds each peer with `EspNowWifiInterface::Station` and
+`channel: None`. The Hub therefore always transmits on whatever channel its access point uses.
+
+The consequence is on the Node side: when the router changes channel, the Hub follows it, and the
+Node has to find the Hub again by probing ([espnow.md](espnow.md)). While the station is not
+associated, or the clock is not set, the relay does not answer probes at all, so a Node never
+settles on a Hub that cannot forward its frames.
+
+### Choosing the access point (H-1)
+
+When the station is not associated, `Uplink::step` in `packages/rs/uplink/src/uplink.rs` calls its
+`join`, which scans and hands the result to `select_bssid` in `packages/rs/uplink/src/bssid.rs`.
+`BoardWifi::scan` in `apps/rs/hub/src/board/wifi.rs` does an active scan of every channel and keeps
+the strongest 32 access points (`SCAN_CAPACITY`).
+
+`select_bssid` looks only at access points with the provisioned SSID. It skips any whose security
+is unsupported and returns the one with the highest RSSI; on a tie, the first heard wins. It never
+takes the first match. `BoardWifi::join` then pins the station to that BSSID and channel. This
+matters for mesh networks, where several access points share one SSID.
+
+A join failure retries with a backoff of 1 s, doubling to at most 60 s, and reset by a successful
+join.
+
+> [!NOTE]
+> H-1 also asks the Hub to re-evaluate its access point when RSSI degrades. The code does not do
+> this. The Hub rescans only after the link has dropped: while it stays associated it keeps its
+> BSSID, however weak.
+
+### Supported security (H-3)
+
+`security` in `apps/rs/hub/src/board/wifi.rs` maps esp-radio's authentication method onto the
+`Security` enum in `packages/rs/hal/src/wifi.rs`, and `Security::is_supported` decides:
+
+- **Open** — supported. An empty passphrase joins with no authentication.
+- **WPA or WPA2 Personal** (including mixed WPA/WPA2) — supported.
+- **WPA2/WPA3 transition mode** — supported. The Hub joins it with WPA2-Personal.
+- **WPA3-only** (WPA3 Personal / SAE, and the WPA3 extended-PSK modes) — unsupported.
+- **Anything else** (WEP, OWE, enterprise, WAPI) — unsupported.
+
+`BoardWifi::join` only ever configures `Wpa2Personal` (or `Open`), and refuses a passphrase shorter
+than 8 characters as a wrong password. If every access point with the SSID is unsupported,
+`select_bssid` returns `SelectError::Unsupported`, and the Hub never tries to join. During BLE setup
+the app sees this as the `UnsupportedSecurity` Wi-Fi status. If the access point itself rejects the
+security at join time (disconnect reasons 210 and 211), `join_error` reports the same thing.
+
+> [!WARNING]
+> WPA3-only Wi-Fi is not supported. esp-radio 1.0.0-beta.1 has no WPA3-SAE station mode (spike F-6).
+> Put the router in WPA2 or WPA2/WPA3 transition mode for the Hub's network.
+
+### Certificate dates (H-2)
+
+`apps/rs/hub/Cargo.toml` builds mbedtls-rs with `hook-wall-clock`, so certificate validity dates are
+checked (the spike's `tls-time-check`). `main` installs the RTC as mbedTLS's wall clock before
+anything can open TLS. Until SNTP sets the clock, the RTC reads 1970 and every certificate fails
+closed, which is why the uplink runs SNTP once per boot before its first request (AD-11).
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| Hub heap | 64 KiB reclaimed + 144 KiB regular | `heap_allocator!` in `apps/rs/hub/src/main.rs` |
+| Heap budget | at least 32 KiB free at the lowest point (logged, not enforced) | `heap_min_free` in `apps/rs/hub/src/main.rs` |
+| Heap log interval | 60 s | `UPTIME_EVERY` in `apps/rs/hub/src/main.rs` |
+| Scan results kept | 32 access points | `SCAN_CAPACITY` in `packages/rs/uplink/src/lib.rs` |
+| Join timeout | 30 s | `JOIN_TIMEOUT` in `apps/rs/hub/src/board/wifi.rs` |
+| Join backoff | 1 s, doubling, at most 60 s | `BACKOFF_INITIAL_MS`, `BACKOFF_MAX_MS` in `packages/rs/uplink/src/lib.rs` |
+| ESP-NOW send timeout | 60 ms | `SEND_TIMEOUT` in `apps/rs/hub/src/board/espnow.rs` |
+| ESP-NOW peer channel | `None` (the station's channel) | `send` in `apps/rs/hub/src/board/espnow.rs` |
+
+## Where to read the code
+
+- `apps/rs/hub/src/main.rs` (`main`): the heap regions, BLE before Wi-Fi, and when BLE is dropped.
+- `apps/rs/hub/src/main.rs` (`heap_min_free`, `uptime_task`): how the heap budget is measured and
+ logged.
+- `apps/rs/hub/src/board/radio.rs` (`BoardRadio::esp_now`): ESP-NOW taken from the running Wi-Fi
+ controller (F-7).
+- `apps/rs/hub/src/board/espnow.rs` (`send`, `relay_task`): peers with `channel: None`, one peer
+ entry per send.
+- `packages/rs/uplink/src/bssid.rs` (`select_bssid`): the strongest supported BSSID for the SSID
+ (H-1).
+- `packages/rs/uplink/src/uplink.rs` (`Uplink::step`, `join`): rescan and re-join only after the
+ link drops, with backoff.
+- `apps/rs/hub/src/board/wifi.rs` (`security`, `join_error`, `BoardWifi::join`): which networks the
+ Hub can join, and why WPA3-only is refused (H-3).
+- `packages/rs/hal/src/wifi.rs` (`Security::is_supported`): the one place the supported set is
+ defined.
+- `apps/rs/hub/README.md`: building and flashing the Hub ([README](../../apps/rs/hub/README.md)).
diff --git a/docs/firmware/sealing.md b/docs/firmware/sealing.md
new file mode 100644
index 00000000..0231b80e
--- /dev/null
+++ b/docs/firmware/sealing.md
@@ -0,0 +1,175 @@
+# Sealing frames: counters and the replay window
+
+Every wake report a Node sends, and every acknowledgement the Server sends back, is sealed with
+ChaCha20-Poly1305. The Hub in between relays the sealed bytes and can neither read nor forge them.
+This page shows how a frame is sealed, where the counter that makes each nonce unique comes from,
+how the Server refuses replays, and why a report leaves the Node's flash only on a sealed
+acknowledgement. It builds on the purpose keys from [identity.md](identity.md); the radio that
+carries the frames is in [espnow.md](espnow.md). The index is [README.md](README.md).
+
+The governing decisions are AD-12 (sealing in both directions) and AD-17 (counters, the replay
+window and the report buffer) in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md),
+and requirement N-1 (delivery) in [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+## How it works
+
+### Two keys, one per direction
+
+The Node derives two 32-byte purpose keys from its device key: `seal/v1` for uplink frames and
+`ack/v1` for downlinks (`DeviceKeys` in `packages/rs/crypto/src/keys.rs`). Because the two
+directions use different keys, an uplink and a downlink can never share a (key, nonce) pair, even
+when their counters are equal. Each side keeps its own counter: the Node reserves uplink counters
+in flash, and the Server reserves downlink counters in its database.
+
+### Nonce and associated data
+
+ChaCha20-Poly1305 needs a 12-byte nonce that never repeats under one key. Coldframe builds it from
+the Device ID and the frame counter (`nonce` in `packages/rs/crypto/src/frame.rs`):
+
+```text
+nonce = device_id[0..4] ‖ counter (u64, big-endian) 12 bytes
+aad = protocol_major (u8) ‖ device_id (8 bytes) ‖ counter (u64, big-endian) 17 bytes
+```
+
+The key is already unique to the Device, so the counter alone keeps the nonce unique; the four
+Device ID bytes are only a prefix. The associated data (`aad`) is authenticated but not
+encrypted. It binds the ciphertext to the full Device ID, the counter and the wire major, so a
+frame cannot be replayed under another Device ID or counter, or read as another protocol version,
+without the tag failing. `seal` and `open` in the same file wrap the AEAD in
+`packages/rs/crypto/src/aead.rs`: the sealed bytes are the ciphertext followed by the 16-byte tag.
+
+### The envelope
+
+On the wire both directions use one message, `SealedEnvelope` in
+`packages/proto/coldframe/device/v1/envelope.proto`. It carries the protocol version, the Device
+ID and the counter in the clear, and the ciphertext. The receiver needs those clear fields to
+rebuild the nonce and the AAD. The plaintext of an uplink is a `NodeFrame` (one wake report); the
+plaintext of a downlink is a `Downlink`.
+
+`seal_uplink` in `packages/rs/transport/src/frame.rs` encodes the `NodeFrame`, seals it with the
+`seal/v1` key under the given counter, wraps it in a `SealedEnvelope`, and puts the radio kind
+byte in front. If the result would not fit one ESP-NOW payload it returns `SealError::TooLong`.
+The plaintext buffer is zeroed after use.
+
+`open_downlink` in the same file does the reverse for the Node. It drops a downlink silently
+unless the envelope is protocol version 1, names this Node's Device ID, opens under the `ack/v1`
+key, and decodes to a `Downlink` of version 1.
+
+### The frame counter in flash
+
+A counter that repeats after a reset would reuse a nonce, which breaks ChaCha20-Poly1305. So the
+Node never keeps the frame counter in RAM alone. `ReservedCounter` in
+`packages/rs/sensing/src/counter.rs` stores a *ceiling* in flash: every value below it may already
+have been used. To get counters, `reserve(n)` writes the raised ceiling, reads it back, and only
+then hands out `ceiling..ceiling + n`. A reset or power cut at any point can waste values (gaps
+are allowed) but never repeat one.
+
+The records go into two 4096-byte sectors of 32-byte slots, appended one after another. When the
+active sector is full, the other sector is erased and the next record goes there, so one sector
+always holds a valid ceiling. On boot the counter is the maximum over the valid records of both
+sectors. A region with written slots but no valid record is `CounterError::Corrupt`, and the
+counter never falls back to 0.
+
+The frame counter lives in the `cf_frame` partition (`apps/rs/node/partitions.csv`, opened by
+`FRAME_PARTITION` in `apps/rs/node/src/board/flash.rs`), with the record magic `CFFC`
+(`FRAME_MAGIC` in `packages/rs/transport/src/transport.rs`). Each wake that sends reserves one
+counter per frame in a single write (`reserve_counters`), and every frame of the burst is sealed
+under its own fresh counter. If the reservation fails, nothing is sealed and the reports stay
+buffered.
+
+> [!WARNING]
+> Never erase the `cf_frame` partition on a Node that has sent frames. The counter would restart
+> at 0. The next frames would then be sealed under nonces already used with the same `seal/v1`
+> key, which leaks plaintext to anyone who recorded the old frames. The Server would also refuse
+> them as replays, because they are far below its high-water mark, so the Node could not deliver
+> anything until its counter climbed past the old one. Only a board that has never sent a frame,
+> or a `dev-mode` Node whose flash root in `cf_ident` is erased with it (a new identity, so new
+> keys, enrolled again), may have `cf_frame` erased. An eFuse Node can never get new keys. The
+> partition table is in the [Node README](../../apps/rs/node/README.md).
+
+### The replay window
+
+An attacker can record a sealed frame and send it again; the tag is still valid. The receiver
+refuses it by its counter. `ReplayWindow` in `packages/rs/crypto/src/frame.rs` keeps the highest
+accepted counter and a 64-bit mask of the 64 counters ending at it. A counter above the high-water
+mark is accepted; a counter within the window is accepted once; anything older, or seen before,
+is a replay. `open_checked` refuses a replay *before* decrypting, and moves the window only
+*after* the frame authenticates, so a forged frame cannot push the window forward.
+
+The window tolerates the gaps and reordering that a reserved counter and a lossy radio produce.
+A large jump, as after many unsent wakes, simply forgets the old window.
+
+Who runs it: the Server, not the firmware. The Server's Device grain loads the window from the
+database, checks each uplink against it, and commits the moved window in the same transaction as
+the Readings (`apps/cs/server/Devices/DeviceGrain.cs`, using the C# port in
+`packages/cs/crypto/Frames.cs`). The Rust `ReplayWindow` and `open_checked` are the reference
+implementation, tested against the shared vectors; no firmware crate calls them today.
+
+### Downlinks on the Node
+
+The Node does not keep a replay window for downlinks. It does not need one, because an authentic
+downlink can only delete what the Server has already stored. Instead the transport decides
+whether a downlink is *fresh*: its `acked_counter` must be a frame of this wake's burst or the
+previous one, not acknowledged before (`Burst::acknowledge` in
+`packages/rs/transport/src/link.rs`). Only a fresh downlink resets the miss count and sets the
+clock. A replayed or late copy is harmless.
+
+### The report buffer and deletion on acknowledgement
+
+A wake report is written to the flash buffer `cf_buf` before the radio starts (`ReportBuffer` in
+`packages/rs/transport/src/buffer.rs`). It stays there, across deep sleep and power loss, until a
+sealed downlink acknowledges it. The buffer holds 96 reports (24 h at one wake every 15 minutes);
+when it is full, the oldest report is dropped.
+
+The `Downlink` lists the `reading_seq` values the Server has durably stored, as ranges
+(`acked_readings`). `ReportBuffer::ack` deletes exactly the Readings and reports whose sequence
+numbers fall in those ranges, by clearing bits of the record's live word. A record with no live
+bit left is dead, and its sector is erased later. What the radio reports for a send is never read:
+only a downlink that passed `open_downlink` deletes anything. A lost frame or a lost downlink
+therefore costs only a resend. A resend is the same report sealed again under a new counter, with
+the boot ID and uptime of the resend, and the Server takes it as a duplicate.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `SEAL_LABEL`, `ACK_LABEL` | `seal/v1`, `ack/v1` | `packages/rs/crypto/src/spec.rs` |
+| `AEAD_NONCE_LENGTH` | 12 bytes | `packages/rs/crypto/src/spec.rs` |
+| `AEAD_TAG_LENGTH` | 16 bytes | `packages/rs/crypto/src/spec.rs` |
+| `FRAME_NONCE_DEVICE_ID_PREFIX_LENGTH` | 4 bytes | `packages/rs/crypto/src/spec.rs` |
+| `FRAME_AAD_LENGTH` | 17 bytes | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_ID_LENGTH` | 8 bytes | `packages/rs/crypto/src/spec.rs` |
+| `PROTOCOL_MAJOR` | 1 | `packages/rs/crypto/src/spec.rs` |
+| `REPLAY_WINDOW` | 64 counters | `packages/rs/crypto/src/spec.rs` |
+| `FRAME_MAGIC` | `CFFC` | `packages/rs/transport/src/transport.rs` |
+| `SECTORS`, `SLOT_SIZE` | 2 sectors, 32-byte slots | `packages/rs/sensing/src/counter.rs` |
+| `CAPACITY` | 96 reports | `packages/rs/transport/src/buffer.rs` |
+| `MIN_SECTORS`, `MAX_SECTORS` | 5 to 8 sectors | `packages/rs/transport/src/buffer.rs` |
+| `BURST_MAX` | 8 frames per wake | `packages/rs/transport/src/transport.rs` |
+| `ACK_WINDOW_MS` | 300 ms | `packages/rs/transport/src/transport.rs` |
+| `MAX_PAYLOAD` | 250 bytes per radio message | `packages/rs/protocol/src/radio.rs` |
+
+The constants in `packages/rs/crypto/src/spec.rs` are generated from `packages/crypto-spec`, so
+the Rust, C# and Kotlin sides share them.
+
+## Where to read the code
+
+- `packages/rs/crypto/src/frame.rs` (`nonce`, `aad`, `seal`, `open`): the whole sealing rule in
+ a few lines.
+- `packages/rs/crypto/src/frame.rs` (`ReplayWindow`, `open_checked`): the sliding window and the
+ check-before-decrypt, accept-after-authenticate order.
+- `packages/rs/transport/src/frame.rs` (`seal_uplink`, `open_downlink`): how a `NodeFrame`
+ becomes a radio message, and what a Node accepts back.
+- `packages/rs/sensing/src/counter.rs` (`ReservedCounter`): a power-loss-safe counter on NOR
+ flash; the module doc explains the record layout.
+- `packages/rs/transport/src/transport.rs` (`reserve_counters`, `send_burst`): one reservation
+ per wake, one fresh counter per frame.
+- `packages/rs/transport/src/buffer.rs` (`ReportBuffer`, `ack`): the report buffer and deletion
+ on a sealed acknowledgement.
+- `packages/rs/transport/src/link.rs` (`Burst`): which downlinks count as fresh.
+- `packages/proto/coldframe/device/v1/envelope.proto` (`SealedEnvelope`, `NodeFrame`,
+ `Downlink`): the wire messages, with comments.
+- `apps/rs/node/partitions.csv`: where `cf_frame` and `cf_buf` sit in flash.
+- `apps/cs/server/Devices/DeviceGrain.cs`: the Server side, where the replay window is enforced
+ and the downlink counter is reserved.
diff --git a/docs/test.sh b/docs/test.sh
index ad83c846..9c6c337f 100755
--- a/docs/test.sh
+++ b/docs/test.sh
@@ -332,6 +332,15 @@ test -z "${STUB_ESP:-}"
expect "without the export file a ci:run block still runs, unsourced" 0 "1 blocks in 2 files pass" - \
-- env -u STUB_ESP PATH="${stubs}:${PATH}" DOCS_REPO_ROOT="${no_export}" ESP_EXPORT="${work}/none.sh" "${commands}"
+firmware_unmarked=$(fixture firmware-unmarked)
+page "${firmware_unmarked}" docs/firmware/x.md '# Firmware
+
+```bash
+true
+```'
+expect "docs/firmware/*.md is checked by default" 1 - "docs/firmware/x.md:3" \
+ -- check "${firmware_unmarked}"
+
expect "a file argument that is no file is a usage error" 2 "" "usage:" -- check "${good}" docs/build/nope.md
expect "the repository guide marks every block" 0 "pass" "" -- "${commands}" --list
diff --git a/packages/crypto-spec/scripts/lib/spec.ts b/packages/crypto-spec/scripts/lib/spec.ts
index 2437edc9..4afbb0ca 100644
--- a/packages/crypto-spec/scripts/lib/spec.ts
+++ b/packages/crypto-spec/scripts/lib/spec.ts
@@ -206,7 +206,7 @@ export function constantsOf(spec: CryptoSpec): ConstantGroup[] {
{
title: 'Sensor identity (AD-19)',
constants: [
- { name: 'sensorIdNamespace', kind: 'str', value: sensorId.namespace, doc: `UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace, "${sensorId.namespaceName}"): \`${sensorId.construction}\`.` },
+ { name: 'sensorIdNamespace', kind: 'str', value: sensorId.namespace, doc: `UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace, \`${sensorId.namespaceName}\`): \`${sensorId.construction}\`.` },
{ name: 'sensorIdNameFormat', kind: 'str', value: sensorId.nameFormat, doc: `The UUIDv5 name of a Sensor: ${sensorId.name}.` },
{ name: 'sensorQuantitySoilMoisture', kind: 'str', value: sensorId.quantities.soilMoisture, doc: 'Quantity token of soil moisture.' },
{ name: 'sensorQuantityAirTemperature', kind: 'str', value: sensorId.quantities.airTemperature, doc: 'Quantity token of air temperature.' },
diff --git a/packages/cs/crypto/Generated/CryptoSpec.g.cs b/packages/cs/crypto/Generated/CryptoSpec.g.cs
index b759acfa..a14e4789 100644
--- a/packages/cs/crypto/Generated/CryptoSpec.g.cs
+++ b/packages/cs/crypto/Generated/CryptoSpec.g.cs
@@ -286,7 +286,7 @@ public static class CryptoSpec
///
/// UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace,
- /// "https://github.com/escendit/coldframe/sensor"): `sensorId = UUIDv5(namespace, name), name = UTF-8
+ /// `https://github.com/escendit/coldframe/sensor`): `sensorId = UUIDv5(namespace, name), name = UTF-8
/// of nameFormat; namespace = UUIDv5(urlNamespace, namespaceName)`.
///
public const string SensorIdNamespace = "5f496f62-2f32-5456-8b2a-ebe01228c999";
diff --git a/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt b/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt
index ad6e27d3..829a5fd9 100644
--- a/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt
+++ b/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt
@@ -195,7 +195,7 @@ public object CryptoSpec {
/**
* UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace,
- * "https://github.com/escendit/coldframe/sensor"): `sensorId = UUIDv5(namespace, name), name = UTF-8
+ * `https://github.com/escendit/coldframe/sensor`): `sensorId = UUIDv5(namespace, name), name = UTF-8
* of nameFormat; namespace = UUIDv5(urlNamespace, namespaceName)`.
*/
public const val SENSOR_ID_NAMESPACE: String = "5f496f62-2f32-5456-8b2a-ebe01228c999"
diff --git a/packages/proto/coldframe/setup/v1/setup.proto b/packages/proto/coldframe/setup/v1/setup.proto
index 333201be..b3c9b086 100644
--- a/packages/proto/coldframe/setup/v1/setup.proto
+++ b/packages/proto/coldframe/setup/v1/setup.proto
@@ -76,8 +76,11 @@ message SetupMessage {
// What kind of Device answers.
enum DeviceKind {
+ // Not set: the default when the field is absent.
DEVICE_KIND_UNSPECIFIED = 0;
+ // A Hub: relays Node frames to the Server over Wi-Fi.
DEVICE_KIND_HUB = 1;
+ // A Node: measures and reports to its Hub over ESP-NOW.
DEVICE_KIND_NODE = 2;
}
@@ -99,8 +102,11 @@ message WifiScanRequest {}
// How a Wi-Fi network authenticates, as the Hub sees it.
enum WifiSecurity {
+ // Not set: the default when the field is absent.
WIFI_SECURITY_UNSPECIFIED = 0;
+ // No passphrase.
WIFI_SECURITY_OPEN = 1;
+ // WPA or WPA2 with a passphrase, including mixed WPA/WPA2.
WIFI_SECURITY_WPA2_PERSONAL = 2;
// WPA2/WPA3 transition mode; the Hub joins with WPA2.
WIFI_SECURITY_WPA3_TRANSITION = 3;
@@ -140,6 +146,7 @@ message WifiConfig {
// The outcome of joining.
enum WifiStatus {
+ // Not set: the default when the field is absent.
WIFI_STATUS_UNSPECIFIED = 0;
// Joined, and the Server at SiteBinding.server_url was reached and accepted the Hub (a signed
// heartbeat answered 200). Only then is the provisioning record stored.
@@ -196,6 +203,7 @@ message EnrolmentResponse {
// Why a step failed.
enum SetupErrorCode {
+ // Not set: the default when the field is absent.
SETUP_ERROR_CODE_UNSPECIFIED = 0;
// The message did not parse or had missing fields.
SETUP_ERROR_CODE_MALFORMED_MESSAGE = 1;
diff --git a/packages/rs/README.md b/packages/rs/README.md
index b00c79de..1163d314 100644
--- a/packages/rs/README.md
+++ b/packages/rs/README.md
@@ -16,4 +16,8 @@ These crates are members of the Cargo workspace in the repository root. The Hub
of them but `sensing` and `transport`, and the Node firmware links all of them (`setup` brings in
`uplink`, which the Node never runs), with the `esp` toolchain, which is Rust 1.95. That is why they declare `rust-version = "1.95"`. None depends on an
esp crate.
+
+The [firmware reference](../../docs/firmware/README.md) explains the mechanisms these crates
+implement and where to read them.
+
Unit tests stay inline next to the code. Integration tests live in [`tests/rs`](../../tests/rs).
diff --git a/packages/rs/crypto/src/spec.rs b/packages/rs/crypto/src/spec.rs
index 919ffd28..97cb0619 100644
--- a/packages/rs/crypto/src/spec.rs
+++ b/packages/rs/crypto/src/spec.rs
@@ -178,7 +178,7 @@ pub const HEARTBEAT_MAX_SKEW_MS: u64 = 300000;
// Sensor identity (AD-19)
/// UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace,
-/// "https://github.com/escendit/coldframe/sensor"): `sensorId = UUIDv5(namespace, name), name =
+/// `https://github.com/escendit/coldframe/sensor`): `sensorId = UUIDv5(namespace, name), name =
/// UTF-8 of nameFormat; namespace = UUIDv5(urlNamespace, namespaceName)`.
pub const SENSOR_ID_NAMESPACE: &str = "5f496f62-2f32-5456-8b2a-ebe01228c999";
diff --git a/packages/rs/protocol/build.rs b/packages/rs/protocol/build.rs
index f8db2bf0..3f70309c 100644
--- a/packages/rs/protocol/build.rs
+++ b/packages/rs/protocol/build.rs
@@ -3,7 +3,10 @@
//!
//! protox compiles the file into a descriptor set in pure Rust (no `protoc`), and micropb-gen
//! turns that into no_std Rust with heapless containers. Every string, bytes and repeated field
-//! gets a fixed capacity, so each message has a compile-time `MAX_SIZE`.
+//! gets a fixed capacity, so each message has a compile-time `MAX_SIZE`. The descriptor set keeps
+//! its source info, so micropb-gen turns every proto comment into the rustdoc of the generated
+//! item. A message, field or enum value without a comment warns under `missing_docs`, which CI
+//! turns into a failure with `-D warnings` (clippy in the `rust` job, rustdoc in the Docs workflow).
use std::path::PathBuf;
use std::{env, fmt};
@@ -46,7 +49,7 @@ fn main() {
}
let mut compiler = protox::Compiler::new([&proto_root]).expect("proto root exists");
- compiler.include_source_info(false);
+ compiler.include_source_info(true);
compiler
.open_file(SETUP_PROTO)
.expect("setup.proto compiles");
diff --git a/packages/rs/protocol/src/lib.rs b/packages/rs/protocol/src/lib.rs
index f7923020..3c559ea0 100644
--- a/packages/rs/protocol/src/lib.rs
+++ b/packages/rs/protocol/src/lib.rs
@@ -3,8 +3,8 @@
//! `no_std` without `alloc`: every string, bytes and repeated field is a heapless container with
//! a fixed capacity, so each message has a compile-time `MAX_SIZE`.
//!
-//! - [`setup_v1`]: the BLE setup protocol, `coldframe.setup.v1` (AD-25). Module path in the
-//! generated code: `coldframe_::setup_::v1_`; the `oneof body` is `SetupMessage_::Body`.
+//! - [`setup_v1`]: the BLE setup protocol, `coldframe.setup.v1` (AD-25); the `oneof body` is
+//! `SetupMessage_::Body`.
//! - [`device_v1`]: sealed Device frames and downlinks, `coldframe.device.v1` (AD-9, AD-12):
//! `SealedEnvelope`, `NodeFrame` (its `oneof measured` is `NodeFrame_::Measured`), `Downlink`
//! and `SpecificationSet`, sized for a Node with four Sensors.
@@ -13,6 +13,9 @@
//! - [`encode`] and [`decode`]: one message to or from a byte slice; [`encode_node_frame`] for a
//! `NodeFrame` in field-number order.
//!
+//! Both wire modules re-export the generated package modules, whose rustdoc is the comments of
+//! the `.proto` files.
+//!
//! Types that can hold a Wi-Fi password (`WifiConfig`, `SetupMessage` and its `Body`) do not
//! implement `Debug`, so a password cannot reach a log through `{:?}`.
@@ -28,7 +31,6 @@ use micropb::{MessageDecode, MessageEncode, PbEncoder};
clippy::pedantic,
nonstandard_style,
unused,
- missing_docs,
reason = "generated by micropb-gen"
)]
mod generated {
@@ -39,11 +41,17 @@ pub mod radio;
pub use micropb;
-/// `coldframe.setup.v1`: the BLE setup protocol.
-pub use generated::coldframe_::setup_::v1_ as setup_v1;
+/// `coldframe.setup.v1`: the BLE setup protocol (AD-25), from
+/// `packages/proto/coldframe/setup/v1/setup.proto`.
+pub mod setup_v1 {
+ pub use super::generated::coldframe_::setup_::v1_::*;
+}
-/// `coldframe.device.v1`: sealed Device frames and downlinks.
-pub use generated::coldframe_::device_::v1_ as device_v1;
+/// `coldframe.device.v1`: sealed Device frames and downlinks (AD-9, AD-12), from
+/// `packages/proto/coldframe/device/v1/envelope.proto`.
+pub mod device_v1 {
+ pub use super::generated::coldframe_::device_::v1_::*;
+}
/// The wire major every message carries in `protocol_version`.
pub const PROTOCOL_VERSION: u32 = 1;
diff --git a/packages/rs/sensing/src/lib.rs b/packages/rs/sensing/src/lib.rs
index 8996132d..80a92e3d 100644
--- a/packages/rs/sensing/src/lib.rs
+++ b/packages/rs/sensing/src/lib.rs
@@ -7,7 +7,8 @@
//! decides what the wake does.
//! - [`battery`]: the switched-divider scaling and the LiPo discharge curve, giving an
//! approximate [`BatteryLevel`].
-//! - [`env`]: [`env_sample_from`], the one conversion of the BME680's floats to integer units.
+//! - [`env`](mod@env): [`env_sample_from`], the one conversion of the BME680's floats to integer
+//! units.
//! - [`counter`]: [`ReservedCounter`], a strictly increasing `u64` reserved in flash before any
//! value is handed out (AD-17). It backs both `reading_seq` and the boot counter.
//! - [`wake`]: [`run_wake`], one measurement with the probe and divider switched on only while
From 7fc31272db13b7eafe16770fde02d91d074f48c7 Mon Sep 17 00:00:00 2001
From: Simon Novak <25969+snovak7@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:41:02 +0200
Subject: [PATCH 2/2] docs(reproduce): reproduction checklist, result record
and stuck-point form
Story 10-5 (SM-5): add docs/reproduce/ with an end-to-end checklist
(parts A-H) that walks the build and deploy guides from parts to a
Threshold Alert, a result template and results folder, and a GitHub
issue form for stuck points. Link it from README and both guides.
The reproduction run itself is owed by an operator.
Closes #129
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01V7eGk9gAtFHrjkmRPs7jPG
---
.../reproduction-stuck-point.yml | 82 +++++++
README.md | 2 +
...-10-5-someone-else-reproduces-coldframe.md | 175 ++++++++++++++
docs/build/README.md | 4 +
docs/deploy/README.md | 2 +
docs/reproduce/README.md | 223 ++++++++++++++++++
docs/reproduce/results/README.md | 25 ++
docs/reproduce/results/TEMPLATE.md | 59 +++++
8 files changed, 572 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE/reproduction-stuck-point.yml
create mode 100644 _bmad-output/implementation-artifacts/spec-10-5-someone-else-reproduces-coldframe.md
create mode 100644 docs/reproduce/README.md
create mode 100644 docs/reproduce/results/README.md
create mode 100644 docs/reproduce/results/TEMPLATE.md
diff --git a/.github/ISSUE_TEMPLATE/reproduction-stuck-point.yml b/.github/ISSUE_TEMPLATE/reproduction-stuck-point.yml
new file mode 100644
index 00000000..752f5589
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/reproduction-stuck-point.yml
@@ -0,0 +1,82 @@
+name: Reproduction stuck point
+description: >-
+ A place where the docs alone did not get you through the reproduction checklist
+ (docs/reproduce/README.md).
+title: "[reproduce] "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: >-
+ Thank you for running the reproduction checklist. File one issue per stuck point.
+ **Do not paste any secret**: no setup code, key, token, password, KEK, Secret value or
+ account credential. Write your domain as ``.
+ - type: dropdown
+ id: part
+ attributes:
+ label: Checklist part
+ options:
+ - A. Start clean
+ - B. The release
+ - C. Build the Node and the Hub
+ - D. Flash the Hub and the Node
+ - E. Deploy the Server at home
+ - F. Build your own apps
+ - G. Add the Hub and the Node, calibrate
+ - H. A Threshold Alert inside the Notification Window
+ validations:
+ required: true
+ - type: input
+ id: guide
+ attributes:
+ label: Guide page and section
+ description: The page and heading you were following.
+ placeholder: docs/build/flash.md#first-boot-and-the-setup-code
+ validations:
+ required: true
+ - type: textarea
+ id: did
+ attributes:
+ label: What you did
+ description: The steps, commands and values you used (secrets removed).
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: What you expected
+ description: What the guide or the checklist said should happen.
+ validations:
+ required: true
+ - type: textarea
+ id: happened
+ attributes:
+ label: What happened
+ description: The output, error or screen you got instead, and any workaround you found.
+ render: text
+ validations:
+ required: true
+ - type: input
+ id: release
+ attributes:
+ label: Release tag
+ description: Write none if you have no tag yet.
+ placeholder: v0.2.0
+ validations:
+ required: false
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment
+ description: >-
+ Machine OS, home server arch and OS, boards and BOM substitutes, phone model and OS.
+ validations:
+ required: false
+ - type: checkboxes
+ id: no-secrets
+ attributes:
+ label: No secrets
+ options:
+ - label: This issue contains no setup code, key, token, password or other credential.
+ required: true
diff --git a/README.md b/README.md
index 385b7a10..b124f4e0 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@ and deployment all live in this repository.
- [Build guide](docs/build/README.md): build, flash and add your own Node and Hub.
- [Deploy guide](docs/deploy/README.md): run Coldframe on your home server, then
[build your own apps](docs/build/apps.md).
+- [Reproduce Coldframe](docs/reproduce/README.md): the end-to-end checklist for a second person
+ rebuilding Coldframe from these docs, and how to report where they got stuck.
- [Firmware reference](docs/firmware/README.md): learn ESP-NOW, BLE provisioning, eFuse keys and
deep sleep from the Node and Hub firmware.
- [Hardware design](hardware/README.md): schematics, BOM, wiring, enclosure and probe sealing.
diff --git a/_bmad-output/implementation-artifacts/spec-10-5-someone-else-reproduces-coldframe.md b/_bmad-output/implementation-artifacts/spec-10-5-someone-else-reproduces-coldframe.md
new file mode 100644
index 00000000..a37f24b1
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/spec-10-5-someone-else-reproduces-coldframe.md
@@ -0,0 +1,175 @@
+---
+title: 'Someone else reproduces Coldframe'
+type: 'feature'
+created: '2026-10-20'
+status: 'awaiting-operator'
+baseline_revision: 'c8c4a2c3c8e770d5b4c32e78273508c8dc0e222c'
+review_loop_iteration: 0
+followup_review_recommended: true
+context:
+ - '{project-root}/_bmad-output/implementation-artifacts/epic-10-context.md'
+warnings: [oversized]
+deferred:
+ - summary: >-
+ docs/check-links.sh ignores #anchor fragments, so the ~30 section links of the reproduction checklist can break silently when a guide heading is renamed.
+ evidence: |-
+ check-links.sh header says it checks "the file part of the target (no #anchor, no ?query)"; docs/test.sh:93-100 fixture expects exit 0 for power.md#budget against a file with only "# Power". All anchors resolve today (checked by script). Pre-existing, deliberate Story 10.2 behaviour; fix is anchor-slug resolution in check-links.sh plus a failing fixture in docs/test.sh.
+ location: >-
+ docs/check-links.sh
+ severity: low
+operator_actions:
+ - "Publish the first release tag vX.Y.Z (story 10-1's operator actions) from a main that contains docs/reproduce/, and confirm its GitHub Release carries the firmware binaries and SHA256SUMS."
+ - "Find a person other than the author, or prepare a clean machine, fresh Node and Hub boards and no prior Coldframe state for an author run, and have them run docs/reproduce/README.md parts A to H against that release tag with only the public repository and docs."
+ - "File every stuck point of that run with the Reproduction stuck point issue form, and merge a fix for each one into the docs (or into a new release tag when the fix is in firmware, Server, charts or apps)."
+ - "Commit the run's record as docs/reproduce/results/YYYY-MM-DD-.md copied from TEMPLATE.md, and update its Fixed by PR column and verdict until it reads Passed before closing story 10-5."
+ - "After this PR merges, open the repository's New issue page on GitHub and confirm the Reproduction stuck point form is offered with its part, guide, environment and release fields."
+---
+
+
+
+## Intent
+
+**Problem:** SM-5 ("reproducible from the docs") has no test instrument. Nothing in `docs/` tells a second person what to do end to end, from parts to a Threshold Alert, what counts as passing, how to file a stuck point, or where the run's results go. The story's real proof, a person other than the author (or the author on a clean machine with fresh hardware) completing the run, can only be done by a human.
+
+**Approach:** Add a manual reproduction checklist under `docs/reproduce/` that walks Stories 10.2 and 10.3 end to end through the existing guides. It links them and does not copy their commands. Add a result template, a results folder, and a GitHub issue form for stuck points. Link the checklist from the README and from the build and deploy guides. The run itself, the issues it raises, the doc fixes and the committed result are operator actions.
+
+## Boundaries & Constraints
+
+**Always:**
+- The checklist follows the guides in order: release tag → parts and assembly → flash Hub and Node → deploy (RKE2 + Fleet) → build own apps → add Hub, add Node → calibrate → Threshold Alert inside the Notification Window. Each step links the guide section that does the work.
+- Each check is a `- [ ]` box with an observable expected result, in the style of `docs/bench/*-checklist.md` (one `#` title, intro, "What you need", lettered `##` parts, a closing "Result" section).
+- State the AC's entry conditions: public repo and docs only; a person other than the author, or the author on a clean machine and fresh hardware with no prior state. No help from the author beyond what is in the repository.
+- Every stuck point becomes a GitHub issue (through the new form), and the doc fix lands before the story closes. The result record lists every issue.
+- Facts must match the existing docs: setup code (Hub on first boot, Node on a 3 s long press), the 180 s setup window, 2.4 GHz WPA2 or mixed WPA2/WPA3 Wi-Fi (no WPA3-only), irreversible eFuse burn outside dev mode, calibration after 2–3 days settling, low Threshold default 30 %, Notification Window default 07:00–22:00, and an Alert after three consecutive Readings below the low Threshold.
+- An author run counts only with an attested clean machine, fresh hardware and no prior state (no cached toolchain, cluster, realm or enrolled boards). The story stays open until every listed stuck-point issue is fixed in the docs.
+- Never commit secrets: the result template warns against setup codes, keys, tokens and domains' credentials.
+
+**Never:**
+- Edit `.github/workflows/ci.yml` or `sprint-status.yaml`.
+- Copy guide commands into the checklist. Add no fenced `bash`/`sh` blocks (they would need `ci:` markers and duplicate the guides).
+- Fake a reproduction result or pre-fill a run record.
+- Create new GitHub labels.
+
+
+
+## Code Map
+
+- `docs/build/README.md` -- the 6-step build guide (parts, assemble, toolchain, flash Hub, flash Node, add from the app). Anchors: `#1-get-the-parts` … `#6-add-them-from-the-app`. Its "Afterwards"-style pointers are where a link to the checklist fits (end of step 6).
+- `docs/build/flash.md` -- `#toolchain`, `#flash-from-the-release-binaries`, `#first-boot-and-the-setup-code`, `#the-buttons`, `#wi-fi`. It holds the eFuse warning.
+- `docs/build/power.md#soil-moisture` -- calibration timing.
+- `docs/build/apps.md` -- build your own apps (`#android`, `#ios`, `#server-push`).
+- `docs/deploy/README.md` -- six sections (`#1-domain-and-dns-api-token` … `#6-first-sign-in`) and `#afterwards`. It links `docs/operations/install.md#verification-checklist-on-the-home-server`.
+- `docs/bench/push-checklist.md:102-125` -- the Alert part (Notification Window default, three Readings below the low Threshold, notification text). Style model for checklists. `docs/bench/node-power-checklist.md:87-91` -- a Result table model.
+- `docs/operations/release.md` -- release tags. No `vX.Y.Z` tag exists yet (`gh release list` is empty), so the run depends on story 10-1's operator action.
+- `hardware/README.md`, `hardware/bom.md` -- parts.
+- `README.md:10-20` -- "Start here" list.
+- `docs/check-links.sh` -- default roots include `docs` (recursive) and `README.md`. It checks relative links and backticked `apps/ packages/ deploy/ docs/ hardware/ tests/ .github/` paths. `docs/check-commands.sh` default glob is `docs/build/*.md docs/firmware/*.md` only.
+- `.github/` has no `ISSUE_TEMPLATE/` yet. Repo labels are the GitHub defaults (`bug`, `documentation` absent). `ci.yml:87,112` path filters do not match `.github/ISSUE_TEMPLATE/**`, so an issue form does not force run-all. `docs.yml` does not trigger on it either.
+
+## Tasks & Acceptance
+
+**Execution:**
+- `docs/reproduce/README.md` -- the reproduction checklist. Intro (SM-5, who may run it, entry conditions, link to the issue form and to `results/`). "What you need" (release tag, parts from the BOM, home server, domain at Cloudflare, SMTP, S3, phone(s) with Apple/Firebase accounts, a clean machine). Parts A–H:
+ - A: Start clean — attestation
+ - B: Release
+ - C: Build the Node and Hub
+ - D: Flash
+ - E: Deploy
+ - F: Build the apps
+ - G: Add Hub and Node, calibrate
+ - H: Threshold Alert inside the Notification Window
+
+ Each part links the guide section and lists observable checks. Then "When you get stuck" (file the issue form, note it, the story stays open until fixed) and "Record the result" (copy the template).
+- `docs/reproduce/results/TEMPLATE.md` -- the copy-me result record: tester (handle or "author, clean machine"), date, release tag, hardware, server OS, phones; attestation box; one row per part A–H (pass/fail/notes); a stuck-points table (issue number, part, fixed by PR); a final verdict line; a no-secrets warning.
+- `docs/reproduce/results/README.md` -- what the folder holds, the file naming `YYYY-MM-DD-.md`, and the rule that SM-5 holds only when a run passes with every listed issue fixed.
+- `.github/ISSUE_TEMPLATE/reproduction-stuck-point.yml` -- a GitHub issue form: title prefix `[reproduce] `, label `bug`. Fields: checklist part (dropdown A–H), guide page and section, what you did, what you expected, what happened, release tag, and a confirmation checkbox that no secret is included.
+- `README.md` -- add a "Reproduce Coldframe" bullet under "Start here".
+- `docs/build/README.md`, `docs/deploy/README.md` -- one sentence each, pointing a second-person tester to `docs/reproduce/README.md`.
+
+**Acceptance Criteria:**
+- Given the repository, when a tester opens `docs/reproduce/README.md`, then it walks Stories 10.2 and 10.3 in order. It covers building a Node and a Hub, deploying, installing their own apps, adding the Hub and the Node, calibrating, and receiving a Threshold Alert inside their Notification Window. Every step links the guide that does it, and every link and path passes `docs/check-links.sh`.
+- Given a stuck point, when the tester opens a new issue on GitHub, then the "Reproduction stuck point" form is offered with the fields above.
+- Given a finished run, when the tester copies `docs/reproduce/results/TEMPLATE.md`, then the record captures the attestation, a per-part result, every stuck-point issue with its fixing PR, and a verdict, and it says where the record is committed.
+- Given the change, when `docs/test.sh`, `docs/check-links.sh` and `docs/check-commands.sh --list` run, then all pass.
+
+## Spec Change Log
+
+## Review Triage Log
+
+### 2026-10-20 — Review pass
+- verdicts: 23 findings — high 0, medium 4, low 17, false 2, maybe-false 0
+- findings:
+ - `[medium]` `[patch]` TEMPLATE.md offers "Passed with open issues" while README Result calls anything else a failed run — fixed: verdicts are now Passed / Pending doc fixes / Failed at part X, and Result says a pending run becomes a pass once every listed fix merges.
+ - `[low]` `[patch]` results/README.md forbids editing a record yet asks to fill "Fixed by PR" later, with no rule for the verdict (same root cause as the row above) — fixed: only the Fixed by PR column and the Verdict line (Pending → Passed) may change after the run.
+ - `[medium]` `[patch]` a rerun cannot meet "on one release tag" or Part A's fresh boards — fixed: "When you get stuck" says to rerun the affected parts from the fixed docs on `main` with the same artifacts and already-flashed boards, noted in the same record.
+ - `[low]` `[reject]` tags cut before this PR lack the checklist — no release tag exists yet (`gh release list` is empty), so the first tag will contain it; the operator action asks for a tag from a main that has docs/reproduce/.
+ - `[low]` `[patch]` Part D checks the Hub's burned/existing identity but not the Node's — fixed: added the Node's `source=burned KEYn` once / `source=existing KEYn` same Device ID check (flash.md:211,222).
+ - `[low]` `[patch]` Part E has no check for deploy section 4 (realm) or for replacing the bootstrap admin — fixed: one check each, matching docs/deploy/README.md:147-167 and section 6.
+ - `[low]` `[patch]` Part H's recovery check passes even if the Lot never recovers — fixed: the Lot must show within its Thresholds again (the Alert closes).
+ - `[low]` `[patch]` issue form uses only `bug` and has no environment fields — the label half is refuted: the `[reproduce] ` title prefix lets a maintainer list every stuck point, and the spec forbids new labels; fixed the other half with an optional Environment textarea.
+ - `[medium]` `[patch]` stuck points needing a code/chart fix have no path to "fixed in the docs" (same root cause as the rerun row) — fixed: such a fix ships in a new release tag and the run continues from the first affected part on it.
+ - `[false]` `[reject]` "Story 10.5" and "SM-5" are internal IDs an outsider cannot read — the intro explains SM-5 in plain words ("can be reproduced from the public repository alone"), and the "(Story N.M)" H1 matches every docs/bench checklist.
+ - `[low]` `[patch]` author tester id spelled `author-clean` and "author, clean machine" — fixed: `author-clean` in TEMPLATE.md too.
+ - `[low]` `[defer]` ~25 `#anchor` links are never checked by CI, and `.github/ISSUE_TEMPLATE/**` is outside docs.yml's path filters — anchors all resolve today; ignoring fragments is the deliberate, tested Story 10.2 behaviour of check-links.sh, so it is deferred; the form is validated by GitHub when it renders.
+ - `[low]` `[patch]` Part A does not rule out reused accounts and credentials from an earlier install — fixed: a Part A box and an Attestation box for Firebase project, APNs key, Cloudflare token and S3 bucket (author runs).
+ - `[medium]` `[patch]` (edge) the "Passed with open issues" verdict lets a record claim a pass the checklist counts as failed — same group as the first row; fixed there.
+ - `[low]` `[patch]` (edge) a non-author cannot tell which Part A boxes apply while the pass rule needs all ticked — fixed: the remaining Part A items are required for author runs; a non-author ticks them where true and writes n/a otherwise.
+ - `[low]` `[patch]` (edge) the checksum check only lists the two .bin files, but `--strict` needs every asset — fixed: the check runs with every Release asset next to `SHA256SUMS` (release.md "Verify a download").
+ - `[low]` `[patch]` (edge) Part E asks for every Secret of section 3 although `coldframe-push` is optional and needs Part F's keys (docs/deploy/README.md section 3, "optional") — fixed: "every required Secret", push may follow in Part F.
+ - `[low]` `[patch]` (edge) Part A's "every eFuse key block unused" conflicts with Part D's "at least one unused USER block" — fixed: Part A says "never flashed with Coldframe (Part D checks the key blocks)".
+ - `[low]` `[patch]` (edge) the issue form requires a release tag even when stuck before one is known — fixed: not required, with "Write none if you have no tag yet".
+ - `[low]` `[patch]` (edge) template and results README disagree on the author id — same group as the author-id row; fixed there.
+ - `[low]` `[defer]` (verification-gap) section anchors in the checklist are never checked — same group as the anchors row; deferred (DW entry in frontmatter).
+ - `[low]` `[reject]` (verification-gap other) the issue form and its A–H dropdown are not checked by any CI — GitHub validates the form when it renders the chooser; a CI check would be new surface for a list that changes only with the checklist's part headings.
+ - `[false]` `[reject]` (intent) the work is uncommitted, the spec still `in-review` and lacks `operator_actions:` — the audit ran before finalization; this step sets `awaiting-operator`, the list and the commit.
+
+## Design Notes
+
+**Why the checklist does not copy commands:** the guides are the thing under test. A checklist that repeats their commands would let a stale guide pass. Each part says "follow X" and then lists what you must observe.
+
+**Why the run is an operator action:** the AC requires a physical build, real accounts (domain, Apple, Firebase, SMTP, S3), a person other than the author and the first release tag. An agent can only prepare the instrument.
+
+## Verification
+
+**Commands:**
+- `docs/check-links.sh` -- expected: every link and path resolves, including `docs/reproduce/**`
+- `docs/test.sh` -- expected: all cases pass
+- `docs/check-commands.sh --list` -- expected: OK (no new unmarked blocks in the default files)
+- `python3 -c 'import yaml,sys; yaml.safe_load(open(".github/ISSUE_TEMPLATE/reproduction-stuck-point.yml"))'` -- expected: parses
+- `actionlint` is not relevant (no workflow change)
+
+**Manual checks (if no CLI):**
+- Every fact listed under Always matches the cited guide lines.
+
+## Auto Run Result
+
+Status: awaiting-operator
+
+**Summary.** The agent-doable part of story 10-5 is done. A manual reproduction checklist (`docs/reproduce/README.md`, parts A–H) walks the build guide and the deploy guide end to end, from the parts to a Threshold Alert inside the Notification Window. It links each guide section instead of copying commands, and states entry conditions, a clean-machine attestation, the stuck-point procedure, rerun rules and the pass rule. A result template and a results folder hold the committed record. A GitHub issue form files stuck points. README, the build guide and the deploy guide link the checklist. The reproduction run itself, the issues it raises, their fixes and the committed record need a human, real hardware, real accounts and a release tag, so they are listed under `operator_actions`.
+
+**Files changed.**
+- `docs/reproduce/README.md` -- the reproduction checklist (new).
+- `docs/reproduce/results/TEMPLATE.md` -- the copy-me result record (new).
+- `docs/reproduce/results/README.md` -- naming, edit rules and when SM-5 holds (new).
+- `.github/ISSUE_TEMPLATE/reproduction-stuck-point.yml` -- the "Reproduction stuck point" issue form (new).
+- `README.md` -- "Reproduce Coldframe" bullet under Start here.
+- `docs/build/README.md`, `docs/deploy/README.md` -- one pointer each to the checklist.
+
+**Review findings.** 23 findings: high 0, medium 4, low 17, false 2.
+- **Patches applied (17 rows in 13 entries):** verdict options and edit rules, rerun and code-fix path, Node identity check, realm and bootstrap-admin checks, optional push Secret, Part H recovery, Part A applicability, board and reused-account wording, full-asset checksum check, author id, issue form release/environment fields.
+- **Deferred (1 entry):** `#anchor` links are not checked by `docs/check-links.sh` (pre-existing Story 10.2 behaviour).
+- **Rejected:** pre-existing tags lacking the checklist (no tag exists yet); internal IDs (false: SM-5 is explained, H1 matches the bench checklists); no CI for the issue form (GitHub validates it; new surface); uncommitted bookkeeping (false: finalization does it).
+
+**Follow-up review recommended: true.** Two medium entries were patched on this first pass (patched: high 0, medium 2 entries, low 11 entries). The rewritten rules for pending verdicts, reruns from `main` and code fixes that need a new tag have not been read by an independent reviewer.
+
+**Verification.**
+- `docs/check-links.sh`: 876 links and paths in 38 files resolve.
+- `docs/test.sh`: 50 passed, 0 failed.
+- `docs/check-commands.sh --list`: 20 blocks in 12 files pass (no new fenced shell blocks).
+- The issue form YAML parses.
+- By hand: the cited log lines (`flash.md` 195-226), release assets and the verify step (`release.md`), the optional `coldframe-push` Secret, `prepare-realm.sh`, and the Alert facts (`push-checklist.md` Part D) match the checklist. The reviewer layers resolved every anchor by script.
+
+**Residual risks.**
+- No release tag exists yet, so parts B and D cannot pass until story 10-1's release is cut.
+- Part G's app wording ("the Hub added", "no longer asks for Calibration") is read from app behaviour, not a guide, and may need tightening after the first run.
+- The issue form is seen live only once it is on the default branch.
diff --git a/docs/build/README.md b/docs/build/README.md
index a3624b05..d7e6a29d 100644
--- a/docs/build/README.md
+++ b/docs/build/README.md
@@ -49,6 +49,10 @@ first: the [deploy guide](../deploy/README.md) sets it up on your home server (t
3. Calibrate the soil probe once it has settled in the soil for 2–3 days
([why](power.md#soil-moisture)).
+Reproducing Coldframe as someone other than its author? Follow the
+[reproduction checklist](../reproduce/README.md), which walks this guide and the deploy guide end
+to end.
+
## Checking this guide
CI checks these pages on every change (`.github/workflows/docs.yml`):
diff --git a/docs/deploy/README.md b/docs/deploy/README.md
index d190b889..a322e5ef 100644
--- a/docs/deploy/README.md
+++ b/docs/deploy/README.md
@@ -233,3 +233,5 @@ restart durability and a backup in S3.
- **Verification**: tick the checklist again after every upgrade.
- **Restore**: when the database is lost or damaged, restore it from the S3 backups with the
same `coldframe-device-kek` ([`restore.md`](../operations/restore.md)).
+- **Reproducing Coldframe** as a second person: the [reproduction checklist](../reproduce/README.md)
+ walks this guide and the build guide end to end.
diff --git a/docs/reproduce/README.md b/docs/reproduce/README.md
new file mode 100644
index 00000000..0fc10641
--- /dev/null
+++ b/docs/reproduce/README.md
@@ -0,0 +1,223 @@
+# Reproduction checklist: someone else builds Coldframe (Story 10.5)
+
+This checklist is the test of Coldframe's promise that it can be reproduced from the public
+repository alone (success criterion SM-5). It walks the [build guide](../build/README.md) and the
+[deploy guide](../deploy/README.md) end to end, from the parts to a Threshold Alert on your phone.
+It does not repeat their commands: **the guides are what is under test**. Each part names the guide
+section that does the work and lists what you must observe when it is done.
+
+Who may run it:
+
+- **A person other than the author**, using only this public repository and its docs; or
+- **the author on a clean machine with fresh hardware and no prior state**: no cached toolchain, no
+ existing cluster, no Keycloak realm, no enrolled boards, no app build or account left over from
+ earlier work. Part A records that attestation; a run without it does not count.
+
+Nobody helps you beyond what is written in the repository. When a guide is unclear, wrong or
+incomplete, that is a **stuck point**: do not ask the author, file it
+([When you get stuck](#when-you-get-stuck)) and work around it only if the guide's own text lets
+you. Every stuck point is fixed in the docs before the story closes.
+
+At the end you write a result record from [`results/TEMPLATE.md`](results/TEMPLATE.md) and commit
+it under [`results/`](results/README.md).
+
+## What you need
+
+- **A release tag** `vX.Y.Z` of this repository with its GitHub Release (firmware binaries and
+ `SHA256SUMS`): [`docs/operations/release.md`](../operations/release.md). Use the same tag for
+ everything below.
+- **The parts for one Node and one Hub** from the [bill of materials](../../hardware/bom.md), plus
+ USB cables, the soil the probe goes into, and the tools to assemble the Node.
+- **A home server** that runs all the time, as listed in the deploy guide's
+ [Before you start](../deploy/README.md#before-you-start).
+- **A domain** whose DNS zone is hosted at Cloudflare, and a local DNS resolver you control.
+- **An SMTP account** that can send mail from an address of yours.
+- **An S3 bucket** off the home server for the database backups.
+- **One or two phones**: an iPhone with a Mac, Xcode and an Apple Developer Program membership,
+ and/or an Android phone with a Firebase project
+ ([what the apps need](../build/apps.md#what-you-need)).
+- **A 2.4 GHz Wi-Fi network** with WPA2-Personal or mixed WPA2/WPA3. WPA3-only is not supported
+ ([`flash.md`, Wi-Fi](../build/flash.md#wi-fi)).
+- **A clean machine** to run the commands and builds from, with nothing of Coldframe on it yet.
+- A notebook (paper or a local file outside the repository) for the time each part takes and every
+ stuck point.
+
+Keep every key, token, password and setup code out of the repository, out of issues and out of the
+result record.
+
+## A. Start clean
+
+- [ ] You are not the author, **or** you are the author and every item below is true. Write which in
+ the result record.
+
+The remaining items are required for an author run. A non-author ticks each one only where it is
+true and writes n/a in the result record otherwise.
+
+- [ ] The machine you run the commands and builds from has no esp toolchain, `espflash`, Rust
+ target cache, Gradle or Xcode build cache for Coldframe, and no kubeconfig for a Coldframe
+ cluster.
+- [ ] The home server has no RKE2, Fleet or Coldframe install on it.
+- [ ] The Hub and Node boards are fresh: never flashed with Coldframe (Part D checks the key
+ blocks).
+- [ ] No Keycloak realm, account, Site or Device of an earlier Coldframe install exists anywhere you
+ will use, and no Coldframe app is installed on the phones.
+- [ ] No Firebase project, APNs key, Cloudflare API token or S3 bucket made for an earlier Coldframe
+ install is reused.
+- [ ] You have the repository from its public URL, checked out at the release tag of Part B, and no
+ other notes than the docs in it.
+
+## B. The release
+
+Follow [`docs/operations/release.md`, What gets published](../operations/release.md#what-gets-published)
+and [Verify a download](../operations/release.md#verify-a-download).
+
+- [ ] The GitHub Release for `vX.Y.Z` exists and carries `coldframe-hub-X.Y.Z.bin`,
+ `coldframe-node-X.Y.Z.bin` and `SHA256SUMS`.
+- [ ] The download checks: with every Release asset downloaded next to `SHA256SUMS`, every line of
+ the checksum check says `OK`.
+- [ ] The release notes link the build-your-own-apps guide.
+- [ ] Your clone is at that tag; the deploy guide, the firmware and the apps are all built from it.
+
+## C. Build the Node and the Hub
+
+Follow the [build guide](../build/README.md), steps
+[1. Get the parts](../build/README.md#1-get-the-parts) and
+[2. Assemble](../build/README.md#2-assemble).
+
+- [ ] Every part you bought matches a line of the [BOM](../../hardware/bom.md), or a substitute meets
+ that line's requirement.
+- [ ] The Node is wired exactly as in [`hardware/node.md`](../../hardware/node.md), without guessing a
+ connection the page does not state.
+- [ ] The Node sits in its enclosure with the soil probe sealed as in
+ [`hardware/enclosure.md`](../../hardware/enclosure.md).
+- [ ] The Hub is a dev board on USB power, placed as in [`hardware/hub.md`](../../hardware/hub.md).
+- [ ] The panel placement follows [power.md](../build/power.md#panel-placement).
+
+## D. Flash the Hub and the Node
+
+Follow [3. Install the toolchain](../build/README.md#3-install-the-toolchain),
+[4. Flash the Hub](../build/README.md#4-flash-the-hub) and
+[5. Flash the Node](../build/README.md#5-flash-the-node), through
+[`flash.md`](../build/flash.md). **Read its eFuse warning first**: the release binaries burn an
+eFuse key on the first boot, and that cannot be undone. Only boards meant to stay a Hub or a Node
+get them.
+
+- [ ] The toolchain reports the versions [`flash.md`, Toolchain](../build/flash.md#toolchain) pins
+ (at least `espflash` for the release binaries).
+- [ ] [The key-purpose check](../build/flash.md#before-you-flash-check-the-key-purposes) shows at
+ least one unused `USER` key block on each board before you flash it.
+- [ ] The Hub's [first boot](../build/flash.md#first-boot-and-the-setup-code) logs its version
+ `X.Y.Z`, `source=burned KEYn` once, a setup code, and that it advertises its BLE setup
+ service. A reboot logs `source=existing KEYn` with the same Device ID.
+- [ ] The Node's first wake logs its version and its identity, then measures and sleeps. Its first
+ wake logs `source=burned KEYn` once; a later wake logs `source=existing KEYn` with the same
+ Device ID.
+- [ ] A long press (3 s) of the Node's setup button
+ ([the buttons](../build/flash.md#the-buttons)) logs `wake plan=setup`, a setup code and a
+ 180 s setup window (`window_ms=180000`).
+- [ ] Each setup code is on a sticker on its Device, and nowhere else.
+
+## E. Deploy the Server at home
+
+Follow the [deploy guide](../deploy/README.md), sections
+[1. Domain and DNS API token](../deploy/README.md#1-domain-and-dns-api-token) to
+[6. First sign-in](../deploy/README.md#6-first-sign-in), in order: single-node RKE2 with Fleet,
+Let's Encrypt certificates through DNS-01, split DNS. The Compose example does not count.
+
+- [ ] RKE2 is installed and `kubectl` shows one node, Ready.
+- [ ] Fleet is installed, and every required Secret of section 3 exists before the GitRepo is
+ applied (the optional `coldframe-push` may follow in Part F).
+- [ ] The `coldframe-realm` ConfigMap is written with `prepare-realm.sh` for `https://`
+ ([section 4](../deploy/README.md#4-keycloak-realm-and-phase-two)), and Keycloak imports the
+ `coldframe` realm on its first start.
+- [ ] The GitRepo points at your release tag, and every bundle becomes Ready.
+- [ ] From a laptop on the home network the three hosts (``, `api.`,
+ `auth.`) resolve to the home server's LAN address and serve a Let's Encrypt
+ certificate with no warning.
+- [ ] You register in the web app, the verification email arrives through your SMTP account, and
+ you create a Site.
+- [ ] Keycloak's temporary bootstrap admin is replaced by a permanent admin in the master realm and
+ deleted ([section 6](../deploy/README.md#6-first-sign-in)), and the `coldframe-keycloak-admin`
+ Secret is kept.
+- [ ] Every item of the
+ [verification checklist on the home server](../operations/install.md#verification-checklist-on-the-home-server)
+ is ticked, including a backup in your S3 bucket.
+
+## F. Build your own apps
+
+Follow [`docs/build/apps.md`](../build/apps.md) from the same release tag:
+[Android](../build/apps.md#android) and/or [iOS](../build/apps.md#ios), with your own ids, then
+[Server push](../build/apps.md#server-push).
+
+- [ ] Each app builds with your Server URL and Keycloak issuer, and installs on your phone. No key,
+ team ID, keystore or `google-services.json` was added to the repository (`git status` is
+ clean apart from the git-ignored files the guide names).
+- [ ] Each app signs in on the home Wi-Fi and shows the Site of Part E, with no Unreachable notice.
+- [ ] The Server's log has `PushChannelConfigured` for every provider you set up.
+- [ ] Each app asks for notification permission, and you allow it.
+
+## G. Add the Hub and the Node, calibrate
+
+Follow [6. Add them from the app](../build/README.md#6-add-them-from-the-app).
+
+- [ ] **Add a Hub**: the app finds the unprovisioned Hub over BLE, takes its setup code from the
+ sticker, and sends it your 2.4 GHz WPA2 (or mixed WPA2/WPA3) network and your Server's
+ address. The app reports the Hub added to the Site, and its serial output no longer prints a
+ setup code on boot.
+- [ ] Create a Lot for the Node if the Site has none.
+- [ ] **Add a Node**: long-press its setup button (3 s), pick the Lot, and enter its setup code
+ within the 180 s setup window. The Node is listed on that Lot.
+- [ ] A short press of the Node's button sends a Reading now, and the Lot shows it.
+- [ ] Put the probe in the soil it stays in and leave it **2–3 days to settle**
+ ([why](../build/power.md#soil-moisture)). Readings keep arriving the whole time.
+- [ ] Calibrate the soil Sensor in the app (a dry point and a wet point). Afterwards the Lot shows
+ soil moisture as a % (a multiple of 5) and the Sensor no longer asks for Calibration.
+- [ ] The Lot's low Threshold is the default **30 %** unless you changed it; note the value you use.
+
+## H. A Threshold Alert inside the Notification Window
+
+The Alert flow is described in the
+[push checklist, Part D](../bench/push-checklist.md#d-an-alert-on-both-phones). Do this inside your
+Notification Window (My notifications; the default is **07:00 to 22:00** in your time zone), with
+the app in the background.
+
+- [ ] Let the Lot's soil read below its low Threshold for **three consecutive Readings**: take the
+ probe out of the soil (a short press sends each Reading now).
+- [ ] Within a minute of the third Reading each phone shows **one** notification titled
+ "‹Lot› needs water", with the body "~‹value› % in the soil, your low is ‹low› %."
+- [ ] Tapping the notification opens Lot detail of that Lot.
+- [ ] Put the probe back and let the Lot recover (three Readings within the Thresholds): the Lot
+ shows within its Thresholds again (the Alert closes), and no new notification arrives.
+
+## When you get stuck
+
+A stuck point is any place where the docs alone did not get you to the next check: a missing step,
+a wrong command or value, a link that leads nowhere useful, an unexplained error, or a guess you had
+to make.
+
+1. Open a new issue on GitHub and choose the **Reproduction stuck point** form. Name the part
+ (A–H), the guide page and section, what you did, what you expected and what happened. Paste no
+ secret, setup code, token or private domain credentials.
+2. Write the issue number in your notes, next to the part.
+3. Carry on only if you can without the author's help; otherwise stop the run there and record the
+ result as failed at that part.
+
+Each stuck point is fixed by a pull request to the docs. The story stays open until every issue
+listed in the result record is fixed. After a doc fix merges, rerun the affected parts following
+the fixed docs on `main`, with the same release artifacts and the boards already flashed, and note
+the rerun in the same record. A fix in the firmware, the Server, the charts or the apps ships in a
+new release tag; the run then continues from the first affected part on that tag.
+
+## Record the result
+
+Copy [`results/TEMPLATE.md`](results/TEMPLATE.md) to `docs/reproduce/results/YYYY-MM-DD-.md`,
+fill it in, and open a pull request with it ([`results/README.md`](results/README.md) has the
+rules). Fill it in truthfully, including the parts that failed.
+
+## Result
+
+Pass: parts A to H all ticked, with the attestation of Part A, on one release tag, and every
+stuck-point issue raised during the run fixed in the docs. A run where A to H passed but listed
+issues are still open is **pending doc fixes**; it becomes a pass once every listed issue's fix is
+merged. Anything else is a failed run of Story 10.5, and SM-5 does not hold until a later run
+passes.
diff --git a/docs/reproduce/results/README.md b/docs/reproduce/results/README.md
new file mode 100644
index 00000000..99fc3561
--- /dev/null
+++ b/docs/reproduce/results/README.md
@@ -0,0 +1,25 @@
+# Reproduction results
+
+This folder holds the result records of runs of the
+[reproduction checklist](../README.md), one file per run. They are the evidence for SM-5:
+someone other than the author (or the author on a clean machine with fresh hardware and no prior
+state) reproduced Coldframe from the public docs alone.
+
+## Adding a record
+
+1. Copy [`TEMPLATE.md`](TEMPLATE.md) to `YYYY-MM-DD-.md` in this folder: the date the run
+ ended and your GitHub handle, or `author-clean` for an author run. A second run on the same day
+ adds `-2`.
+2. Fill it in: the attestation, every part A to H, every stuck-point issue and the verdict
+ (Passed, Pending doc fixes, or Failed at a part). Record failures as they happened.
+3. Open a pull request with the file. After the run, only the "Fixed by PR" column and the Verdict
+ line (Pending doc fixes → Passed, once every listed fix is merged) may be edited, in the fix's
+ pull request or a follow-up one.
+
+Records never hold secrets: no setup code, key, token, password or credential of any account.
+
+## When SM-5 holds
+
+SM-5 holds, and Story 10.5 can close, only when a record here shows a passing run with the
+attestation ticked and **every** stuck-point issue it lists fixed in the docs by a merged pull
+request. A failed run stays in the folder as history; the next run gets its own record.
diff --git a/docs/reproduce/results/TEMPLATE.md b/docs/reproduce/results/TEMPLATE.md
new file mode 100644
index 00000000..60e26ef0
--- /dev/null
+++ b/docs/reproduce/results/TEMPLATE.md
@@ -0,0 +1,59 @@
+# Reproduction result: YYYY-MM-DD, ‹tester›
+
+
+
+Checklist: [`docs/reproduce/README.md`](../README.md).
+
+| | |
+| --- | --- |
+| Tester | ‹GitHub handle›, or `author-clean` |
+| Date(s) of the run | |
+| Release tag | `vX.Y.Z` |
+| Node hardware | ‹board, sensors, any BOM substitutes› |
+| Hub hardware | ‹board› |
+| Home server | ‹CPU arch, RAM, OS and version› |
+| Machine the commands ran from | ‹OS and version› |
+| Phones | ‹model and OS version of each› |
+
+## Attestation
+
+- [ ] I am not the author of Coldframe, **or** I am the author and ran on a clean machine with fresh
+ hardware and no prior state (no cached toolchain, cluster, realm or enrolled boards), as in
+ Part A of the checklist.
+- [ ] For an author run: no Firebase project, APNs key, Cloudflare API token or S3 bucket made for
+ an earlier Coldframe install was reused. (Non-author: tick only where true, otherwise n/a.)
+- [ ] I used only the public repository and its docs, and got no help from the author beyond them.
+- [ ] This record contains no secret.
+
+## Per part
+
+| Part | Pass / fail | Notes (what you saw instead, how long it took) |
+| --- | --- | --- |
+| A. Start clean | | |
+| B. The release | | |
+| C. Build the Node and the Hub | | |
+| D. Flash the Hub and the Node | | |
+| E. Deploy the Server at home | | |
+| F. Build your own apps | | |
+| G. Add the Hub and the Node, calibrate | | |
+| H. A Threshold Alert inside the Notification Window | | |
+
+## Stuck points
+
+One row per issue filed with the "Reproduction stuck point" form during this run. Write "none" in
+the first row if there were none.
+
+| Issue | Part | Summary | Fixed by PR |
+| --- | --- | --- | --- |
+| | | | |
+
+## Verdict
+
+‹Passed / Pending doc fixes: A–H passed, issues #… still open / Failed at part ‹X›: ‹reason››