Files
typewriter/docs/adr.md
Julien Calixte 5d9591e5ea docs: rename roadmap.md to macroplan.md and refresh the plan
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).
2026-07-11 19:27:27 +02:00

45 KiB
Raw Permalink Blame History

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 ProposedAccepted → (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 12 MB range — comfortable in 16 MB flash.
  • Build times are real (clean build ~510 min). Acceptable.
  • Cross-compiling toolchain (espup) is one more thing to install.
  • We will not use tokio or async runtimes in v0.1 — see ADR-006.
  • Revisit if esp-idf-rs upstream stalls or if gitoxide doesn'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) ~100300 ms partial / ~7001000 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 (~$515); 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-waveshare doesn't already cover this panel's controller, ~300 LoC of embedded-hal SPI driver. Validated in spike 2.
  • Per-keystroke latency floor ~100300 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 repo scope 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 832 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 → persistence and file layout).

  • FatFS caveat (Spike 3, verified 2026-07-11): FatFS's f_rename returns FR_EXIST on an existing destination — it does not replace like POSIX rename(2). So the atomic save must f_unlink the target before renaming the *.tmp over 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.md pins 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-G and 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.rsenv!() → 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_flash and 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 2632, octal PSRAM 3337, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 47/11/12).
  • Supersedes the "shared SPI2, different CS" assumption in the boot sequence and ADR-007's storage context; the sd_fat spike 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

  1. Append a new ## ADR-NNN: <title> section to this file.
  2. Status starts as Proposed, with today's date.
  3. Once merged + agreed, flip to Accepted.
  4. When superseded, leave the old ADR in place and add Superseded by ADR-MMM to its status line. Never delete.
  5. Cross-reference from the relevant section of the README or design docs if the decision is load-bearing for code review.