# Architecture ## Purpose and system shape This ESP32-S3 firmware exposes one MAX3243-backed UART1 RS-232 port through three bounded transport families: - native USB CDC-ACM, which is local and unauthenticated; - authenticated HTTPS with a browser WebSocket terminal; - authenticated SSH. UART0 remains a separate trusted administration and recovery console. A local OLED and three buttons provide status and a deliberately limited control surface. Persistent application configuration and security material are stored in versioned NVS blobs. The central data-path invariant is one serial writer with multiple observers: ```text USB CDC ---------\ WebSocket --------> session broker <--> serial service <--> UART1 <--> MAX3243 SSH role=user ---/ | +-- one writer lease +-- bounded output per observer SSH role=admin ------> shared administration dispatcher <------ UART0 (does not join the broker) ``` ## Typed Network settings (8D.12/8D.13) `web_network_settings` provides admin-only secret-free GET snapshot and GET/POST operation routes; `web_ui.c` adds Network without changing broker/terminal ownership. Wi-Fi working/runtime projection is zero-wait under one mutex; mDNS is a separate consistent projection. SSIDs use reversible byte JSON, with explicit UTF-8-text/hex UI conversion; passwords are never returned/prefilled, only `password_configured`. Omitted passwords preserve current secrets; explicit replacement and disabled-STA clear are distinct, AP clear is always denied. Only an ID enters the existing administration dispatcher. Its session/deadline revalidation precedes canonical generation-checked mutations; `wifi_manager` remains radio and reannouncement owner. Wi-Fi generation compare/merge/validation and queue-before-publication occur under its mutex; Save holds selected bytes stable, Load is stored-only without default-secret generation. mDNS independently checks generation for Set/Save/Load/Defaults and queues reannouncement; changed RAM with queue failure is explicitly reported, not rolled back. Edits require explicit Save. Next profile follows canonical enabled-priority order, not the profile editor's selected index. One static login-bound pending/result slot and one firmware-lifetime one-second timer bound queued secret retention to 30 seconds plus scheduling latency. Dequeued locals wipe on return; admitted work is not hard-cancelled by logout/deadline. `accepted` means apply/owner admission, not online/DNS completion. Response delivery before disruption is not guaranteed; recovery is STA/AP inspection plus independent UART0 administration/native USB UART1 access, never automatic mutation replay. UI navigation preserves serial traffic/lease; actual network loss can disconnect network clients. Bounds: 768-byte/four-receive request, 2,048-byte snapshot, 128-byte result; 27 handlers/six sockets, no task/stack-size/dispatcher-item/queue-depth/schema growth. Optional staged Network registration failures preserve unrelated routes; timer failure denies mutation without gating snapshot reads. Timer heap and target HTTPD/dispatcher stack/memory floors remain unmeasured. Full fields, registration rollback, states, UI and validation limits: `docs/phase8d12_13_implementation.md`. Browser-shell restrictions remain unchanged. ## Startup and initialization `app_main()` in `src/main.c` is the composition root. The implemented order matters: 1. Report PSRAM and initialize the sole project-owned application DRBG before Wi-Fi or other radio use. 2. Initialize boot-critical RGB LED state, RS-232 ownership/static-safe hardware, diagnostics, and the shared administration dispatcher resources. RGB LED initialization is currently guarded by `ESP_ERROR_CHECK` and is therefore boot-fatal. 3. Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds. 4. Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure. 5. Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here. 6. Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed. 7. Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task. 8. Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions. 9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects `main.c` after accepted legacy-credential cleanup. 10. Start the local status/control task if button initialization succeeded. 11. Construct ESP-IDF's UART REPL to initialize `esp_console`, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher. Several core initializers use `ESP_ERROR_CHECK`; optional display and network/security paths generally log failure while retaining UART0 administrative recovery and network-independent UART1 access through USB. SSH starts before command registration, so role-`user` sessions can be admitted in that interval while role-`admin` sessions are rejected until the administration frontend is ready. ## Serial service and physical ownership `serial_service` owns the UART1 driver while running. It exposes bounded RX/TX streams and a task that: - continuously drains UART RX, even if UART event notification is incomplete; - moves pending TX to the UART FIFO without blocking indefinitely on CTS; - discards and accounts queued traffic during shutdown/reconfiguration. `rs232_port_owner` separately protects the physical UART/MAX3243 resource: - `NONE`: available; - `PHASE0`: hardware diagnostics own it; - `SERVICE`: serial service owns it; - `FAULT`: cleanup could not establish a safe state; reboot is required. The owner is cooperative rather than an interceptor for UART/GPIO APIs. Active diagnostic commands claim `PHASE0`, and the running service claims `SERVICE`; boot-time static-safe GPIO initialization and service-owned restoration of that static mode are explicit exceptions. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation. Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure. Stop/reconfiguration discards and accounts serial-service RX/TX streams and task-local pending TX, but does not disconnect broker clients or clear their writer lease, events, or already-fanned output. An open USB session retries service start after a stop; existing WebSocket and role-`user` SSH sessions do not independently restart it. Persistence is explicit through save/reset commands. ## Session broker and data flow The permanent `session_broker` task is the intended sole consumer/producer of serial-service data. ### RS-232 to clients ```text UART RX -> serial-service RX stream -> broker task -> independent bounded output stream for every connected client -> USB / WebSocket / SSH transport output ``` The broker drains serial input even with no clients. A full client output stream drops only that client's copy and updates drop counters; it does not block UART reception or other clients. ### Clients to RS-232 ```text transport input -> broker write check -> serial-service TX stream -> UART TX ``` Only the generation-safe client ID holding the current writer lease may enqueue input. All connected clients, including the writer, observe UART output. Normal requests acquire the lease only when free; disconnect releases it. Administrative APIs can force reassignment or compare-and-release an expected writer. Broker events are advisory bounded notifications. Transports reconcile against authoritative snapshots because an event queue can overflow. `DTR_ON_CONNECT` follows whether any broker client is connected, not writer ownership. The broker currently enters nonblocking serial read/write APIs while holding its mutex and takes the serial-service state mutex during first-connect/last-disconnect DTR changes. Keep this ordering acyclic: serial-service code must not call broker APIs while holding its state mutex. ## Transport architecture ### USB CDC `usb_cdc_transport` has a permanent transport task and TinyUSB callbacks. Attached plus host DTR asserted is treated as open. Opening starts the serial service if necessary, creates the `usb-cdc` broker client, and opportunistically requests writer ownership; otherwise USB observes. TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. The line-coding callback records the latest host setting for diagnostics only. It never reconfigures UART1: physical framing and speed remain controlled by the explicit serial configuration, regardless of USB writer ownership. ### HTTPS, WebSocket, and web serial `web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade. HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. No legacy credential is imported, synchronized or consulted for authentication. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry. `web_cookie_auth` owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only `__Host-` Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial/admin upgrades require matching cookie/Origin/ticket, with admin role additionally required by the admin endpoints. `web_session_store` holds four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. These are live cookie sessions in 8D.3, with no sliding renewal. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Authentication/store-init failure now gates HTTPS startup rather than falling back to Basic. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`. Serial tickets/slots bind to distinct originating web-session IDs; 8D.3 rejects zero instead of treating it as Basic. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. 8D.3 activates these checks for all browser routes, with five added authentication handlers (14 total), unchanged six HTTPS sockets and no new task. A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; expired/stale identities are reclaimed and live capacity is rejected with 503/Retry-After, not eviction. Ticket issuance and upgrade require `Origin` matching validated Host after host-case/default-port normalization; missing Origin fails even for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts. `web_httpd_adapter` is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See `docs/phase8d3_implementation.md` for source verification, tests and pending on-wire checks. `web_diagnostics` observes public synchronous HTTPS create/close callbacks without replacing socket/transport cleanup, and wraps only the four serial/admin ticket/upgrade handlers. Six always-maintained post-TLS metadata records supply a console-safe occupancy snapshot without querying HTTPD off-owner; an opt-in 32-entry numeric ring adds timing/heap/HTTPD stack samples. Firmware-lifetime connection sequences and capture epochs survive clear/restart and fence fd reuse/toggle races. No tasks/probes/event subscriptions; no authentication/request data retained. UART0/admin SSH commands never wait for HTTPD; browser policy remains unchanged. This is successful-TLS occupancy, not preaccept or failed/in-progress handshake instrumentation; exact limits/overhead in `docs/phase8d11_implementation.md`. Ordinary HTTPS idle retention is independently enforced by `web_httpd_idle`: one persistent one-second ESP timer, at most one generation-qualified HTTPD work reservation and six owner-only rows. The private adapter observes IDF's all-route successful `req_new`/`req_delete` completion marker, checks actual WS/async flags and pending/readable input, and shuts down only the current expired ordinary fd after 15 seconds of observed idle. TLS-create resets reused-fd observations before diagnostic publication. No TLS cleanup override, LRU eviction, in-progress request interruption or diagnostics dependency. Stop fences submissions before destroying HTTPD; only successful stop retires queued state. Slow owner work and accepted-but-lost nonblocking UDP work preclude a hard wall-clock guarantee; loss stays bounded until successful restart. Timer preparation failure gates HTTPS start. SDK audit, tests and exact limits: `docs/https_idle_cleanup.md`. Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes. `web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets. ### Browser admin backend 8D.8 adds an in-document admin-only Settings/Serial view and optional `GET /api/settings/serial`. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; `serial_service_get_snapshot()` takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See `docs/phase8d8_implementation.md` for bounds, tests and target-pending evidence. The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling. 8D.6 `web_ui.c` now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See `docs/phase8d6_implementation.md`; the following paragraph describes the original backend boundary, before its UI entry was added. 8D.5 additionally supplies `web_admin_transport` and `web_admin_tickets`: one optional admin socket, two 30-second digest-only tickets bound to current originating session/principal, the same two shared console slots, no serial broker client. Ticket POST requires cookie/Origin/CSRF/admin; ordinary GET upgrade requires cookie/Origin/admin/ticket and console admission before 101. Six total HTTPS sockets remain, LRU purge is disabled, and two routes bring the handler budget to 16. Optional admin registration/PSRAM allocation failures do not take down M1. A 20 ms ESP timer queues at most one HTTPD poll, with no new task; only HTTPD accesses the 1,552-byte PSRAM-only RX/TX payload or socket IO. Notifiers close the generation-qualified console and flag the socket. HTTPD shuts down the verified current fd directly and owns subsequent read cleanup, avoiding IDF's queued reusable `sock_db *` close race. Detach fences submissions; failed stop retains ownership, and queued state is retired only after successful HTTPD stop. Console dispatcher/prompt and owner input/output/idle checks enforce session and principal currentness. WEB supports deferred self-close only; parsed canonical policy denies unsupported lifecycle/network/account mutations before handler side effects. No normal UI entry, typed settings or lifecycle parity is included. See `docs/phase8d5_implementation.md` for validation limits and exact restrictions. ### SSH `ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers. Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`. Routing follows the authenticated role: - `user`: start the serial service if necessary, then create a broker-backed binary-transparent serial stream and opportunistically request writer ownership; - `admin`: bounded administration console, with no broker client or writer lease. A shell request is required, but project code does not explicitly require a PTY. Exec and subsystem requests are rejected, and there is no project SFTP, SCP, agent-forwarding, or TCP-forwarding route. ## Authentication, authorization, and revocation `user_database` is a fixed-capacity, mutex-protected store: at most eight accounts and three authorized keys per account. Accounts have `user` or `admin` role, random account ID, and authentication generation. Passwords are salted PBKDF2-HMAC-SHA256 verifiers; plaintext passwords are not retained in the database. Network code holds copied, secret-free principals rather than pointers into database records. Principal currentness requires matching username, account ID, role, and authentication generation. Password, role, or key changes increment the generation; deletion/recreation also changes the account ID. Revocation has two layers: 1. after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; notification failure does not roll back the mutation; 2. transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails. The final administrator cannot be deleted or demoted. UART0 establishes the first administrator through normal `user add admin` and owns explicit unavailable-database recovery to empty. Recovery refuses a healthy database. No bootstrap API or command remains. Authenticated admin SSH can run the operational registry but is denied recovery; other secret-bearing commands are remotely available unless their handlers deny them. NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials and TLS/SSH private keys remain recoverable under physical flash extraction. ## Typed Accounts settings (8D.10) **8D.11 extension:** Authorized-key operations share the Accounts slot/dispatcher and canonical database key transactions with mutation-lock account ID/generation checks. A zero-wait per-account projection returns only key slot/type/SHA256 fingerprint. Independently optional admin/Origin/CSRF JSON POST `/api/settings/accounts/keys` reads this projection; existing operation POST admits bounded public-key text or key-delete/key-clear. Three stable slots may be sparse. Successful mutations target-revoke, including self; uncertain acknowledgement never triggers automatic replay. 24 handlers, unchanged socket/task/stack-size/queue budgets. Runtime stack margins remain pending. See `docs/phase8d11_implementation.md`; older no-8D.11 statements below are historical. Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0 first-admin provisioning/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`. **Historical slice 1 architecture (superseded scope/counts, retained evidence):** `web_account_settings` supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. `user_database_get_accounts()` copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. `user_database_delete_current()` and `user_database_set_role_current()` compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; first-admin provisioning/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See `docs/phase8d10_implementation.md` for limits and pending target checks. ## Typed Serial settings (8D.9) `web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence. ## Console architecture UART0 and admin SSH share canonical command implementations: ```text UART0 linenoise frontend --\ > fixed request queue -> one dispatcher -> esp_console_run() admin SSH line editor ----/ | +-> registered *_console handlers ``` `admin_ssh_console` creates the dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. An admin SSH connection during that boot window is rejected rather than racing an incomplete registry. The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes. The 8D.4/8D.5 boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation. For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH. Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list. The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the same control task. `web stop` is deferred only for browser origin; UART0/admin SSH keep their synchronous HTTPS-stop path. WEB performs authoritative cookie/principal/token validation after drain and delay, then calls lifecycle APIs outside console locks, never socket IO. Console snapshots expose pending deferral; HTTPD discards buffered/new input observed during it and latches each frame's discard decision across payload reception/cancellation. HTTPS stop intentionally closes both browser routes. The second 8D.7 slice additionally permits exact parsed browser `web certificate rotate --force`. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner `dispatcher_actions` mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof). The third 8D.7 slice permits browser other-account interactive add/password and forced delete/role mutations, with shared parsed policy at dispatcher admission and canonical-handler defense. Self-target, generated-secret, key, bootstrap and recovery workflows remain blocked. Account/owner/session/token currentness is checked after password prompts and before database API operation admission. This is operation-admission currentness, not an atomic liveness/NVS-commit guarantee: an admitted derivation/mutation may finish and target-revoke after disconnect or expiry; subsequent stale operations reject. Reconnecting administrators must inspect uncertain account outcomes rather than assume cancellation. Existing transaction cleanup, account invariants and best-effort targeted notifications remain unchanged. ## Wi-Fi and persistence `wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used. `wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots. Persistent namespaces/blobs include: - `serial/config`; - `wifi_app/config`; - `mdns_cfg/config`; - `local_ui/config`; - `web_sec/material`; - `user_db/database`; - `ssh_sec/material`. Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in `partitions.csv`; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented. ## Local UI and hardware boundaries `board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded. When button GPIO initialization succeeds, `local_status_ui` starts a firmware-lifetime low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer. The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands depend on the UI task. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined. Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership. ## Concurrency and lifecycle constraints - Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task. - Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation. - Transport slot generations and account authentication generations solve different stale-reference problems; preserve both. - Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, the SSH owner task owns post-initialization wolfSSH runtime calls, and the console dispatcher owns `esp_console_run()`. - Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path. - Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order. - Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters. - The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall. ## Legacy credential removal storage boundary `user_database_init(load_result)` has no credential input. Missing storage is persisted empty; `user_database_recover_empty()` is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private `v1_admin_marker` retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains. `web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: `docs/legacy_credential_removal.md`.