Bench Spike 0 — five-leg hardware de-risk (BLOCKING)

🛑 This is the one blocking gate in Epic 0 (AR-4). Broad build-out (Epic 1+) does not begin until all five legs below are retired — measured on real hardware and recorded here, or explicitly risk-accepted in writing by the operator (architecture.md:526—​536; epics.md:180, :186). This is also the only Epic-0 story that cannot be done zero-hardware: the legs measure outcomes on a real MMDVM board + a Pi 4 and a Pi 5 + a live BrandMeister talkgroup (epics.md:331).

This document is the single source of truth for the spike. The dev-story agent ships the verified zero-hardware kit (this scaffold, the runbooks, the analysis tool, the probe, the helpers, the decided policies). The operator runs the five legs on the bench, fills the PENDING — operator slots, sets the two pins, completes the reshape ledger, and commits. No measured value in this file may be invented — see Anti-fabrication below.

Status

Spike status

KIT READY — awaiting operator bench run. All [DEV] instrumentation/policy is complete and verified on synthetic fixtures; all five [OPERATOR] legs are PENDING.

Baseline

a074bfa (Story 0.7 committed)

Blocks

Epic 1 (on-air DMR relay) and everything downstream of it (epics.md:188—​190)

🚨 Anti-fabrication rule (the single most important rule here)

No hardware measurement in this report may be invented, simulated, estimated, or asserted by anyone who did not physically take it. No "jitter was ~2 ms", no "the AP survived 5 cycles", no "the BM call returned 200" unless it actually happened on the rig. Every measured field stays the literal token PENDING — operator until the operator fills it with a real reading. The only non-hardware numbers anywhere near this spike are the synthetic-fixture results that prove the jitter-stats.sh math (Leg (a) Method), and they are labelled synthetic. A fabricated bench result would let a blocking architectural gate be "passed" on fiction — the exact failure this spike exists to prevent.

