The file is the macroplan (plus per-version scope), so rename it to match and retitle to "Macroplan"; update all inbound links and friendly labels across the docs. Refresh the plan while here: v0.2 gutter built, :gl pull recorded (v0.7), command-line editing (v0.4), and the format_on_save pref (v0.5).
45 KiB
Architecture Decision Records
A running log of the load-bearing technical decisions on this project. Each record states what was considered, what we chose, and what we accept as a consequence. Status moves from Proposed → Accepted → (eventually) Superseded when a later ADR replaces it.
Format inspired by Michael Nygard's ADR template, kept short on purpose.
Related docs:
../README.md — project overview, hardware table, macro plan.
../CONTEXT.md — project glossary: Tracked, Local,
Save, Publish, plus the principles ("writing tool, not sync engine")
that constrain ADR-010 specifically.
macroplan.md — per-version scope (v0.1 → v1.x).
v0.1-mvp-product.md — what the v0.1 device must do.
v0.1-mvp-technical.md — how v0.1 is built.
qfd.md — Quality Function Deployment: requirements → functions →
components, with the tradeoffs from this file ranked by user-facing weight.
ADR-001: Language and runtime — Rust on esp-idf-rs (std)
Status: Accepted — 2026-05-14 Scope: Whole project.
Context
The firmware needs: USB host, Wi-Fi + TLS, SPI peripherals, a SD filesystem, and a working git implementation that can push over HTTPS. All on an ESP32-S3 with 8 MB PSRAM. We also want the code to stay refactorable as features pile up across nine downstream releases.
Options considered
| Option | Pros | Cons |
|---|---|---|
| C on ESP-IDF (no Arduino) | Reference platform on the bare native SDK; every peripheral has a driver; smallest binary of the C-family options; no C++ runtime / exceptions / RTTI to reason about. | All memory safety on you; no RAII for resource cleanup; no generics so widget / state code gets repetitive; refactoring at scale is painful. |
| C++ on ESP-IDF | Same peripheral coverage as C; RAII, templates, and std:: containers ease widget / state code; mature in the ESP-IDF examples. |
Exception / RTTI story on embedded is messy; ABI / linker surprises; memory safety still on you; binary larger than plain C. |
Rust on esp-idf-rs (std) |
First-class Espressif-sponsored Rust support; std gives heap / threads / VFS / mbedtls; can use the broader Rust ecosystem (gitoxide, ropey, embedded-graphics). |
Larger binary than no_std; longer build times; some unsafe at FFI seams. |
Rust on esp-hal (no_std) |
Smallest binary, most "pure" embedded experience. | No std = no off-the-shelf git, no easy TLS, would re-implement a lot of plumbing. |
| Gleam + Shore on AtomVM | Beautiful language, the user's stated preference. | BEAM on ESP32 is memory-hungry; no bindings for USB host, e-ink, SD, TLS, git in that ecosystem. Two research projects stacked. |
| MicroPython / CircuitPython | Fastest to prototype. | Too slow for responsive editing at the latencies e-ink already imposes; GC pauses would surface as dropped keys. |
| TinyGo | Modern, ergonomic. | ESP32-S3 support is thinner than Rust's; smaller ecosystem of embedded crates equivalents. |
Decision
Rust on esp-idf-rs (std). It's the sweet spot: keeps the door open to
the entire Rust ecosystem we need (gitoxide especially), gets us threads
and TLS without writing them, and has Espressif as an actual upstream.
Consequences
- Binary will be in the 1–2 MB range — comfortable in 16 MB flash.
- Build times are real (clean build ~5–10 min). Acceptable.
- Cross-compiling toolchain (
espup) is one more thing to install. - We will not use
tokioor async runtimes in v0.1 — see ADR-006. - Revisit if
esp-idf-rsupstream stalls or ifgitoxidedoesn't compile cleanly against it (spike 7 is the kill-switch — see v0.1 technical: hardware bring-up order).
See also: qfd.md §7 for the binary-size / build-time costs traded against ecosystem access.
ADR-002: UI strategy — custom widgets on embedded-graphics, not Ratatui
Status: Accepted — 2026-05-14 Scope: Whole project.
Context
We need a TUI-like editor (header, edit area, status, palettes later). The output medium is e-ink: pixel framebuffer with partial-refresh windows aligned to panel-internal regions, ~10× slower than an LCD per region.
Options considered
| Option | Pros | Cons |
|---|---|---|
| Ratatui with a custom backend | Mature widget set, well-known API, lots of community examples. | Built for char-grid terminals over ANSI; per-cell diff fights e-ink's region-refresh model; backend would re-rasterise glyphs from cell-diffs; ~200 KB of binary and a leaky abstraction. |
Raw embedded-graphics only |
Smallest footprint, full control. | Every screen built from primitives; no widget reuse; status line / palette would each be ad-hoc. |
| LVGL via Rust bindings | Full GUI toolkit, themable. | Designed for actively-refreshing colour LCDs; e-ink integration is awkward; way more than we need. |
Custom thin widget layer on embedded-graphics |
Borrow Ratatui's API ideas (Layout, Block, Paragraph) without its rendering model; dirty-rect tracking aligned to e-ink regions; ~500 LoC. |
We own and maintain the layer. |
Decision
Custom thin widget layer on embedded-graphics. Steal the widget API
shape from Ratatui (because it's a good shape) but render directly to a
pixel framebuffer with our own dirty-rectangle tracking sized to the panel's
refresh regions.
Consequences
- ~500 LoC of widget/layout code we maintain. Worth it.
- We can tune refresh cadence (partial vs full) at the widget level.
- If we later want to render to a terminal for desktop testing, we add a second backend; the widget API stays.
Implementation: v0.1 technical → render module. Owns the two top-ranked functions (H1 latency, H2 region area) in qfd.md §3.
ADR-003: Display medium — e-ink (GDEY0579T93 panel)
Status: Accepted — 2026-05-14 Scope: v0.1 through v1.0. 10.3" e-ink upgrade remains on the v1.x table; a non-e-ink swap would supersede this ADR.
Context
The display has the largest downstream blast radius of any hardware choice. The medium (e-ink vs. LCD vs. memory LCD vs. OLED) — not the specific panel — is the real architectural decision: it sets the render strategy (ADR-002), the per-keystroke latency floor, the idle-power profile (and so the v0.8 battery story — ADR-008), the UX posture, and the BOM shape. The specific panel (GDEY0579T93 + DESPI-c579 breakout) is already on hand and documented here as the instantiation, not as a freshly weighed option.
This ADR records the medium choice with eyes open. E-ink has well-known costs at the typing latencies a writing appliance wants — Astrohaus shipped the Freewrite Alpha in 2023 on a reflective LCD specifically to address typing-latency complaints from their original e-ink line. We are accepting costs the category leader retreated from.
Options considered
| Option | Refresh / persistence | Pros | Cons |
|---|---|---|---|
| E-ink (reflective, image-persistent) | ~100–300 ms partial / ~700–1000 ms full / persists at 0 W | Paper aesthetic; persists at zero idle power; no backlight (no glare, no eye strain); category convention (Freewrite, reMarkable, Kindle Scribe, Boox); medium enforces writing posture | Slow per-keystroke feedback; ghosting accumulates → periodic full-refresh flash; scroll is the worst-case refresh op (full edit-area redraw); requires waveform / refresh-cadence tuning; Astrohaus retreated from e-ink in Freewrite Alpha (2023) on typing-latency grounds |
| FSTN graphical LCD (monochrome) | <16 ms, no refresh quirks | Cheap (~$5–15); trivial render code; snappy scroll | Backlit (always-on power), unreadable indoors without it; no image persistence; calculator / feature-phone aesthetic; writing-grade resolution (≥600 px wide at ≥6") effectively unavailable as a hobbyist part |
| Sharp Memory LCD (monochrome, reflective) | ~20 ms, persists at near-0 W | Persists and refreshes fast (best technical combo); sun-readable; ghost-free | Caps around 4.4" before getting rare and expensive; reflective-only feels like a screen, not paper; niche sourcing; lower DPI than e-ink at writing size |
| TFT / OLED (color, self-lit or backlit) | <16 ms, persists only at full power | Bright, fast, plentiful | Backlit / self-lit → screen-feel, not paper; OLED burns in static text (status line, header); defeats the writing-tool posture; not seriously a contender |
Decision
E-ink as the display medium, instantiated with the GDEY0579T93 (5.79", 792×272, SSD1683-class) driven over SPI through the DESPI-c579 breakout — which is already on hand. The DESPI-c579 is a passive level-shifter / FPC adapter, not an active controller — same SPI driver model as any other e-paper.
The medium is chosen for: paper aesthetic, zero-idle-power persistence
(which makes ADR-008's battery deferral structurally cheap to revisit at
v0.8), the category convention users have a mental model for, and alignment
with the "writing tool, not screen" posture pinned in
CONTEXT.md. The slow refresh and scroll cost are accepted
as the price of those properties.
Consequences
- Visible writing column on this panel is ~13 lines. UI must embrace the constraint — no multi-pane, no large headers. See v0.1 product → screen layout.
- Framebuffer is ~27 KB; keeps PSRAM free for git pack data — a top-3 budget item in qfd.md §6.
- Driver: SSD1683-class. If
epd-wavesharedoesn't already cover this panel's controller, ~300 LoC ofembedded-halSPI driver. Validated in spike 2. - Per-keystroke latency floor ~100–300 ms (partial refresh). The render module must buffer the active line and flush on a short timer, not redraw on every keystroke. Owns the top-ranked H1 latency constraint in qfd.md §3; strategy lives in ADR-002.
- Scroll is the worst-case refresh operation — every scroll is a full edit-area redraw, either with a visible flash (full refresh) or accumulating ghost trails (partial refresh). The concrete scroll strategy (continuous-scroll-with-periodic-flush vs. page-down vs. hybrid) is a v0.1 product decision, not part of this ADR — see v0.1 product → screen layout. Tuning is a render-module concern in v0.1 technical.
- Industry calibration: Astrohaus shipped Freewrite Alpha (2023) on a reflective LCD specifically to fix typing-latency complaints from their e-ink line. The latency cost we're accepting is one the commercial leader couldn't fully tune away after a decade. Set expectations accordingly — do not promise "instant feedback."
- Idle power on e-ink is structurally ~0, which makes the v0.8 battery sizing exercise straightforward — see ADR-008 and macroplan → v0.8.
- 10.3" e-ink upgrade path is preserved by keeping the renderer resolution-agnostic. A non-e-ink swap (e.g. Sharp Memory LCD) would invalidate ADR-002's dirty-rect strategy and force a fresh medium ADR.
ADR-004: Git implementation — gitoxide (gix)
Status: Accepted — 2026-05-14 Scope: Whole project, all releases.
Context
The device must do add, commit, push over the network. Optionally
later: fetch, pull, branch. The library must compile against
esp-idf-rs (std, mbedtls available).
Options considered
| Option | Pros | Cons |
|---|---|---|
libgit2-sys (C bindings) |
Battle-tested, comprehensive, well-known. | C dependency complicates cross-compile to ESP32-S3; needs mbedtls glue; binary size; less Rust-idiomatic. |
gitoxide (gix) |
Pure Rust, modular crates (we only depend on what we use), idiomatic API, active development. | Smart-HTTP push path is newer than libgit2's; PSRAM allocation patterns less battle-tested on embedded. |
| Hand-rolled HTTP + pack | Smallest possible footprint. | Reinventing git internals; pack delta + ref discovery + index updates are not weekend work. |
Shell out to git binary |
Trivial. | There is no git binary on the ESP32-S3. |
Decision
gitoxide. Modular means we pull only gix-pack, gix-protocol,
gix-transport, etc. — not 200 KB of features we don't use. Pure Rust
removes a class of cross-compile pain. The smart-HTTP path is validated in
spike 7 before we commit to integration; if it fails on the device, we
fall back to libgit2-sys for v0.1 (documented as the kill-switch in the
risk table).
Consequences
- We become an early-ish embedded user of
gitoxide; bugs reported back upstream. - Auth via PAT in an Authorization header — no SSH (see ADR-005).
- Performance on PSRAM during pack operations is a watched metric — top-3 priority in qfd.md §6.
Implementation: v0.1 technical → git module
and risks table.
Outcome — Spike 7, 2026-07-05: kill-switch fired
gix was ruled out for v0.1 and the fallback taken. gitoxide supports push only
over file:// and ssh:// — not HTTP(S) — so with HTTPS + PAT fixed by
ADR-005, the smart-HTTP push path this ADR bet on does not exist yet. We
switched to libgit2 (git2 crate) and proved add → commit → push
(incl. pull --no-edit + retry) on desktop
(spikes/spike7-git-push). The remaining risk is
now the on-device libgit2 → xtensa/mbedtls cross-compile — the very pain
this ADR chose gix to avoid. Full context:
postmortem. Revisit gix if
its HTTP(S) push lands upstream before v0.1 ships.
ADR-005: Auth — HTTPS + GitHub Personal Access Token
Status: Accepted — 2026-05-14 Scope: v0.1 through at least v0.9.
Context
The device must authenticate to GitHub (or other git remotes) to push. Auth has to be: enterable on a tiny screen-less first-run flow, storable on-device, and reasonably secure for a personal appliance.
Options considered
| Option | Pros | Cons |
|---|---|---|
| HTTPS + PAT | Trivial to implement; PAT is a string the user pastes during captive-portal setup; works with gitoxide smart-HTTP. |
Long-lived secret on device; PAT rotation is manual. |
| HTTPS + OAuth device flow | No secret typed by hand; user approves on github.com. | Adds an OAuth client app to maintain; token still has to live on device; more first-run UX work. |
| SSH | No PAT; per-device deploy keys. | SSH on embedded is heavy (host-key handling, key generation); gitoxide's SSH transport story is less mature than HTTPS; users would have to register the public key on GitHub anyway. |
| GitHub App with installation token | Strongest model, rotating credentials. | Massive overhead for a single-user device. |
Decision
HTTPS + PAT. In v0.1 the PAT (and all other config) is compiled into the firmware binary via build-time env vars — the dev's-only-user model makes the binary-as-secret-store acceptable. From v0.9 onward, the PAT moves to encrypted LittleFS with a key derived from the chip's eFuse, so a stolen SD card alone is not enough.
Consequences
- The user (= dev, in v0.1) must generate a PAT with
reposcope and supply it as a build-time env var. Provisioning is build-time only — see v0.1 product → provisioning. - PAT is never logged. Validated in code review.
- Rotation in v0.1 = wipe NVS and re-run setup. Proper rotation UI is v0.9 — see macroplan → v0.9.
- Revisit if we ever want to support multiple remotes per device with different credentials.
ADR-006: Concurrency — std::thread + channels, no async runtime
Status: Accepted — 2026-05-14 Scope: v0.1 through at least v1.0.
Context
The firmware has several concurrent concerns: USB input, Wi-Fi maintenance, screen rendering, occasional git operations. None of them are I/O-bound at the scale where async wins. The number of "tasks" is bounded and small (≤ 8).
Options considered
| Option | Pros | Cons |
|---|---|---|
std::thread + channels |
Boring, debuggable, stack traces work, no executor to tune; ESP-IDF FreeRTOS underneath is well-understood. | Each thread costs 8–32 KB stack depending on workload; not zero-cost like async. |
embassy async |
Trendy, ergonomic, low memory per task. | esp-idf-rs and embassy don't mix cleanly; adopting embassy means dropping std and rewriting against esp-hal (ADR-001 reversed). |
tokio on esp-idf-rs |
Familiar async. | Heavy executor, oversized for ≤ 8 tasks, mbedtls/gitoxide integration would need a lot of glue. |
| Single-threaded event loop | Smallest memory. | Long-running ops (git push, full refresh) block input. |
Decision
std::thread + crossbeam-channel. Five tasks (usb, wifi, ui,
render, git). Editor state behind a single Mutex. No await, no
runtime to tune, no colour-of-functions problem.
Consequences
- ~76 KB of stack space across the five task stacks (8 + 8 + 16 + 12 + 32 KB — see v0.1 technical → threads / tasks for the breakdown). Comfortable in the ESP32-S3's 512 KB internal SRAM.
- Refresh / git / Wi-Fi each get their own thread, so a slow push doesn't freeze typing.
- If task count balloons past ~10 (unlikely), revisit.
ADR-007: Storage split — FAT-on-SD for working copy, LittleFS-on-flash for config
Status: Accepted — 2026-05-14 Scope: Whole project.
Context
Two storage needs: a large, removable, growable area for the git working copy and notes; and a small, durable, never-removed area for device config (Wi-Fi credentials, PAT, remote URL).
Options considered
| Option | Pros | Cons |
|---|---|---|
| SD (FAT) for working copy + LittleFS (internal) for config | Plays to each medium's strengths; user can pop the SD to read on desktop; config can't be lost by yanking the card. | Two filesystems to manage. |
| All on SD | One filesystem. | Config disappears if SD is removed; PAT on FAT is harder to protect than on encrypted NVS. |
| All in internal flash | Single medium; encrypted. | 16 MB flash limits notes growth; no desktop-side access; SD slot becomes pointless. |
| SPIFFS for everything | Single FS, well-known on ESP32. | SPIFFS isn't great with large files; no removability. |
Decision
FAT on SD for /sd/repo/ and /sd/local/. LittleFS on internal flash
for /nvs/config.toml. PAT inside config is encrypted with an eFuse-
derived key.
Consequences
-
User can plug the SD into a laptop and read/edit files there. Discouraged but possible.
-
Config survives SD reformatting.
-
Power-loss safety on FAT is weaker than LittleFS — we mitigate with atomic-rename writes (see v0.1 technical →
persistenceand file layout). -
FatFS caveat (Spike 3, verified 2026-07-11): FatFS's
f_renamereturnsFR_EXISTon an existing destination — it does not replace like POSIXrename(2). So the atomic save mustf_unlinkthe target before renaming the*.tmpover it, and pair that with boot recovery of a lingering*.tmp. Recovery is not simply "promote the tmp": a crash during the tmp write leaves a partial tmp, so the choice depends on whether the target survived —- tmp + target both present → the crash could have been mid-write, so the tmp is untrustworthy. Keep the committed target, discard the tmp (this is the documented "you get the previous version" behaviour).
- tmp only, target absent → the target was already unlinked, so the tmp is the newest complete, fsync'd copy and the only one left. Promote it.
Implemented in
firmware::persistence::Storage::{save,recover}. See the Spike 3 postmortem. -
SD-card compatibility: use a genuine card, ideally ≤32 GB (SDHC/FAT32). Large or counterfeit SDXC cards may reject
CMD59(SPI-mode CRC) and fail to mount; we keep CRC required rather than run the user's writing over an unchecked bus. The device reports a swap-the-card message instead of a hex code.
ADR-008: MVP power — wall-powered, battery deferred to v0.8
Status: Accepted — 2026-05-14 Scope: v0.1 only. Revisited in ADR-future at v0.8.
Context
"DIY typewriter" suggests portability, which suggests battery. But battery adds: charging circuit, BMS, thermal margin, soft power switch, lid-close detection, sleep states. Each of those has its own bring-up cost.
Options considered
- USB-C wall power, no battery. Simple, safe, lets us measure real draw before sizing a cell.
- 18650 + IP5306 from day one. Pretty close to a known-good pattern; IP5306 handles charge + 5 V boost.
- LiPo + dedicated charger IC + buck/boost. More control, more parts.
Decision
Wall power only for v0.1. Battery is its own phase (v0.8) once the power profile of "boot + type + idle + push" is measured on real hardware. Sizing a battery before measuring is guessing.
Consequences
- v0.1 device is tethered. Not the final aesthetic, but the right MVP — scope is in v0.1 product → out of scope.
- We can decide cell capacity from real numbers in v0.8, not specs sheets.
- Lid-close detection / deep sleep slips to v0.8 with the battery — see macroplan → v0.8.
ADR-009: Keyboard transport — USB host (TinyUSB)
Status: Accepted — 2026-05-14 Scope: v0.1 through at least v1.0.
Context
The Nuphy keyboard speaks both wired USB-C (HID) and Bluetooth LE (HID). The ESP32-S3 has USB OTG (host capable) and BLE 5. Either transport works.
Options considered
| Option | Pros | Cons |
|---|---|---|
| USB host (TinyUSB) | Keyboard draws no battery of its own; ESP32-S3 powers it through the host port; standard boot-protocol HID is well-supported; no radio contention with Wi-Fi during push. | One more USB connector on the enclosure; cable between device and keyboard (or shared chassis). |
| BLE-HID | No cable; keyboard can be slightly remote from the device. | Keyboard has its own battery to manage; BLE shares the 2.4 GHz radio with Wi-Fi, so a Ctrl-G push contends with input; pairing UX is more first-run work. |
| UART receiver (custom keyboard firmware) | Lowest latency, simplest stack. | Requires reflashing the Nuphy or building a passthrough; not viable as a product choice. |
Decision
USB host (TinyUSB) for v0.1. BLE-HID is kept as a documented fallback if TinyUSB host turns out unstable (spike 4 is the gate).
Consequences
- Enclosure design must include a USB-A or USB-C port for the keyboard.
- The Nuphy's own battery is irrelevant when wired — saves the user a charging surface.
- Wi-Fi and keyboard input do not contend for radio time.
- If we ever want a fully wireless build, we revisit with a BLE-HID ADR.
ADR-010: Publish UX — atomic Ctrl-G, auto-timestamp commit message, no user prompt
Status: Accepted — 2026-05-14 Scope: Whole project, all releases.
Context
The device needs an action that ships writing to the git remote. Most
git-using tools expose commit and push as distinct user gestures, often
with a commit-message prompt. The device's actual user (= the author of this
firmware) already uses the gct shell alias
for their own writing: git add . && git commit -m "<timestamp>" && git push,
with a git pull --no-edit fallback when the push fails non-fast-forward.
gct is the established workflow; the typewriter mirrors it.
Options considered
| Option | Pros | Cons |
|---|---|---|
| Three separate gestures (save / commit / push) | Maximally git-native; user has fine control. | Three keys to remember, three failure modes to surface, three concepts in the user's head. Wrong shape for an appliance whose job is to remove ceremony. |
One gesture, prompt for message (Ctrl-G → modal asking for message → commit → push) |
Conventional "publish" pattern; each commit is named. | A modal prompt on e-ink is hostile (latency, full refresh); the user's actual workflow (gct) explicitly avoids authoring messages; messages would be noise ("updated notes" × 1000). |
One gesture, auto-timestamp message (Ctrl-G mirrors gct) |
Matches the user's real workflow; one key, one outcome; no prompts, no modes, no decisions in the writing path. | Commit history is timestamp-noise (useless for code archaeology); a future reader will wonder where the commit messages went; locks in a UX assumption that's hard to undo without breaking muscle memory. |
Decision
One gesture, auto-timestamp message, atomic from the user's view. Ctrl-G
runs the full gct sequence (stage all → short-circuit if nothing staged →
commit with ISO-8601 timestamp → push → on push failure, pull --no-edit then
retry). Failure surfaces as a single retry-able outcome in the status line.
Consequences
- The user's vocabulary collapses to Save and Publish;
CONTEXT.mdpins this — commit is not a user-facing term. - Commit history is a stream of timestamps. The device is a writing tool, not a code repository — the history is here for recoverability, not narrative.
- The pull-merge-retry path means the device may author merge commits on the user's behalf, with git's default merge message. Acceptable: the user doesn't read commit history from the device anyway.
- The previously-planned "commit message prompt" item in v0.7 has been removed from the roadmap.
- Reversing this later (introducing message prompts) would change the
semantics of
Ctrl-Gand break the user's muscle memory. Hard-to-reverse by design.
ADR-011: Credential provisioning — how the PAT reaches the device and is protected at rest
Status: Proposed / Open — 2026-07-07 (deferred beyond v0.1) Scope: Gates the first distribution to any non-dev user.
Context
ADR-005 decided the auth model (HTTPS + PAT) and sketched an endgame ("from v0.9 the PAT moves to encrypted storage with an eFuse-derived key"), but left the mechanics open: how does a token get onto a device, and how is it protected once there?
Spike 7 made this concrete. The PAT is currently baked into the firmware image
at build time (build.rs → env!() → a const in the .bin) — ADR-005's
deliberate v0.1 shortcut. On a real device that means:
- the token sits in plaintext in flash — anyone with physical access can
esptool read_flashand extract a working GitHub PAT (push/pull as the user); - it is the same token on every unit built from that image — no per-device revocation;
- rotation requires a reflash.
This is fine for the dev's own bench unit (it's their token, their device) and is why the Spike 7 postmortem lists it as the last standing shortcut. It is not fine for a unit in anyone else's hands. Resolving it needs a provisioning path, which the current design ("build-time only, no provisioning module") deliberately omits.
Options considered
| Option | Pros | Cons |
|---|---|---|
| Build-time bake (current, ADR-005 v0.1) | Zero UX; nothing to build. | Plaintext in flash; same token per unit; reflash to rotate. Dev-bench only. |
| On-device paste → NVS (plaintext) | No reflash; per-device token. | Still plaintext at rest; needs a first-run entry UI (captive portal / keyboard). |
| On-device paste → NVS encrypted (eFuse key) | ADR-005's stated target; a flash dump alone doesn't yield the token; per-device + rotatable. | Needs NVS encryption + eFuse key derivation + first-run UI — the whole provisioning module. |
| USB provisioning tool (host writes NVS over serial) | No on-device text entry; scriptable at assembly. | Needs a host-side tool; token still needs at-rest protection (combine with encryption). |
| Per-device fine-grained PAT (orthogonal) | Least blast radius; per-device revoke; repo-scoped. | User mints one PAT per device; pairs with any storage option above. |
| GitHub App installation token | Strongest, rotating creds. | Heavy for a single-user appliance — rejected in ADR-005 for overhead. |
Decision
Open — deferred. v0.1 stays on the build-time bake (dev-bench only). Before shipping to any non-dev user, pick a provisioning mechanism — the likely shape is on-device paste → eFuse-encrypted NVS (ADR-005's target) + a per-device fine-grained PAT to bound the blast radius. That requires the provisioning module currently deferred, NVS encryption, and eFuse key derivation, and ties into ADR-007 (where config lives).
Consequences
- Blocks nothing in v0.1 — the bench unit runs on the baked PAT.
- Gates first non-dev distribution — this ADR must flip to Accepted (with a chosen mechanism) before a unit leaves the dev's hands.
- Whatever is chosen, the token must never be logged or written into the working copy — already enforced in the git module (PAT lives only in libgit2's credential callback).
- Rotation UX and the first-run flow are the real work here, not the crypto.
ADR-012: SD on its own SPI3 host (not shared with the EPD on SPI2)
Status: Accepted — 2026-07-11 Scope: v0.1 hardware; whole project.
Context
The EPD (SSD1683) and the SD card both want SPI. The v0.1 plan (the boot
sequence in v0.1 technical and
the storage context of
ADR-007)
assumed one shared SPI2 bus with a per-device chip-select. Spike 3 (verified
2026-07-11, postmortem) proved the
SD works on the SPI2 wiring, but surfaced the integration blocker: the EPD driver
uses esp-idf-hal's SpiBusDriver, whose constructor takes an exclusive
spi_device_acquire_bus(BLOCK) and holds it for the driver's whole lifetime
(it must keep CS asserted across a cmd→data sequence while toggling DC). While
held, no other device on that host can transact — so an SD on SPI2 is locked out
for as long as the panel driver is alive. Compounding it, persistence/git runs on
a dedicated thread (Spike 7) while the EPD refreshes on the main task, so SD
and EPD access are genuinely concurrent.
Options considered
| Option | Pros | Cons |
|---|---|---|
| Shared SPI2, arbitrate | One bus; ~2 fewer GPIOs. | Rewrite the proven EPD SPI layer to per-transaction device drivers; add a cross-thread mutex around all SPI2 access; residual "corruption on save during render" risk on the highest-value path. |
| SD on its own SPI3 | EPD code untouched; no arbitration/mutex; each bus at its own clock; matches the risk-table fallback exactly. | ~2 extra GPIOs + traces. |
Decision
SD gets its own SPI3 host. The EPD keeps SPI2 and its exclusive-lock model, unchanged. This is the mitigation the technical-doc risk table already names ("move SD to a separate SPI peripheral — ESP32-S3 has two"). SPI3 is free (SPI0/1 are flash + PSRAM; nothing else uses SPI3).
Pins — SD on SPI3: SCK 14, MOSI 15, MISO 13, CS 10 (MISO/CS unchanged from the spike; only SCK/MOSI move off the EPD-shared 12/11). EPD stays on SPI2: SCK 12, MOSI 11, CS 7, DC 6, RST 5, BUSY 4.
Consequences
- No shared-bus arbitration or mutex — the git thread's SD I/O never contends with an EPD refresh. Removes the "corruption on save during render" risk for the device's first value (not losing the user's writing).
- Each bus runs at its own clock (EPD ~4 MHz on jumpers; SD 10 MHz+).
- Costs ~2 extra GPIOs + traces; the pin budget has room (avoids flash 26–32, octal PSRAM 33–37, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 4–7/11/12).
- Supersedes the "shared SPI2, different CS" assumption in the boot sequence and
ADR-007's storage context; the
sd_fatspike is rewired to SPI3 and its EPD-CS-deselect step (only meaningful on a shared bus) is removed. - The
SpiBusDriver-holds-the-lock mechanism was read from the constructor, not re-verified on silicon; it doesn't affect this decision (SPI3 sidesteps it), but is the first thing to confirm if a shared bus is ever revisited.
How to add a new ADR
- Append a new
## ADR-NNN: <title>section to this file. - Status starts as Proposed, with today's date.
- Once merged + agreed, flip to Accepted.
- When superseded, leave the old ADR in place and add Superseded by ADR-MMM to its status line. Never delete.
- Cross-reference from the relevant section of the README or design docs if the decision is load-bearing for code review.