Install event firmware in-app behind a signed release contract - #2223
Install event firmware in-app behind a signed release contract#2223RCGV1 wants to merge 7 commits into
Conversation
📝 WalkthroughWalkthroughEvent firmware handling now uses resilient manifest merging, HTTPS and image validation, policy-based notifications, event-specific presentation, and signed OTA contracts with verified artifact downloads. The UI exposes event branding and installation flows while preserving device targeting and cache behavior. ChangesEvent firmware metadata and presentation
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant FirmwareScreen
participant AvailabilityResolver
participant ContractVerifier
participant ArtifactDownloader
participant Installer
FirmwareScreen->>AvailabilityResolver: Resolve event OTA availability
AvailabilityResolver->>ContractVerifier: Verify signed OTA contract
ContractVerifier-->>AvailabilityResolver: Return verified contract
AvailabilityResolver-->>FirmwareScreen: Return compatible artifact
FirmwareScreen->>ArtifactDownloader: Prepare artifact
ArtifactDownloader-->>FirmwareScreen: Return verified local file
FirmwareScreen->>Installer: Start installation for expected device
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Meshtastic/Views/Settings/Firmware/Firmware.swift (1)
411-421: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winPass
expectedNodeNumto local-file firmware install sheets.
Firmware.swiftpassesnode.numfor row installs, but the Catalyst file picker still opensNRFDFUSheetandESP32OTAIntroSheetwithout the target node. WhenexpectedNodeNumis nil, these sheets fall back to treating any connected device as valid, while row installs require the correct node.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Views/Settings/Firmware/Firmware.swift` around lines 411 - 421, Update the local-file firmware sheet switch in the showInstallationSheet flow to pass the selected target node’s number as expectedNodeNum to NRFDFUSheet and ESP32OTAIntroSheet, matching the row-install paths; leave UF2MassStorageView unchanged unless it already supports the same parameter.
🧹 Nitpick comments (7)
Meshtastic/Views/Connect/EventFirmwareInfoView.swift (1)
278-288: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid byte-at-a-time accumulation for the icon download.
for try await byte in bytesruns one async-sequence iteration per byte — up to ~2M iterations for a max-size icon. Since the cap is already enforced,URLSession.data(for:)plus a length check is both simpler and far cheaper; if you want streaming, iteratebytes.chunksstyle buffers instead.♻️ Simpler bounded fetch
- let (bytes, response) = try await URLSession.shared.bytes(for: request) + let (data, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), response.expectedContentLength <= Int64(EventFirmwareImageValidator.maximumEncodedBytes), - response.mimeType == "image/png" || response.mimeType == "image/jpeg" else { + response.mimeType == "image/png" || response.mimeType == "image/jpeg", + data.count <= EventFirmwareImageValidator.maximumEncodedBytes else { return nil } - var data = Data() - if response.expectedContentLength > 0 { - data.reserveCapacity(Int(response.expectedContentLength)) - } - for try await byte in bytes { - guard data.count < EventFirmwareImageValidator.maximumEncodedBytes else { - return nil - } - data.append(byte) - } return EventFirmwareImageValidator.image(from: data)Note this buffers the full body before the cap check; keep the streaming form if you must abort mid-transfer, but buffer in chunks rather than per byte.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Views/Connect/EventFirmwareInfoView.swift` around lines 278 - 288, Update the icon download logic around the byte iteration to avoid per-byte async accumulation. Prefer a bounded URLSession data fetch followed by a maximum-size check before calling EventFirmwareImageValidator.image(from:), or, if early streaming cancellation is required, consume buffered chunks while preserving the existing cap enforcement and validation behavior.Meshtastic/Model/EventFirmwareOTASelector.swift (1)
1-2: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMissing file-level header/MARK comment on new Model files. Both new files start directly with their
importstatements with no// MARK: FileNameor copyright header, per repo convention.
Meshtastic/Model/EventFirmwareOTASelector.swift#L1-L2: add a// MARK: EventFirmwareOTASelector.swift(or copyright) header beforeimport Foundation.Meshtastic/Model/EventFirmwareOTAService.swift#L1-L2: add a// MARK: EventFirmwareOTAService.swift(or copyright) header beforeimport CryptoKit.As per coding guidelines, "Add
// MARK: FileNameor a file-level copyright comment at the top of files."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Model/EventFirmwareOTASelector.swift` around lines 1 - 2, Add a file-level MARK or copyright header before the imports in both EventFirmwareOTASelector.swift (lines 1-2) and EventFirmwareOTAService.swift (lines 1-2), using each file’s name for the MARK comment.Source: Coding guidelines
Meshtastic/Model/EventFirmwareArtifactDownloader.swift (1)
1-3: 📐 Maintainability & Code Quality | 🔵 TrivialMissing file-level header comment.
This new file has no
// MARK: FileNameor copyright header at the top, unlikeEventFirmwareNotificationPolicy.swiftin the same PR. As per coding guidelines, "Add// MARK: FileNameor a file-level copyright comment at the top of files."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Model/EventFirmwareArtifactDownloader.swift` around lines 1 - 3, Add the project-standard file-level header at the top of EventFirmwareArtifactDownloader.swift, using either a // MARK: FileName comment or the same copyright-header format established by EventFirmwareNotificationPolicy.swift. Keep it before the import statements.Source: Coding guidelines
Meshtastic/Model/EventFirmwareOTAContract.swift (1)
1-3: 📐 Maintainability & Code Quality | 🔵 TrivialMissing file-level header comment.
Same as
EventFirmwareArtifactDownloader.swift— no// MARK: FileNameor copyright header at the top of this new file. As per coding guidelines, "Add// MARK: FileNameor a file-level copyright comment at the top of files."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Model/EventFirmwareOTAContract.swift` around lines 1 - 3, Add a file-level header at the top of EventFirmwareOTAContract.swift, using either the project’s standard // MARK: FileName format or the established copyright header pattern from EventFirmwareArtifactDownloader.swift, before the imports.Source: Coding guidelines
Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift (2)
302-309: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLog the underlying error before collapsing it to a generic message.
Download/checksum/signature failures are exactly the cases that need diagnostics, and the caught
erroris discarded. Add a typedLoggercall (per the project'sLoggerextensions) while keeping the user-facing text generic.🪵 Proposed fix
} catch { + Logger.services.error("Event firmware artifact preparation failed: \(error.localizedDescription, privacy: .public)") await MainActor.run { preparationState = .failed( "The firmware package could not be downloaded and verified." )As per coding guidelines, "Use
OSLog/Loggerfor all logging... Prefer the typed loggers defined inMeshtastic/Extensions/Logger.swift."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift` around lines 302 - 309, In the catch block of the firmware preparation flow, log the caught error using the project’s typed Logger extension before updating preparationState. Keep the existing generic user-facing failure message and preparationTask cleanup unchanged.Source: Coding guidelines
1-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a file header and
// MARK: -separators.This file bundles the policy enum, the main view, a DI helper, a format extension, and two simulator-only types with no section markers or top-of-file comment.
As per coding guidelines, "Use
// MARK: -comments to separate logical sections within a file" and "Add// MARK: FileNameor a file-level copyright comment at the top of files."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift` around lines 1 - 27, Add a file-level header comment at the top of EventFirmwareInstallerView.swift, then organize the policy enum, main view, dependency-injection helper, format extension, and simulator-only types into logical sections using // MARK: - separators. Keep the existing declarations and behavior unchanged.Source: Coding guidelines
Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift (1)
38-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winOptional-
expectedNodeNumfallback is reimplemented in both OTA sheets. Both views wrapEventFirmwareInstallerPolicy.isExpectedDeviceActivewith an identicalguard letthat degrades to "any device is connected" whenexpectedNodeNumisnil. Move that fallback into the policy so the weaker semantics are defined once and can be tested.
Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift#L38-L46: replace the computed property body with a single call to a policy overload acceptingInt64?.Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift#L20-L28: apply the same replacement.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Views/Settings/Firmware/ESP32` OTA/ESP32OTAIntroSheet.swift around lines 38 - 46, Move the optional expectedNodeNum fallback into EventFirmwareInstallerPolicy by adding or using an overload accepting Int64?, preserving “any device connected” behavior when nil. Replace the computed property bodies in Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift lines 38-46 and Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift lines 20-28 with a single policy call, leaving the non-optional policy logic centralized and reusable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Meshtastic/API/MeshtasticAPI.swift`:
- Around line 1073-1126: Update the manifest-to-entity assignments in the
payload mapping block so every remaining plain string field only overwrites
cached values when its payload value is non-empty. Apply this to displayName,
welcomeMessage, tag, eventStart, eventEnd, timeZone, location, domain, theme
name/tagline, theme fonts, and firmware slug/version/id/title/releaseNotes,
while preserving the existing URL and color validation behavior.
In `@Meshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/Contents.json`:
- Around line 3-7: Remove the "scale" entry from the hamvention.png image record
in the EventFirmwareHAMVENTION asset metadata, leaving the filename and idiom
unchanged so this single-scale imageset matches EventFirmwareDEFCON and
EventFirmwareFAB.
In `@Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift`:
- Around line 204-219: Update the String-returning helpers actionTitle,
actionIcon, actionFooter, and unavailableMessage so each user-facing literal is
localized with String(localized:), preserving the existing conditional and
switch behavior. Ensure the corresponding Text and Label call sites receive the
localized strings rather than raw untranslated literals.
---
Outside diff comments:
In `@Meshtastic/Views/Settings/Firmware/Firmware.swift`:
- Around line 411-421: Update the local-file firmware sheet switch in the
showInstallationSheet flow to pass the selected target node’s number as
expectedNodeNum to NRFDFUSheet and ESP32OTAIntroSheet, matching the row-install
paths; leave UF2MassStorageView unchanged unless it already supports the same
parameter.
---
Nitpick comments:
In `@Meshtastic/Model/EventFirmwareArtifactDownloader.swift`:
- Around line 1-3: Add the project-standard file-level header at the top of
EventFirmwareArtifactDownloader.swift, using either a // MARK: FileName comment
or the same copyright-header format established by
EventFirmwareNotificationPolicy.swift. Keep it before the import statements.
In `@Meshtastic/Model/EventFirmwareOTAContract.swift`:
- Around line 1-3: Add a file-level header at the top of
EventFirmwareOTAContract.swift, using either the project’s standard // MARK:
FileName format or the established copyright header pattern from
EventFirmwareArtifactDownloader.swift, before the imports.
In `@Meshtastic/Model/EventFirmwareOTASelector.swift`:
- Around line 1-2: Add a file-level MARK or copyright header before the imports
in both EventFirmwareOTASelector.swift (lines 1-2) and
EventFirmwareOTAService.swift (lines 1-2), using each file’s name for the MARK
comment.
In `@Meshtastic/Views/Connect/EventFirmwareInfoView.swift`:
- Around line 278-288: Update the icon download logic around the byte iteration
to avoid per-byte async accumulation. Prefer a bounded URLSession data fetch
followed by a maximum-size check before calling
EventFirmwareImageValidator.image(from:), or, if early streaming cancellation is
required, consume buffered chunks while preserving the existing cap enforcement
and validation behavior.
In `@Meshtastic/Views/Settings/Firmware/ESP32` OTA/ESP32OTAIntroSheet.swift:
- Around line 38-46: Move the optional expectedNodeNum fallback into
EventFirmwareInstallerPolicy by adding or using an overload accepting Int64?,
preserving “any device connected” behavior when nil. Replace the computed
property bodies in Meshtastic/Views/Settings/Firmware/ESP32
OTA/ESP32OTAIntroSheet.swift lines 38-46 and
Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift lines 20-28 with a
single policy call, leaving the non-optional policy logic centralized and
reusable.
In `@Meshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swift`:
- Around line 302-309: In the catch block of the firmware preparation flow, log
the caught error using the project’s typed Logger extension before updating
preparationState. Keep the existing generic user-facing failure message and
preparationTask cleanup unchanged.
- Around line 1-27: Add a file-level header comment at the top of
EventFirmwareInstallerView.swift, then organize the policy enum, main view,
dependency-injection helper, format extension, and simulator-only types into
logical sections using // MARK: - separators. Keep the existing declarations and
behavior unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7a32c34d-3e5c-4dd0-93a2-d1a1c1de68a7
⛔ Files ignored due to path filters (3)
Meshtastic/Assets.xcassets/EventFirmwareDEFCON.imageset/defcon34.pngis excluded by!**/*.pngMeshtastic/Assets.xcassets/EventFirmwareFAB.imageset/fab26.pngis excluded by!**/*.pngMeshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/hamvention.pngis excluded by!**/*.png
📒 Files selected for processing (81)
Meshtastic/API/MeshtasticAPI.swiftMeshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swiftMeshtastic/Assets.xcassets/EventFirmwareDEFCON.imageset/Contents.jsonMeshtastic/Assets.xcassets/EventFirmwareFAB.imageset/Contents.jsonMeshtastic/Assets.xcassets/EventFirmwareHAMVENTION.imageset/Contents.jsonMeshtastic/Enums/FirmwareEditionEnum.swiftMeshtastic/Extensions/UserDefaults.swiftMeshtastic/MeshtasticApp.swiftMeshtastic/Model/EventFirmwareArtifactDownloader.swiftMeshtastic/Model/EventFirmwareEntity.swiftMeshtastic/Model/EventFirmwareNotificationPolicy.swiftMeshtastic/Model/EventFirmwareOTAContract.swiftMeshtastic/Model/EventFirmwareOTASelector.swiftMeshtastic/Model/EventFirmwareOTAService.swiftMeshtastic/Model/EventFirmwarePresentation.swiftMeshtastic/Persistence/MarketingCapture.swiftMeshtastic/Persistence/Persistence.swiftMeshtastic/Persistence/UpdateSwiftData.swiftMeshtastic/Resources/docs/developer/architecture.htmlMeshtastic/Resources/docs/developer/swiftdata.htmlMeshtastic/Resources/docs/index.jsonMeshtastic/Resources/docs/markdown/developer/architecture.mdMeshtastic/Resources/docs/markdown/developer/swiftdata.mdMeshtastic/Resources/docs/markdown/user/firmware.mdMeshtastic/Resources/docs/user/firmware.htmlMeshtastic/Resources/event_firmware.jsonMeshtastic/Views/Connect/Connect.swiftMeshtastic/Views/Connect/EventFirmwareInfoView.swiftMeshtastic/Views/ContentView.swiftMeshtastic/Views/Helpers/Compact Widgets/ParticulateMatterCompactWidget.swiftMeshtastic/Views/Helpers/Compact Widgets/RadiationCompactWidget.swiftMeshtastic/Views/Helpers/MeshtasticLogo.swiftMeshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swiftMeshtastic/Views/Nodes/Helpers/Map/WaypointForm.swiftMeshtastic/Views/Nodes/Helpers/NodeListFilter.swiftMeshtastic/Views/Onboarding/DeviceOnboarding.swiftMeshtastic/Views/Provisioning/WifiProvisioningView.swiftMeshtastic/Views/Settings/AppData.swiftMeshtastic/Views/Settings/AppSettings.swiftMeshtastic/Views/Settings/Channels/ChannelForm.swiftMeshtastic/Views/Settings/Config/BluetoothConfig.swiftMeshtastic/Views/Settings/Config/DeviceConfig.swiftMeshtastic/Views/Settings/Config/DisplayConfig.swiftMeshtastic/Views/Settings/Config/LoRaConfig.swiftMeshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swiftMeshtastic/Views/Settings/Config/Module/AudioConfig.swiftMeshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swiftMeshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swiftMeshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swiftMeshtastic/Views/Settings/Config/Module/MQTTConfig.swiftMeshtastic/Views/Settings/Config/Module/MeshBeaconConfig.swiftMeshtastic/Views/Settings/Config/Module/NeighborInfoConfig.swiftMeshtastic/Views/Settings/Config/Module/PaxCounterConfig.swiftMeshtastic/Views/Settings/Config/Module/RangeTestConfig.swiftMeshtastic/Views/Settings/Config/Module/SerialConfig.swiftMeshtastic/Views/Settings/Config/Module/StoreForwardConfig.swiftMeshtastic/Views/Settings/Config/Module/TelemetryConfig.swiftMeshtastic/Views/Settings/Config/Module/TrafficManagementConfig.swiftMeshtastic/Views/Settings/Config/NetworkConfig.swiftMeshtastic/Views/Settings/Config/PositionConfig.swiftMeshtastic/Views/Settings/Config/PowerConfig.swiftMeshtastic/Views/Settings/Config/SecurityConfig.swiftMeshtastic/Views/Settings/Discovery/DiscoveryScanView.swiftMeshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swiftMeshtastic/Views/Settings/Firmware/EventFirmwareInstallerView.swiftMeshtastic/Views/Settings/Firmware/Firmware.swiftMeshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swiftMeshtastic/Views/Settings/Routes.swiftMeshtastic/Views/Settings/ShareChannels.swiftMeshtastic/Views/Settings/TAKServerConfig.swiftMeshtastic/Views/Settings/UserConfig.swiftMeshtasticTests/EventFirmwareArtifactDownloaderTests.swiftMeshtasticTests/EventFirmwareInstallerViewTests.swiftMeshtasticTests/EventFirmwareMetadataTests.swiftMeshtasticTests/EventFirmwareNotificationTests.swiftMeshtasticTests/EventFirmwareOTAContractTests.swiftMeshtasticTests/EventFirmwareOTASelectorTests.swiftMeshtasticTests/EventFirmwareOTAServiceTests.swiftdocs/developer/architecture.mddocs/developer/swiftdata.mddocs/user/firmware.md
💤 Files with no reviewable changes (26)
- Meshtastic/Views/Helpers/Compact Widgets/ParticulateMatterCompactWidget.swift
- Meshtastic/Views/Settings/Config/Module/NeighborInfoConfig.swift
- Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift
- Meshtastic/Views/Settings/AppData.swift
- Meshtastic/Views/Settings/Config/Module/AudioConfig.swift
- Meshtastic/Views/Settings/ShareChannels.swift
- Meshtastic/Views/Settings/Config/Module/MeshBeaconConfig.swift
- Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift
- Meshtastic/Views/Helpers/Compact Widgets/RadiationCompactWidget.swift
- Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift
- Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift
- Meshtastic/Views/Settings/Config/SecurityConfig.swift
- Meshtastic/Views/Settings/Config/DisplayConfig.swift
- Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift
- Meshtastic/Views/Provisioning/WifiProvisioningView.swift
- Meshtastic/Views/Settings/Config/Module/SerialConfig.swift
- Meshtastic/Views/Settings/Config/Module/TrafficManagementConfig.swift
- Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift
- Meshtastic/Views/Settings/Discovery/DiscoveryScanView.swift
- Meshtastic/Views/Settings/Config/NetworkConfig.swift
- Meshtastic/Views/Settings/Config/LoRaConfig.swift
- Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift
- Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift
- Meshtastic/Views/Settings/Config/PositionConfig.swift
- Meshtastic/Views/Settings/AppSettings.swift
- Meshtastic/Views/Settings/Config/PowerConfig.swift
|
Addressed the remaining review follow-ups in f4363a6 and merged the current upstream Changes include:
Validation:
|
Summary
Event metadata is display-only and can't safely authorize a firmware install, so today event firmware means a trip to the web flasher. This adds an in-app installer for event editions — and a return-to-standard path — gated behind an Ed25519-signed release contract, separate from the metadata feed.
Blocked until two things exist outside this repo: the contract needs ratifying (meshtastic/design#133) and the API needs the signed endpoint plus a provisioned signing key (meshtastic/api#110). The app embeds only the trusted public key and fails closed without one — no production behavior changes until the key is deliberately provisioned.
What changed
Testing
43 unit tests in 5 suites: contract verification, exact-target selection, download verification, installer handoff policy, and availability. Not yet run against a real radio — a physical ESP32 and nRF install (and return-to-standard) are required before this leaves draft, along with the upstream contract and key provisioning.