Rig & preconditions (operator assembles)

  • Pinned MMDVM board (ZUMspot-class STM32 GPIO hat — the exact model is Pin #1, below).

  • A Pi 4 and a Pi 5, each on fresh Raspberry Pi OS Trixie (legs (a) and (c) run on both; leg (d) is Pi-5-specific — architecture.md:533).

  • Stock MMDVMHost (unmodified upstream) — the spike proves integrations with stock tools before any product code is written (scope fence; epics.md:188—​190).

  • A live BrandMeister talkgroup + a valid DMR-ID + a BrandMeister SelfCare API key (the key is supplied to the auth probe via env at run time — never committed).

  • Network reachability for leg (e) (the lightest leg — needs network + key, not the RF rig).

The cadence number is itself a spike output (Micro-decision #1)

The AC restates "~30 ms voice-superframe cadence" (epics.md:333); architecture.md:272/296 names "~60 ms DMR frames". Both are defensible at different altitudes (DMR = 30 ms TDMA slots; voice frames recur ~60 ms per slot). This spike pins the exact empirical budget — it does not assume one. The analysis tool takes the cadence as a parameter (--cadence-us); leg (a)'s result records the measured, pinned value, and the report flags the doc discrepancy so Epic 1 inherits one authoritative number.


Leg (a) — Real-time frame timing / jitter floor

Risk / owns: can the relay hold the DMR voice cadence under realistic load? Gates the runtime-isolation topology decision — ADR-004 / AR-11 (architecture.md:272, :296).

Method / Runbook

  • Capture (operator-side): with a live QSO running through stock MMDVMHost on the rig, passively record frame arrival timestamps into a CSV (one integer per line, µs) — e.g. from stock MMDVMHost frame logging or a UART/UDP tap. Do this under realistic system load: web/SSE traffic concurrent with the relay (architecture.md:530). Capture on both Pi 4 and Pi 5.

  • Analysis (this kit, verified): run scripts/spike/jitter-stats.sh --cadence-us <budget> --tolerance-us <budget> <capture.csv>. It computes inter-frame deltas and mean / p50 / p95 / p99 / max jitter against the cadence target and emits PASS/FAIL (exit 1 on a tolerance breach).

  • Tool already verified zero-hardware on two synthetic fixtures (scripts/spike/fixtures/): clean.csv (spaced exactly at 60 000 µs → max jitter 0 µs → PASS) and jittery.csv (an 8 000 µs excursion → FAIL at --tolerance-us 5000). The dev run confirmed both verdicts (this is the synthetic proof the math is correct — not a hardware reading).

Pass criteria (operator-tunable): max jitter ≤ the tolerance budget the operator sets for the pinned cadence, sustained under load, on both Pis. Record the pinned --cadence-us here once measured.

Result

  • Pinned cadence budget (µs): PENDING — operator (resolves the 30 ms vs 60 ms doc gap)

  • Tolerance used (µs): PENDING — operator

  • Pi 4 — mean / p50 / p95 / p99 / max jitter + PASS/FAIL: PENDING — operator

  • Pi 5 — mean / p50 / p95 / p99 / max jitter + PASS/FAIL: PENDING — operator

What a failed leg reshapes: if the cadence holds → keep ADR-004 in-process isolation as-is. If it can’t hold → escalate the runtime topology: SCHED_FIFO / elevated thread priority, or a true engine↔web process split. That is an architecture decision (ADR-004 / AR-11), revised and recorded here, not a perf footnote.


Leg (b) — Golden BM↔MMDVMHost conformance corpus + provenance

Risk / owns: the M1 mitigation — the captured corpus is the test oracle for the Epic 1 engine port (risk-seam-map.md, row M1; architecture.md:282(b), :531).

Method / Runbook

  • The corpus format, location, ownership, and PII/RF-licensing posture are DECIDED and written before any capture lands — see docs/interop/bm-corpus/README.md. Summary: JSONL with a t_us arrival-timing field (same unit jitter-stats.sh consumes); no raw RF captures in git (.gitignore-guarded raw/ + .dmrcap); commit *only an anonymised / synthetic / permission-cleared oracle subset after manual PII review.

  • Capture (operator-side): record real DMRD + control frames with inter-frame arrival timing from a live BM↔MMDVMHost session per that policy; anonymise; review; land the reviewed .jsonl + PROVENANCE.md at docs/interop/bm-corpus/.

Pass criteria: an anonymised oracle subset covering the conformance cases (voice superframe start/continue/end + control) exists at the agreed location, with provenance, and no raw capture or third-party PII committed.

Result

  • Oracle subset committed (path + frame count): PENDING — operator

  • Provenance recorded: PENDING — operator

What a failed leg reshapes: if a faithful corpus cannot be captured/anonymised within policy, the engine-port test strategy (M1’s oracle) is revised — e.g. fall back to synthetic-only conformance fixtures, or lean harder on the subprocess-wrapped stock MMDVMHost fallback (risk-seam-map.md, M1) — recorded here and in the risk map.


Leg (c) — Regdom pre-boot radio unlock (highest blast radius)

Risk / owns: onboarding network bring-up (M3 / AR-16). Highest blast radius — if the regulatory domain isn’t set before the radio is used, the Pi radio stays locked and the first AP never comes up (architecture.md:75, :142, :532).

Method / Runbook

  1. Seed the regdom pre-boot via cloud-init / Netplan on the fresh-Trixie image. Netplan exposes the country as regulatory-domain under the Wi-Fi device; cloud-init can also set it. Bake the operator’s ISO-3166 country code (e.g. GB, US, SE) into the image so it is applied before the first AP is raised.

  2. Runtime fallback (if the pre-boot seed didn’t take):

    sudo raspi-config nonint do_wifi_country <CC>   # <CC> MUST be UPPERCASE — fails silently otherwise (architecture.md:75)
    sudo nmcli radio wifi on
  3. Evidence to capture (record verbatim in the Result slot):

    iw reg get        # expect the country set to <CC>, not "country 00: (the world regdom)"
    rfkill list       # expect Wireless LAN NOT 'Soft blocked: yes'

    and confirm that the AP raises only after the regdom is set, on the fresh-Trixie target, on both Pis.

Pass criteria: iw reg get shows the intended <CC> and rfkill shows the WLAN unblocked, before the first AP is raised, from a pre-boot seed on a fresh image — on both Pi 4 and Pi 5.

Result

  • Pi 4 — iw reg get / rfkill evidence + AP-after-regdom confirmed: PENDING — operator

  • Pi 5 — iw reg get / rfkill evidence + AP-after-regdom confirmed: PENDING — operator

What a failed leg reshapes: if the regdom does not unlock the radio pre-boot, then onboarding is redesigned, not patched (M3 / AR-16): the network-bring-up sequence, rescue path, or image-seed mechanism changes — recorded here and in the risk map.


Leg (d) — nmcli AP-mode reliability on Pi 5

Risk / owns: onboarding (M3 / AR-16). nmcli is the singular network path by design (architecture.md:74, :533) — if shared-mode AP bring-up is flaky across reboots, a fallback layer must be reintroduced.

Method / Runbook

  • After each of 5+ power cycles on the Pi 5, run scripts/spike/nmcli-ap-powercycle.sh once. It checks the active NM Wi-Fi profile is a real shared-mode AP (802-11-wireless.mode = ap and ipv4.method = shared) and appends a cycle=N … result=PASS|FAIL line to a cycle log (./ap-powercycle.log, which is .gitignore-covered).

  • Helper already smoke-tested zero-hardware against a fake nmcli: the PASS branch (mode=ap, ipv4=shared → exit 0), the FAIL branch (wrong mode → exit 1), and the cycle-number increment in the log were all confirmed by the dev run (synthetic, not a hardware reading).

Pass criteria: the shared-mode AP comes up correctly on all 5+ cycles (every logged line result=PASS).

Result

  • Power-cycle log (paste the 5+ cycle=… result=… lines): PENDING — operator

  • Cycles run / cycles PASS: PENDING — operator

What a failed leg reshapes: any flaky cycle ⇒ the nmcli-only network path is insufficient ⇒ reintroduce a hostapd/dnsmasq fallback layer in onboarding (M3 / AR-16) — an architecture change recorded here and in the risk map.


Leg (e) — Live BrandMeister auth header

Risk / owns: the BM client (AR-13) — confirm the key-as-HTTP-Basic-username header shape is accepted by the live BM v2 REST endpoint (architecture.md:265—​266, :534).

Method / Runbook

  • Supply the SelfCare API key via env and run once:

    BM_API_KEY=<your-key> ./scripts/spike/bm-auth-probe.sh

    The probe makes one authenticated request to https://api.brandmeister.network/v1.0/ with HTTP Basic, key-as-username (empty password), and prints the HTTP status + a verdict. It refuses to run without a key, feeds the key to curl via a stdin config so it never appears in argv/ps, and never echoes, logs, stores, or parses the key (format-agnostic: v1 128-char and v2 JWT). Single call — no polling (architecture.md:65 "no documented rate limits").

  • Probe already verified zero-hardware: refuses with no key set (exit 2), and a hermetic fake-curl run confirmed a dummy key never appears anywhere in the probe’s output. The one live call is operator-gated on a real credential.

Pass criteria: the single live call returns HTTP 2xx (verdict ACCEPTED) → the key-as-username header shape works.

Result

  • HTTP status + verdict from the one live call: PENDING — operator

What a failed leg reshapes: a 401/403 or other non-2xx ⇒ the BM-client auth design (AR-13) is revised (different header shape / endpoint / scheme) before the Epic 1 BM connection is built — recorded here and in the risk map.


Pins (operator sets — these two leave the spike as durable facts)

  • Pin #1 — MMDVM board model. Resolves PRD Open Q2 (architecture.md:60, :536). ZUMspot-class STM32 GPIO hat — record the exact model used on the rig: PENDING — operator

  • Pin #2 — numeric RAM/CPU budget for the relay under load. Feeds NFR-5 (architecture.md:74). Measure on the rig under realistic load (e.g. systemd-cgtop, or /proc/<pid>/status VmRSS + ps/top CPU%), on both Pis:

    • Pi 4 — peak RSS / steady CPU% under load: PENDING — operator

    • Pi 5 — peak RSS / steady CPU% under load: PENDING — operator

Reshape ledger

Per leg: leave as the as-designed expectation; the operator flips a row to "revised: …​" only where reality diverges. Any "revised" row reshapes its decision before the dependent epic begins.

Leg As-designed expectation Operator verdict

(a) timing/jitter

Cadence holds under load on both Pis → ADR-004 / AR-11 in-process isolation kept as-is

PENDING — operator ("confirmed as designed" | "revised: …​")

(b) corpus

Anonymised oracle subset captured per policy → M1 engine-port oracle established

PENDING — operator

(c) regdom

Pre-boot regdom unlocks the radio before first AP on fresh Trixie → onboarding (M3/AR-16) as-designed

PENDING — operator

(d) nmcli AP

Shared-mode AP survives 5+ Pi-5 power cycles → nmcli-singular path (M3/AR-16) confirmed

PENDING — operator

(e) BM auth

Key-as-Basic-username accepted (HTTP 2xx) → BM-client auth (AR-13) as-designed

PENDING — operator

Operator hand-off checklist

  1. Assemble the rig (pinned board on Pi 4 + Pi 5, fresh Trixie, stock MMDVMHost, live TG + DMR-ID + API key).

  2. Leg (a): capture timestamp CSVs under load on both Pis → jitter-stats.sh → fill Result + pin the cadence budget.

  3. Leg (b): capture → anonymise → review → land the oracle subset + PROVENANCE.md per the corpus policy.

  4. Leg (c): prove pre-boot regdom on both Pis → paste iw reg get / rfkill evidence.

  5. Leg (d): nmcli-ap-powercycle.sh ×5+ on the Pi 5 → paste the cycle log.

  6. Leg (e): bm-auth-probe.sh once with the real key → record HTTP status + verdict.

  7. Set Pin #1 (board model) and Pin #2 (RAM/CPU budget); complete the reshape ledger.

  8. Any "revised" row reshapes its decision before the dependent epic begins, then commit this report.

References

  • epics.md:323—​338 (Story 0.8 + the five-leg ACs); epics.md:87 (AR-4 🛑 blocking); epics.md:180/186/188—​190 (Epic 0 gate → Epic 1).

  • architecture.md:526—​536 (Pre-Implementation Conditions — Bench Spike 0); :516—​520 (why these five were promoted to blocking).

  • architecture.md:272/296 + AR-11 (ADR-004 runtime isolation / process-split trigger — leg a); :282(b)/:531 + AR-12 (corpus oracle — leg b); :70—​78/:142 + AR-16 (regdom-first + uppercase do_wifi_country + NM shared-mode AP — legs c/d); :265—​266/:534 + AR-13 (key-as-Basic-username — leg e).

  • architecture.md:60 (board model = PRD Open Q2 — Pin #1); :74 (NFR-5 footprint — Pin #2); :65 (no documented BM rate limits).

  • docs/adr/risk-seam-map.adoc — M1 → leg (b), M3 → legs (c)/(d), BM-auth → leg (e).