Status: FROZEN v1.28 · Owner: project maintainer · Frozen:
2026-07-31 ([docs]; pre-v1 two-profile release eligibility and explicit
C3 deferral; evidence-derived resource policy and exact HIL V2 records;
pre-policy two-root baseline-input staging and mechanical baseline/policy
assembly;
exact-profile manifest, C3 silicon-revision,
license-audit safety, candidate-finalization, and candidate pin-state
amendments, plus the real-tool license-evidence and exact license-catalog
models, trusted ESP-IDF tool-download-cache evidence, exact reviewed-source
evidence catalog, stable generated-input matchers, exact license-choice
semantics, relationship-bound aggregate-package evidence, an independent
shipment-review ledger, exact multi-frontend toolchain identity, canonical
literal-manifest evidence, byte-exact frozen-source reconstruction, and
retained per-target source-checkout isolation, deterministic retained-root
compiler path mapping, and fail-closed ESP-IDF described-source directory
markers, generated-header inputs, and direct-object reconciliation, plus
component-owned linked outputs, lexical exact-path validation, nested-build
logical paths, shell-free compiler/linker command receipts, executable
version-matched recovery-command syntax, and the canonical pre-v1 same-origin
publication channel with an optional byte-identical mirror, plus bounded
completed-HIL report assembly, plus the exact one-version unqualified public
beta exception, on the same date)
This document is the source of truth for the initial browser-provisioning release bundle. It refines website.md §7 and firmware requirements §8. ESP Web Tools defines the installer manifest format and the ESP-IDF v4+ merged-image requirement; this document defines the additional PyBLE compatibility, integrity, provenance, recovery, and hardware-validation contract around it.
Browser flashing is a one-time, wired provisioning path. It does not add a USB runtime transport: after provisioning, the app communicates with the board over BLE and PBLE/1.
The current pre-v1 browser bundle contains exactly these two
provisioning image profiles. The exact audited v0.4.2 bytes may be offered
only as the hardware-tested public beta defined in §10; that narrow validation
does not make them qualified-release profiles.
A later qualified public bundle uses the same exact profile definitions:
| Profile ID | ESP Web Tools chipFamily |
Required target configuration | ESP image silicon window (min_chip_rev_full…max_chip_rev_full) |
Merge settings | Browser image and component map |
|---|---|---|---|---|---|
esp32-4mb |
ESP32 |
Classic ESP32 with 4 MiB external SPI flash; no PSRAM assumption | 0…399 |
DIO, 40 MHz, 4 MiB | merged firmware.bin at 0x1000; bootloader 0x1000; partition table 0x8000; application 0x10000 |
esp32-s3-n16r8 |
ESP32-S3 |
ESP32-S3 with 16 MiB flash and 8 MiB Octal PSRAM (N16R8-class) | 0…99 |
DIO, 80 MHz, 16 MiB | merged firmware.bin at 0x0000; bootloader 0x0000; partition table 0x8000; application 0x10000 |
esp32-c3-4mb remains a known provisioning profile and an initial v1
firmware target, with this frozen future qualification:
| Deferred profile ID | ESP Web Tools chipFamily |
Required target configuration | ESP image silicon window (min_chip_rev_full…max_chip_rev_full) |
Merge settings | Browser image and component map |
|---|---|---|---|---|---|
esp32-c3-4mb |
ESP32-C3 |
ESP32-C3 revision v0.3 or newer with 4 MiB external flash; no PSRAM assumption | 3…199 |
DIO, 80 MHz, 4 MiB | merged firmware.bin at 0x0000; bootloader 0x0000; partition table 0x8000; application 0x10000 |
That deferred profile is not part of the current public release: it MUST
NOT have an entry in release.json, a public manifest or binary directory, or
an installer selection. The website MUST show it separately as unavailable
pending exact-profile real-hardware validation. Re-enabling it requires a new
SemVer candidate, the complete automated gates, its own HIL row, and a new
immutable public bundle; it MUST NOT be added to an existing version.
The offsets and flash settings above are frozen from the matching ESP-IDF
flasher_args.json outputs. The release builder MUST compare generated
flasher_args.json with this table and fail on any difference; it MUST NOT
silently preserve stale or hand-copied offsets.
These are image-layout profiles, not GPIO-routing profiles and not an
app-runtime chip allowlist. ESP Web Tools detects the chip family, but that
detection does not prove flash capacity, PSRAM type, board wiring, power
quality, or silicon revision. In particular, the initial S3 image MUST be
presented only as esp32-s3-n16r8; neither the website nor release notes may
call it a generic image for every ESP32-S3 board. Any future C3 image MUST be
presented as requiring ESP32-C3 revision v0.3 or newer.
Before the install action appears, the user MUST explicitly select a profile and affirm that the connected module meets its stated flash/PSRAM configuration. The installer MUST then give ESP Web Tools only that profile's single-build manifest. A family mismatch MUST therefore be rejected by ESP Web Tools after connection; a shared multi-family manifest is forbidden because it could silently select a different image than the profile the user affirmed. A USB VID/PID, serial-port name, browser user-agent, or previously saved BLE board identity MUST NOT be treated as proof of a profile.
Adding or re-enabling a flash size, PSRAM topology, or chip family requires a new release candidate with the applicable profile ID, artifact set, compatibility copy, automated cases, and real-board HIL record. It MUST NOT broaden an existing profile or mutate a published bundle by implication.
One bundle represents one firmware-agent SemVer and one immutable source state.
For version <version>:
<version>is canonical SemVer without a leadingv;- the annotated Git tag is
firmware-v<version>; - every binary is built from the exact tag commit in a clean checkout;
- the MicroPython and ESP-IDF refs and full 40-hex commit IDs are read from the
frozen
firmware/versions.lock; agent_versionequals<version>andprotocol_versionequalsPBLE/1;- the build records the full PyBLE source commit, the clean/dirty state, build runner OS/architecture, compiler/tool versions, UTC build time, and upstream patch count;
- the same source commit and pinned toolchain MUST produce byte-identical released parts after documented deterministic-build normalization; a two-clean-build comparison is release-blocking; and
- no firmware binary may be rebuilt, renamed in place, or replaced after HIL begins. Any changed byte outside the administrative promotion envelope defined in §9 creates a new release candidate and invalidates prior HIL evidence for that file set.
Each of the two clean reproducibility build roots MUST retain this exact relative source layout until the license audit, candidate validation, and two-root byte comparison have completed:
.sources/esp32/micropython/
.sources/esp32-s3/micropython/
.sources/esp32-c3/micropython/
Each path is an independent MicroPython checkout for exactly the named target.
Its HEAD MUST equal the full versions.lock [micropython].commit, its
origin URL MUST equal the canonical versions.lock [micropython].repo, and
its tracked tree MUST be clean when admitted and validated. Each target's
authoritative application project_description.json project_path MUST
resolve to that target's retained checkout at ports/esp32, and the audit
receipt MUST bind the project-description bytes and checkout commit/origin
identity. A missing, escaped, symlinked, dirty, wrong-origin, wrong-commit, or
cross-target checkout is fatal.
Build preparation and ESP-IDF dependency materialization MUST be target-local.
In particular, no target may share, overwrite, or delete another target's
ports/esp32/managed_components or other mutable generated/source state. A
sequential build that leaves any earlier target described by source bytes from
a later target is invalid, even if released binaries happen to compare equal.
Before configuring or compiling an application, the build runner MUST replace
the target's exact retained MicroPython checkout prefix in compiler debug,
macro, and source-file paths with the target-stable logical prefix
/MICROPYTHON, and the exact PyBLE checkout prefix with /PYBLE. The more
specific MicroPython mapping MUST win if those paths are nested. ESP-IDF's
reproducible-build mapping MUST continue to replace the target build directory
with /IDF_BUILD. These mappings MUST be controlled by the runner and MUST
replace, not append to, any ambient compiler path-map input. A release build is
invalid if its whole ELF still contains either clean source/build root, or if
the two clean builds produce different whole-ELF hashes,
application-descriptor ELF hashes, application images, or merged images.
Generated frozen-content comments may retain their root-local input paths and
therefore are not cross-root release artifacts; each root's frozen content
MUST still independently reproduce and match its own generated build input
under the license-audit rules in §6.
These isolated build checkouts do not replace the canonical candidate proof
root. The exact versions.lock, board overlays, firmware/pyble sources,
literal manifests, release tools and license policy/evidence, and pinned
generator/compiler inputs under the canonical
firmware/upstream/micropython remain independently SHA-256-bound by the
semantic receipt and MUST agree with the corresponding selected build inputs.
For this release contract, candidate-frozen means that the exact
versions.lock bytes and the source commit containing them have been selected
as immutable inputs to one release candidate. Candidate-freezing MUST happen
before the two clean release builds, license audit, candidate packaging,
protected-site staging, or HIL. It is an input-selection state only: it does
not assert that the pins work on hardware and does not approve them for a
public release. Exact-profile HIL on both current release profiles,
esp32-4mb and esp32-s3-n16r8, remains the pre-v1 public-release approval
gate.
Changing either upstream pin after candidate-freezing creates a new source state and a new candidate. The build, reproducibility comparison, license audit, protected deployment, and complete current-release-profile HIL matrix MUST then start again; evidence from the abandoned candidate MUST NOT be carried forward.
Before the first OI-1 policy can be committed, the release tool MUST provide a
create-baseline-inputs operation that breaks the policy/evidence dependency
cycle without weakening candidate validation. It MUST bind two distinct,
non-nested clean build roots to the clean PyBLE proof checkout, validate all
three maintained ESP32 targets and their retained source checkouts, require
the released inputs to compare byte-for-byte, and then atomically create one
new no-replace, access-controlled output tree containing exactly:
esp32-4mb/
manifest.json
firmware.bin
application.bin
partition-table.bin
esp32-s3-n16r8/
manifest.json
firmware.bin
application.bin
partition-table.bin
The two manifest files MUST be generated by the same production manifest function used by candidate packaging. The three binary files per profile MUST be copied from the validated primary build root. The deferred C3 build MUST participate in validation and reproducibility comparison but MUST NOT be staged. This operation runs before, and therefore MUST NOT require, an OI-1 policy, baseline-evidence file, release tag, license evidence, or HIL approval. Its output is measurement input only: it is not a release candidate and MUST NOT be accepted by the website or public release validator.
After both exact-profile baseline runs succeed, the release tool MUST provide
an assemble-oi1-baseline operation. It accepts the immutable staged baseline
input tree above, exactly two canonical single-profile fragments emitted by
the OI-1 bench, the clean canonical PyBLE proof checkout, and one explicit UTC
created_at value. It MUST derive source_commit from that checkout's exact
HEAD and firmware_version from its versions.lock; operator-supplied
substitutes for either identity are forbidden. Before writing, it MUST require
the proof checkout to be clean, require exactly one fragment for each profile
regardless of input order, validate every fragment and observation field,
bind each fragment's firmware hash, manifest hash, and build measurements to
the corresponding staged bytes, and mechanically derive all nine thresholds
with the frozen formulas.
The operation MUST canonicalize and create the baseline evidence at
docs/validation/firmware/oi1/<HEAD>.json, compute the digest of those exact
bytes, and atomically update firmware/qualification/oi1-gates.json with the
exact frozen policy shape and derived thresholds. The baseline path is
no-replace: any existing destination is fatal. The policy update MUST be an
atomic same-directory replacement, and both complete payloads MUST pass the
production baseline/policy validator. This operation is evidence assembly
only; it does not approve a release or mutate staged measurement inputs.
The protected candidate site's build-selected SHA-256 of release.json is the
root identity of the candidate exercised during HIL. The completed HIL
evidence MUST record that exact lowercase 64-hex digest. Public finalization
MUST verify it against the candidate source tree and MUST derive a new public
directory without mutating that source.
The maintainer MUST verify that the local annotated tag, embedded agent
version, DEVICE_INFO/HELLO version, manifest version, bundle metadata, and
public same-origin directory all identify the same version and source commit.
For v1.0 and later, the matching GitHub Release MUST identify that same version
and source commit as well. For a v0.x release, any optional mirror MUST meet the
same identity and byte-parity requirements, but absence of a mirror does not
block same-origin publication.
The canonical public files are static and use this exact layout:
https://pyble.dev/firmware/v<version>/
release.json
release.schema.json
SHA256SUMS
THIRD_PARTY_LICENSES.txt
RELEASE_NOTES.md
RECOVERY.md
HIL_REPORT.md
esp32-4mb/
manifest.json
firmware.bin
bootloader.bin
partition-table.bin
application.bin
esp32-s3-n16r8/
manifest.json
firmware.bin
bootloader.bin
partition-table.bin
application.bin
No esp32-c3-4mb/ path exists in this release. Absence is intentional and is
part of the immutable release contract, not a missing upload.
The profile's firmware.bin is the MicroPython/ESP-IDF-generated merged
image and is the only binary its profile-scoped ESP Web Tools manifest
installs. This follows ESP Web
Tools' ESP-IDF v4+ requirement: the browser tool cannot patch flash
mode/frequency/size headers across separate images on the fly. The manifest
therefore references one merged image at that image's base offset.
application.bin is the application component identified as micropython.bin
by the corresponding flasher_args.json. The bootloader, partition table, and
application components remain in the bundle for provenance, validation, and
advanced recovery, but the browser manifest MUST NOT flash them separately.
The release builder copies component bytes without transforming them and proves
that firmware.bin is the deterministic merge of those authoritative
components at the frozen settings and offsets.
Every manifest part path MUST be relative to its profile directory, remain in
that directory, and resolve on https://pyble.dev; absolute URLs, ..,
redirects, cross-origin artifacts, and mutable aliases such as latest are
forbidden. For every v0.x release, the immutable same-origin directory is the
canonical public firmware distribution. A pre-v1 mirror is optional; when one
is published, every corresponding file and byte MUST equal the canonical
same-origin bundle. v1.0 and later MUST additionally publish the matching
byte-identical GitHub Release.
A published v<version>/ directory is immutable. Successful artifact responses
(2xx and 304) receive immutable caching, but every 4xx or 5xx response below
/firmware/ MUST receive Cache-Control: no-store and MUST NOT receive an
immutable directive. This prevents a transient publication or file-permission
failure from poisoning an edge cache for the lifetime of the release.
The signed metadata continues to contain canonical paths without query strings.
Browser retrieval MAY append exactly one deterministic pyble_release query
whose value is the build-selected release.json SHA-256. PyBLE MUST preserve
the canonical pathname, same-origin requirement, redirect rejection, exact
response URL check, size check, and SHA-256 check. The same deterministic query
MUST cover ESP Web Tools' later manifest and firmware fetches so an earlier
cached error cannot re-enter between verification and flashing.
HTML and the website's selected-release descriptor remain revalidating. A new
release updates the website's compile-time descriptor to a new exact version
path and expected release.json SHA-256; it never overwrites an old directory.
Each <profile-id>/manifest.json MUST use the documented ESP Web Tools schema,
contain only the installer fields understood by that tool, and contain exactly
one build for its owning profile. For example,
esp32-s3-n16r8/manifest.json is:
{
"name": "PyBLE",
"version": "<version>",
"new_install_prompt_erase": false,
"new_install_improv_wait_time": 0,
"builds": [
{
"chipFamily": "ESP32-S3",
"parts": [{ "path": "firmware.bin", "offset": 0 }]
}
]
}<version> above is a template substitution, not literal released JSON.
The esp32-4mb manifest has the same shape and exactly one ESP32 build with
offset 4096. There is no C3 manifest in the current release. The website MUST
set the custom element's manifest URL to the verified manifest for the selected
profile only. It MUST NOT pass an all-family catalog to ESP Web Tools or change
manifests after the browser has selected a serial device.
new_install_prompt_erase: false deliberately makes a new installation erase
the existing device. The website MUST explain this before activation and
require the user to acknowledge that the workspace and previous firmware will
be lost. PyBLE does not implement Improv Serial, so the manifest uses the
documented top-level zero Improv wait and does not invent a per-build field.
The manifest validator MUST reject unknown top-level/build/part keys, a missing or extra build, a family other than the owning profile's family, a missing or extra merged-image part, non-integer or out-of-range offsets, unsafe paths, a version mismatch, or any difference from the profile table. It MUST also parse the merged image, its ESP component images, and partition table sufficiently to prove the expected chip target, base offset, image roles, component placement, partition offsets/sizes, application fit, merge settings, and declared flash capacity before publication.
The stock ESP Web Tools manifest has no artifact digest or byte-size fields.
PyBLE therefore MUST NOT invent non-standard digest fields inside
manifest.json. Integrity is carried separately by release.json, validated
by release.schema.json (JSON Schema draft 2020-12).
The release tool is the canonical producer of release.schema.json. Any
frozen consumer copy used by the web flasher MUST equal that generated JSON
object exactly, including deterministic array ordering; JSON-Schema semantic
equivalence is not sufficient for this fail-closed staging boundary. A
cross-component automated contract MUST compare the checked-in web copy with
the release-tool generator so the two cannot drift together with isolated
fixtures.
Every release-controlled JSON or TOML schema_version is type-strict: the
declared integer is accepted only as an integer, never as a boolean or
numerically-equal fractional value. This applies equally to build provenance,
the release-tool lock, license policy and shipment ledger, audit receipts, HIL
records, and release.json.
release.json schema version 2 MUST contain (the version is the exact JSON
integer 2; booleans and numerically-equal fractional values are invalid).
Version 2 is an intentional incompatible metadata revision: it freezes the
two-profile pre-v1 release cardinality and MUST NOT validate the former
three-profile candidate shape. Older immutable bundles retain their own schema
beside their metadata:
schema_version=2;- release identity:
version,tag,agent_version,protocol_version, and UTCbuilt_at; - provenance: full PyBLE commit and clean state; MicroPython ref/commit; ESP-IDF ref/commit; patch count; runner and compiler/tool versions;
- one entry for each current release profile in the first table of §1,
including profile ID,
chip_family, flash/PSRAM requirements, flash mode/frequency, requiredsilicon_revision.minimum_fullandsilicon_revision.maximum_fullintegers matching the §1 image window, and HIL status (pendingfor a candidate or the exactv0.4.2public beta, orpassedfor a qualified public bundle); - one
manifestentry per profile with its relative path, exact byte size, and lowercase 64-hex SHA-256; - one
installentry per profile for the merged image, with relativepath, decimal baseoffset, exact bytesize, and lowercase 64-hexsha256; - exactly three
componentsentries per profile forbootloader,partition-table, andapplication, each with its relativepath, decimal flashoffset, exact bytesize, and lowercase 64-hexsha256; - relative paths plus size/SHA-256 for
THIRD_PARTY_LICENSES.txt,RELEASE_NOTES.md,RECOVERY.md, andHIL_REPORT.md; and - the exact locally bundled
esp-web-toolsversion used by the site.
All values are required; placeholders, unknown, and abbreviated commits fail
any bundle. pending is accepted only on an access-controlled candidate used
for HIL or the exact audited and digest-bound v0.4.2 public-beta exception
in §10; an HIL
status other than passed fails every ordinary public bundle.
release.schema.json
itself is versioned and immutable beside the metadata. SHA256SUMS MUST use the
conventional lowercase-hex, two-space, relative-path format and cover every
file in the directory tree except SHA256SUMS itself.
The static website build embeds the exact versioned release.json URL and its
reviewed SHA-256. Before enabling the custom install element in a browser, the
client MUST:
- fetch that same-origin metadata with redirects rejected;
- verify the metadata bytes against the embedded SHA-256 using Web Crypto;
- validate it against the local schema;
- fetch the selected profile's declared
manifest.jsonand merged install image; - verify every declared byte size and SHA-256;
- cross-check that the manifest has exactly one build and that its version, family, path, offset, and install set match only the selected profile in the verified metadata; and
- fail closed without rendering an active install control if any fetch, schema, path, size, digest, or cross-check fails.
The checked bytes and ESP Web Tools' subsequent requests are protected against deployment races by immutable version paths. This checksum layer detects corrupt or inconsistent publication; HTTPS and release-review controls remain the trust boundary against a compromised origin.
THIRD_PARTY_LICENSES.txt MUST be generated mechanically from the exact
resolved build inputs and include, for every redistributed dependency, its
name, version/ref, source URL, SPDX identifier, copyright notice, required
notice text, and complete license text. At minimum it covers upstream
MicroPython, the ESP-IDF components present in the binaries, and every frozen
MicroPython package. The build fails on an unrecognized, missing, or
license-incompatible dependency.
The firmware audit toolchain MUST pin esp-idf-sbom 1.2.0 and its complete
transitive Python closure by artifact hash in firmware/release-tools.lock.
The reviewed top-level wheel is
esp_idf_sbom-1.2.0-py3-none-any.whl with SHA-256
a1444a7f23740c44cacbce4845efb5cbcb08927878b6a3852c33a52d8b2b5da9
(upstream tag v1.2.0, commit
d46a159ac239b9f843c59e0b4bfcfaff1859b862). A changed tool or closure
requires review and a new lock; an unpinned environment MUST NOT generate a
public notice.
The conservative build audit runs against all six authoritative ESP-IDF
descriptions: application and bootloader project_description.json for each
of the three initial build targets. This preserves the v1 three-target build
and license gate even while the pre-v1 public bundle contains two profiles.
The released notice MUST classify as redistributed only the dependency union
of the two packaged profiles; C3-only observations remain retained review
evidence and MUST NOT be represented as a shipped C3 image or shipped profile.
The audit uses linked-only output (--rem-unused --rem-config) plus file tags,
runs with network access disabled, an isolated temporary home, and a committed
hash-pinned empty SBOM_EXCLUDED_CVES_FILE. UUIDs, timestamps, and absolute
paths in intermediate SPDX evidence are normalized before semantic comparison;
the released notice itself contains none of them.
firmware/licenses/license-policy.json is the reviewed, fail-closed mapping
from the exact raw package identities and resolved build inputs to immutable
source/ref metadata, SPDX expressions, applicability, complete license-text
path/SHA-256, required NOTICE path/SHA-256, and disposition. Raw tool metadata,
reviewed notice metadata, and resolved-input evidence are separate fields; a
stronger reviewed value MUST NOT be represented as if the raw tool emitted it.
Its schema_version is the exact JSON integer 2; booleans and
numerically-equal fractional values are invalid.
The generator MUST:
- reconcile ESP-IDF SBOM packages with the archives actually included by both
application and bootloader map files and with uniquely resolved
compile_commands.jsonsources; - inventory the exact frozen manifest inputs and reconcile them with the
generated
frozen_content.c, including the pinned upstream NeoPixel module; - account explicitly for prebuilt ESP controller/PHY/Wi-Fi/coexistence blobs and compiler/newlib runtime archives, including the GCC Runtime Library Exception where applicable;
- treat a raw SBOM package's
NOASSERTION/NONEas unresolved and release-blocking unless it uniquely matches an exact package identity, source/archive provenance, and profile/role applicability in a reviewed, hash-bound policy entry that supplies the SPDX expression, complete license texts, and required notices; - reject a stale generated inventory, an unmapped or ambiguous object/archive,
missing or ambiguous provenance, a missing or changed license/NOTICE text,
an unknown SPDX identifier, an unapproved
LicenseRef, or a policy-denied or review-required expression, and ensure the final generated inventory contains noNOASSERTION/NONE; and - emit a stable, sorted union annotated by profile, deduplicating identical complete texts by their reviewed SHA-256, then produce byte-identical output in the second clean build.
The following resolution rules are part of that fail-closed mapping:
-
The audit retains all six exact raw
esp-idf-sbomoutputs and six normalized reviewed SPDX documents. Raw tag/value files use.spdx.tag; only normalized JSON files use.spdx.json. The receipt hashes both sets, the exact raw package-property sets and complete relationship multisets, the locked wheel closure and execution/isolation identity, and every exact build, policy, map, manifest, source, archive, license, and NOTICE input. Raw bytes are never overwritten by normalization or discarded after a successful audit. -
Frozen files are resolved without executing manifest code, from the literal
include,require,package,module, and non-selectingmetadataoperations of the hash-bound pinned manifests. Imports, assignments, control flow, computed arguments, unknown calls, unresolved variables, path escape, symlinks, duplicate destinations, and unrecognized package resolution are fatal. Directory recursion is not a manifest resolver. Every traversed manifest and selected source is receipt-hashed, and the resolved set MUST exactly equalfrozen_content.c. Thus the pinned asyncio manifest includes its six declaredasyncio/*.pyfiles and rootuasyncio.py, but notmanifest.pyor the C-providedtask.py. -
A System V/GNU/BSD archive is structurally parsed as an ordered multiset. Repeated member basenames are permitted because ESP-IDF legitimately produces them. Every first-column map occurrence MUST have a corresponding archive-member occurrence, and a map count exceeding the archive count is fatal. Malformed headers, names, long-name tables, sizes, padding, trailing bytes, symlinks, or special files are fatal. The exact archive and map bytes are receipt-bound, so duplicate basenames cannot admit an unrecorded archive or member.
-
Every
project_description.jsoncomponentsourcesentry MUST resolve without symlinks below the repository or selected build root. A regular-file entry MUST appear incompile_commands.jsonexcept for the following exact generated-header shape in an applicationmaincomponent: the entry is a direct regular-file child of the selected role build'sgenhdr/directory and its basename is exactly one ofcompressed.data.h,moduledefs.h,mpversion.h,pins.h,qstrdefs.generated.h, orroot_pointers.h. Those six non-translation inputs are receipt-hashed but are not assigned an archive member.ESP-IDF may also emit an existing directory as a source marker; that marker is admitted only at or below the same component's declared source directory, and it describes only compile-command source files recursively below it. It does not recursively add uncompiled files to the license inventory.
Every compile-command source MUST still be covered by an exact described file, an admitted described directory marker, or a
CONFIG_ONLYcomponent root, except for these pinned application-maininputs:- the nine repository-owned files
firmware/user_c_modules/pyble/{pble_proto,pble_ble,pble_info,pble_device_config,pble_runner,pble_console,pble_fs,pble_lock,pble_boot}.c; - the fourteen files in the selected target's immutable retained
.sources/<target>/micropython/lib/berkeley-db-1.xxtree:btree/{bt_close,bt_conv,bt_debug,bt_delete,bt_get,bt_open,bt_overflow,bt_page,bt_put,bt_search,bt_seq,bt_split,bt_utils}.candmpool/mpool.c; and - the exact zero-byte role-root file
project_elf_src_<idf-target>.c, where<idf-target>is the literalproject_description.jsontarget. The same anchor is the sole compile-source exception in each bootloader role.
A missing file, unexpected basename, alternate or sibling retained tree, escaped path, near-match root, nonzero generated ELF anchor, or symlinked input is fatal.
- the nine repository-owned files
-
A generated component archive has only a stable topology matcher in committed policy. An ordinary matcher contains exactly its ESP-IDF component name. A nested CMake archive produced below a
CONFIG_ONLYowner additionally contains one explicit selector with the nested target, build-relative archive path, and build-relative object directory. The nested form is valid only when the named owner is exactlyCONFIG_ONLY, has emptyfileandsourcesproperties, the archive is a unique linked map input, every selected compile output is below the exact object directory, and the compile-output basename multiset equals the archive member multiset. Its logical source paths MUST remain below the immutable reviewed owner source tree. This is the narrowly admitted shape used by Mbed TLS'smbedcrypto,mbedtls, andmbedx509targets. Their separately compiled Everest and p256m object-library outputs are build-only/unlinked; they MUST NOT be assigned tolibmbedcrypto.a. A synthetic component, path-only selector, or transitive object directory is not accepted.Ordinary component membership is selected by compile output topology, not by a set of unique source paths: the exact object root is
<archive-parent>/CMakeFiles/<component-lib>.dir, using the component's literalfileandlibfields. This distinction is required because each admitted PyBLE C source is compiled once intolibmain.aand once directly into the application ELF. The selected output-basename multiset MUST equal the parsed archive-member multiset exactly. Each selected source MUST also belong to that same component's exact described file or directory marker; an applicationmainarchive may additionally own the exact nine pinned PyBLE sources above. A file or root described by one component MUST NOT authorize a linked output owned by another component, and the pinned Berkeley DB sources remain direct-object-only.A relative JSON
filefield resolves against the command'sdirectoryand MUST equal its literal-csource. A relative JSONoutputfield resolves against the role build containingcompile_commands.json, while the compiler's literal-oargument resolves against the command'sdirectory; those output paths MUST resolve to the same existing regular, symlink-free file. This rejects plausible but wrong source/output evidence assembled under the wrong base. Operand traversal MUST be validated in its original lexical order:..MUST NOT be collapsed before checking an earlier symlink component. A string-form compilercommandMUST be one shell-free argument serialization: shell control, redirection, command substitution, and response-file syntax (including driver-wrapped-Wl,@...) are fatal. Anargumentsarray is already direct argv and is not interpreted as shell text, but response-file syntax is still fatal.ESP-IDF legitimately emits compile commands for build-only objects that the selected executable does not link. Therefore every linked compile output MUST be consumed exactly once, either by one generated archive member or by one exact direct-object
LOADpath in the same linker map. Every exact map direct-objectLOADMUST resolve to one compile output. A remaining compile output is classified as unlinked and MUST NOT be assigned to a generated archive/input merely because its basename matches a linked member. Its logical source path remains in the hash-bound compile-command document and must still pass the source-root rules above, while its output and source bytes are excluded from the redistributed-input binding. They do not affect the shipped ELF and are not release inputs merely because ESP-IDF built them.Direct objects are admitted only in these pinned topologies:
CMakeFiles/<app-elf>.dir/project_elf_src_<idf-target>.c.objfor the zero-byte role-root ELF anchor;- application
CMakeFiles/micropython.elf.dir/<absolute-PyBLE-source>.objfor the nine repository-owned PyBLE C sources; and - application
esp-idf/main/CMakeFiles/micropy_extmod_btree.dir/<absolute-retained-source>.objfor the fourteen retained Berkeley DB sources.
Each direct output MUST be a regular, symlink-free file below the selected role build, and the map MUST name its exact build-relative path once. Exact generated-header, source-exception, anchor, and direct-output topologies MUST be checked lexically for symlink components before any canonical-path equality check; an alternate path resolving to the same bytes is not equivalent. Receipt records for files below the selected build root MUST use
build/<build-relative-path>even when that build root is itself nested below the repository root. The role's exactCMakeFiles/<app-elf>.dir/link.txtMUST also be a regular, symlink-free receipt input. Its safely parsed direct.o/.objargument set MUST equal the map's direct-objectLOADset exactly. Response files, including driver-wrapped forms, shell operators, duplicate object arguments, and path escape are fatal. Basename-only matching is forbidden.One logical source may therefore have multiple compile outputs. Archive source records remain archive-only and unique by logical path. The role's generated
mainbinding additionally contains:linker_command_sha256, the exact rolelink.txtdigest;metadata_inputs, the canonical logical path/SHA-256 records for the six applicationgenhdr/inputs (empty for bootloader); anddirect_objects, canonical records containing each direct output's logical path/SHA-256 and its compile source's logical path/SHA-256.
The
mainbinding is invalid unless those three fields are present and exactly cover the role evidence. Duplicate linked consumption, an extra or missing directLOADor link-command object, a wrong direct-output root, an archive/output multiset mismatch, or treating an unlinked output as redistributed is fatal.project_description.json,compile_commands.json, and map SHA-256 values, generated source and auxiliary-input hashes, compile-output/direct-LOADand linker-command reconciliation, direct object/source hashes, member lists, and the final archive SHA-256 are observations of one build. They MUST be retained in the validated receipt binding and MUST NOT appear in committed policy. Two clean builds in different absolute roots use the same stable policy even when their exact generated-document hashes differ. The receipt for each build still binds its own hashes, logical source inventory, member multiset, and archive digest. A policy field that attempts to predeclare or override one of those generated values is fatal. An opaque prebuilt archive, a frozen source tree, or a compiler/runtime archive has no such exception: its digest is predeclared in reviewed policy and MUST match exactly.After all six offline SBOM executions, the observer MUST rebuild this exact generated-input context and require byte-for-byte equality with the initial observations. Rechecking only archive/tree digests is insufficient: project/compile/map/link documents, metadata inputs, archive sources, direct outputs, and direct sources are all race-sensitive release inputs.
-
An archive outside the repository and build roots is admitted only below one versioned toolchain root proven by the exact compile-command executables and the locked ESP-IDF
tools/tools.jsonentry. The policy predeclares the tool name, ESP-IDF version key, platform key, and one exact, nonemptycompiler_frontendscatalog. Each frontend record contains only its constrained installed-root-relative path and SHA-256; paths are unique and canonically ordered. The initial Xtensa and RISC-V catalogs contain exactly their prefixedgccandg++frontend pair. A singular representative compiler is insufficient.Every compile-command entry's absolute executable MUST be consumed by exactly one declared frontend, and every declared frontend MUST be observed. Repeated commands using the same executable do not create a new frontend identity. Missing, extra, or duplicate frontend identities are fatal. All frontends for one toolchain MUST derive the same installed root, trusted ESP-IDF tools home, metadata entry, and cached distribution; a sibling installation or mixed tools homes is fatal.
The distribution record contains exactly the HTTPS URL, logical filename (the URL basename), byte size, SHA-256, archive format, and top-level archive root. The archive root is distinct from the installed version directory: for example, the versioned installation may be
tools/xtensa-esp-elf/esp-14.2.0_20241119/xtensa-esp-elfwhile the archive root is onlyxtensa-esp-elf. A repository-relative distributionpathis forbidden; the 453 MB pinned Xtensa/RISC-V downloads are never vendored.The observer derives one trusted ESP-IDF tools home from the exact frontend path set, the installation layout, and the repository's pinned ESP-IDF metadata. It then derives the cache entry as
<tools-home>/dist/<logical-filename>. Neither policy nor caller may supply a cache root/path, alternate metadata path, or arbitrary toolchain root. The metadata entry MUST exactly agree with the policy URL, filename, size, SHA-256, name, version, platform, export path, and archive root. The tools home, itstools/anddist/directories, the versioned installation, the frontend/runtime inputs, and the cached archive MUST have no symlink ancestor or terminal symlink and MUST remain below that tools home. Filename mismatch, missing cache bytes, size/digest mismatch, unsafe archive entry, root mismatch, or a sibling/caller-selected installation is fatal.During the offline audit the matching cached archive is size/digest verified and safely read. Every frontend MUST be a regular, non-symlink installed file, match its reviewed SHA-256, and be byte-identical to the corresponding member of that exact cached distribution. Every admitted compiler/runtime archive is likewise byte-compared with its counterpart in the distribution. Private observations retain the exact absolute frontend paths required to prove the build. Validated evidence and receipts record only logical metadata, semantic hashes, constrained relative frontend/member paths, and distribution identity; they MUST NOT contain a host-absolute tools-home, cache, frontend, installed-root, or runtime path.
-
Each raw package is matched exactly once by profile/role and its complete raw property set, including name, version state, download-location state, copyright state, declared/concluded license states, checksums, annotations, external references, and relationship multiset.
NOASSERTION/NONEis an expected literal state, never a wildcard. SPDXPackageVersionis optional: if the raw tag/value document omits it, the parsed package and exact raw policy both omitversionInfo. Absence is not synthesized into"",NOASSERTION, or a reviewed version. The release build hydrates its isolated source copy with resolved ESP Component Manager metadata before SBOM generation. Every property the pinned tool then emits for a managed component—including version, supplier, originator, summary, download location, external references, copyright, and concluded expression—is retained as exact raw evidence and frozen in policy; it is not discarded merely because an earlier clean build emitted a sparse package. Presence/absence and any present bytes must agree exactly; an explicitly empty tag remains malformed.Policy keeps three distinct values: the exact raw concluded state, the reviewed raw-package expression used in normalized evidence, and each resolved redistributed input's expression used in the public notice. A concrete raw concluded expression MUST parse and equal the reviewed raw-package expression; it is preserved even when it describes the broad toolchain distribution rather than the narrower linked runtime inputs. When redistributed inputs have different reviewed terms, the reviewed package declares a nonempty, unique
reviewed_input_expressionsallowlist. If omitted, its only allowed input expression is the reviewed raw-package expression. A resolution expression MUST equal one complete allowlisted expression, not merely use an identifier subset. Complete license texts exactly cover the union of the raw-package and allowlisted-input expressions. Thus the concrete broad GCC/toolchain raw expression remains unchanged in normalized evidence while the exact reviewed GPL-with-GCC-exception/newlib runtime expression is used for the linked inputs and public notice. A raw unresolved expression may be resolved only by one exact source/input-bound reviewed entry, with attribution naming that entry. Immutable reviewed version/source/copyright values populate only the normalized document and notice; they are not falsely compared with weaker raw fields. Raw occurrences with different concrete concluded expressions MUST use distinct reviewed package records and resolutions even when they share the same SPDX ID in different profiles; a union expression MUST NOT be invented to make unlike occurrences share one review. Any unexpected property, value, package, relationship, resolution, or ambiguity is fatal. -
A raw package that the pinned tool reports but that contributes no linked archive, compiled source, or aggregate project identity may be excluded from the redistribution notice only through an explicit
not-shippeddisposition and an exact zero-input proof for that profile/role. Committed zero-input proof contains only the exact profile/role and an emptymatched_input_ids; it MUST NOT predeclare project-description, compile-command, or map hashes. The validator derives those three hashes from that build into the validated proof. The receipt semantic hash covers the derived proof, so a different build cannot reuse its receipt even though both builds share stable policy.A shipped aggregate/framework/source package with no exclusive input uses
allow-aggregate, an emptyinput_refs, and exactly one proof per profile/role package reference. Each proof contains a nonempty simple path of exact raw SPDX relationships from the aggregate package to a raw package owned by a namedallowresolution with nonempty inputs in the same profile/role. Every edge must occur in that raw document, consecutive edges must join, and the terminal package must actually be consumed by the named input-owning resolution. The aggregate appears in the public notice but consumes no input; pointing at another aggregate/not-shipped resolution, reusing the target's inputs, inventing an edge, cycling, crossing a profile/role, or substitutingnot-shippedis fatal. This form covers the shipped NimBLE, ESP-IDF, FreeRTOS, lwIP, project, and heap-TLSF aggregate or source occurrences that do not own an exclusive archive.Top-level policy field
shipment_reviewcontains exactly a repository-relativepathand SHA-256 for an independent reviewed shipment ledger. That strict UTF-8 canonical JSON document has the exact JSON integerschema_version: 1(not a boolean or fractional number) and anoccurrencesarray containing exactly{profile_id, role, spdx_id, disposition}per raw package occurrence, sorted lexically by(profile_id, role, spdx_id). It covers every exact raw occurrence once with no extras, and each disposition is exactlyallow,allow-aggregate, ornot-shipped. The ledger contains no build hash and is not duplicated as another mutable field inside a resolution.Every resolution disposition MUST agree with its ledger occurrence. Therefore changing an otherwise fully formed aggregate resolution and its zero-input proof together to
not-shippedremains fatal when the independent ledger saysallow-aggregate. The receipt binds both theshipment_reviewpolicy record and the normalized occurrence classifications. -
A redistributed dependency absent from the raw ESP-IDF package graph is a deterministic supplemental SPDX package, not misrepresented as raw tool output. In this release the exact frozen NeoPixel tree and the three linked Mbed TLS archives (
libmbedcrypto.a,libmbedtls.a, andlibmbedx509.a) omitted by the raw graph are supplemental packages related to their owning MicroPython/ESP-IDF package. Their source trees, generated inputs, license text, frozen/linked proof, and relationship are all receipt-bound. A supplemental package records itssource_spdx_expressionseparately from itsselected_spdx_expression. The selected expression must be one exact top-level choice arm admitted by the source expression. Complete reviewed evidence retains texts for every source-expression identifier; the redistribution notice emits the selected expression and its exact text subset. For Mbed TLS the source evidence remains(Apache-2.0 OR GPL-2.0-or-later)while the redistributed archives explicitly selectApache-2.0. Collapsing the source expression to the chosen arm, emitting both arms as simultaneous obligations, or selecting an expression that is not one source choice is fatal.Literal frozen-manifest resolution also produces canonical evidence for each target. It contains every recursively traversed manifest as one unique repository-logical path/SHA-256 record and every frozen destination mapped to its selected source's repository-logical path/SHA-256, literal optimization level (
nullor 0–3), and package metadata version (nullor an ASCII token matching[0-9A-Za-z][0-9A-Za-z._+-]{0,127}). Both arrays are lexically ordered by their logical identity, contain no host-absolute path, and exactly describe the manifest walk that selected the frozen source tree. The semantic receipt binds this evidence. The public verifier independently repeats the literal traversal and recomputes the records, so changing a selected source or traversed manifest after the build is fatal even when the generated firmware artifact is otherwise unchanged.Filename agreement and current source hashes are not sufficient proof that the built frozen payload came from those bytes. For each target, the collector MUST therefore reconstruct the complete frozen payload in a new temporary build directory. It copies the built
genhdr/qstrdefs.preprocessed.h, invokes the repository-pinnedtools/makemanifest.pyover the already literal-validated manifest with the exact board, port, MicroPython, micropython-lib, target architecture, and empty mpy-tool flag values, and forces a clean compile by starting with no retained.mpyor output file. The subprocess receives a small, release-defined environment containing only controlled temporary-home, temporary-directory, Python cache/isolation, hash-seed, locale, and timezone values plusSOURCE_DATE_EPOCH. That epoch MUST be the exact decimal committer timestamp of the candidate's full PyBLE source commit and MUST equal the build-provenance value used to create the admitted compiler; wall-clock date and caller overrides are forbidden. Inherited loader, Python, path, and executable overrides are not admitted. The pinned, regular, non-symlinkmpy-cross/build/mpy-crossis the only admitted compiler. Once per audit, the collector also buildsmpy-crossfrom the clean pinned MicroPython source into a fresh temporaryBUILDdirectory with the controlled environment and explicit trusted build tools; the fresh executable MUST be byte-identical to the admitted compiler.The temporary
.mpydestination set and every byte MUST equal the retained build's exact regular, non-symlinkfrozen_mpy/set. Becausempy-tool.pyemits its input paths in non-semantic C comments, the final C comparison invokes that same pinned tool over the now-byte-proven retained.mpypaths, in literal manifest order, and combines its output with the clean reconstruction's string prefix. That complete reconstructedfrozen_content.cMUST be byte-identical to the built file. Before that comparison, every generated board copy selected throughBOARD_DIRMUST be byte-identical to its repository-logical overlay orfirmware/pyblesource.The collector then selects the unique compile-command entry whose exact source is this
frozen_content.c, requires an unambiguous argument-vector command with one source and one-o, and replays it from its recorded directory with the same controlled environment while replacing only the output operand with a new temporary file. The rebuilt object bytes MUST equal the unique same-basename member bytes extracted from the owning linked MicroPython archive. The semantic record binds the repository-logical component/archive identity, member name, and object SHA-256. A regenerated C file that is internally consistent but differs from the object actually linked is therefore fatal.Each per-target semantic record additionally binds the exact architecture, qstr header digest, repository-logical path/SHA-256 identities of
makemanifest.py,manifestfile.py,mpy-tool.py, the importedmpy_crosspackage files, and thempy-crossexecutable, plus the sorted destination/SHA-256 inventory of retained.mpyfiles. No temporary or host-absolute path is retained. A source, metadata version, optimization level, module order, qstr header,.mpy, generator, compiler, architecture, copied-board byte, or frozen C mismatch is fatal. -
Coverage is exact without asserting false package/archive cardinality. Every observed raw package, linked archive, compiled source, frozen destination, frozen source tree, prebuilt blob, and compiler/runtime input is consumed by exactly one resolution record; each record declares its complete many-to-many package/input attribution, and aggregate packages may own no archive directly only through rule 7. Every expected relationship is present, every declared stable matcher is observed exactly where applicable, and no unexpected record is accepted.
The initial runtime/input review explicitly includes libgcc.a and
libstdc++.a under GPL-3.0-or-later WITH GCC-exception-3.1;
libc.a, libc_nano.a, and libm_nano.a under the complete reviewed newlib
multi-license LicenseRef; and ESP-IDF's contributing Xtensa
libxt_hal.a under the pinned Tensilica MIT attribution. Command-line-listed
but non-contributing libraries are recorded for reconciliation and MUST NOT be
presented as linked unless the map proves a contributing member.
The reviewed license catalog is identifier-exact. Every identifier and
exception used by a raw, reviewed, resolved-input, or supplemental expression
MUST name one hash-bound complete text record; a text for a different
identifier is not interchangeable merely because both are permissive. The raw
six-document union for the frozen tool includes Apache-2.0,
BSD-2-Clause, BSD-2-Clause-Views, BSD-3-Clause, CC0-1.0, ISC, MIT,
Unlicense, and LLVM-exception. Linked or supplemental inputs additionally
require BSD-1-Clause, GPL-2.0-or-later, GPL-3.0-or-later,
GCC-exception-3.1, the reviewed newlib LicenseRef, and the reviewed
Berkeley DB 1.xx notice/rescission LicenseRef. A LicenseRef is approved
only with its exact complete reviewed text and attribution; it is never an
alias for a guessed SPDX identifier.
The schema-v2 policy also contains one nonempty, identifier-unique
review_files catalog. Each record contains exactly an identifier, purpose,
repository-relative path, SHA-256, and a nonempty unique list of immutable
source identities. It binds verbatim upstream NOTICE, COPYRIGHT, license,
SBOM, source, and binary-library attribution files that support a review but
are not themselves interchangeable with a complete SPDX license text. Every
path MUST be a regular non-symlink file below the repository and MUST match
its digest; every source identity MUST be explicit (commit, tree digest, or
managed-component content hash). Duplicate identifiers/paths, an empty
identity list, an unbound evidence file, or a changed byte is fatal. The
catalog participates in the policy semantic receipt hash and public
revalidation.
Toolchain and framework files that share a historical filename remain
different evidence records. In particular, ESP-IDF's component-level newlib
terms (SHA-256
0681089a556e93791da82718d68011ba452de245f7f59c3846936304756ac0c0)
MUST NOT stand in for the GCC distribution's runtime newlib terms (SHA-256
422aa40293093fb54fc66e692a0d68fd0b24ed5602e5d1d33ad05ba3909057e9).
The pinned GCC distribution's GPLv3 and Runtime Library Exception texts have
SHA-256
8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903
and
9d6b43ce4d8de0c878bf16b54d8e7a10d9bd42b75178153e3af6a815bdc90f74
respectively. The audit safely extracts and byte-compares these records from
both pinned Xtensa and RISC-V distributions; an installed copy alone is not
proof.
The initial reviewed source closure also preserves:
- MicroPython application inputs under the union of MIT, BSD-1-Clause,
BSD-3-Clause, Apache-2.0, and the exact Berkeley DB 1.xx
notice/rescission
LicenseRef, with NeoPixel retained separately as its pinned MIT supplemental tree; - Mbed TLS's original dual-license evidence while recording ESP-IDF's explicit Apache-2.0 selection for the three linked supplemental archives;
- the NimBLE NOTICE, ESP-IDF third-party copyright summary, per-family
controller/coexistence/PHY/Wi-Fi binary-library attribution, and the
Tensilica
libxt_hal.aMIT attribution; and - exact managed-component lock/source/license identity for the linked LAN867x and TinyUSB components. A diagnostic component-manager hash without the fetched candidate source and license bytes is insufficient.
Raw records for BLE Mesh and controller families not selected by a profile
remain byte-exact in the raw graph and use not-shipped resolutions with
receipt-bound zero-input proof. Their notices MUST NOT be added to the
redistribution notice.
Intermediate SBOM/license inventories are retained as release-review evidence
outside the immutable public tree. They are necessary evidence, but do not
replace the complete texts in THIRD_PARTY_LICENSES.txt.
The complete audit, its retained evidence, and the final marker-free
THIRD_PARTY_LICENSES.txt MUST be verified against the exact packaged build
before the protected candidate is staged for HIL. A candidate-only notice
inventory does not qualify for that deployment. Public finalization MUST copy
the candidate notice byte for byte and revalidate it against the same build
inputs and retained evidence; it MUST NOT accept a replacement notice.
The locally bundled website dependency on ESP Web Tools is pinned to an exact
reviewed version in package-lock.json, is served from the PyBLE origin under
the site's existing script-src 'self' policy, and participates in the
website's dependency/license audit. No CDN or other third-party runtime request
is permitted. Website JavaScript notices are generated separately as
WEBSITE_THIRD_PARTY_LICENSES.txt from the exact bundled npm closure and linked
from /flash; they MUST NOT be misrepresented as dependencies embedded in the
firmware image.
RELEASE_NOTES.md MUST state the release date, supported profile IDs and exact
memory requirements, agent/PBLE/1/MicroPython/ESP-IDF versions and commits,
source/tag links, known limitations, destructive-install warning, upgrade
notes, recovery link, and support contact. It MUST explicitly state that the
S3 image is N16R8-class only.
The /flash document remains complete without JavaScript. The install control
is a client-only enhancement and MUST be activated by feature detection:
- a secure context;
navigator.serial;- Web Crypto
subtle.digest; and - successful verification under §5.
The implementation MUST NOT infer support from the browser user-agent. Its unsupported state names a current desktop Chromium browser as the supported path and explicitly states that iPadOS cannot perform the wired provisioning step. The exact ESP Web Tools package is bundled locally; its activation button is exposed only after profile selection, verification, and consent.
Before activation the user MUST affirm all of the following:
- the exact chip/flash/PSRAM profile matches the connected module;
- board files and any previous firmware have been backed up;
- installation erases the device;
- a data-capable USB cable and stable power are in use; and
- other serial monitors and applications using the port are closed.
The page MUST show the selected profile, firmware version, release date, artifact verification result, destructive effect, and link to version-matched recovery instructions adjacent to the button. It MUST never offer one profile as a fallback after another family fails.
The versioned RECOVERY.md and the visible /flash recovery section MUST
cover, in plain language:
- what is erased and how to back up user files before installing;
- normal USB connection, data-cable, stable-power, and serial-port selection;
- automatic reset plus the manual BOOT/RESET sequence, with a warning that button labels vary by board;
- safe retry after permission denial, disconnect, timeout, interrupted erase, interrupted write, verification failure, or a board that no longer boots;
- closing serial monitors, reconnecting USB, manually entering the ROM bootloader, and retrying the same verified profile;
- the exact version-matched
esptoolcommand for the merged image and base offset, plus the component offsets as an advanced diagnostic/recovery alternative using the same bundle bytes; - post-flash hard reset/power cycle, expected
PyBLE-XXXXBLE advertisement, and first connection from the app; - symptoms of a wrong memory profile, an instruction to stop rather than try random images, and the support route/contact; and
- the diagnostic fields safe to share: release version, profile ID, board model/module marking, browser/OS versions, stage and error text, while excluding secrets and personal device labels.
A recovery instruction that has not itself been exercised on both current release profiles does not satisfy the release gate. The page MUST NOT offer a C3 recovery command or binary while that profile is deferred.
For the pinned ESP-IDF 5.5 tool environment (esptool.py v4.12.dev3), every
generated and visible merged-image command MUST use the executable module form
python -m esptool --chip <chip> write_flash <offset> <image> with the
underscore subcommand write_flash. The unsupported hyphenated spelling
write-flash MUST NOT appear in a generated recovery document or the visible
installer recovery section. An automated gate MUST exercise the selected
subcommand against the pinned tool's command-line parser; rendering command-like
text without proving that the pinned parser accepts it is insufficient.
Automated release tests MUST cover:
- clean three-target build from one candidate-frozen pin set and commit;
- byte-for-byte second clean build;
flasher_args.json→ profile component-offset and merge-setting agreement;- merged-image reproduction, ESP image target/role parsing, component placement, partition-table parsing, range/non-overlap, application fit, and flash-capacity checks;
- exact manifest schema, paths, profile parity, and forbidden redirect/origin cases;
- exact two-profile resource-policy and HIL V2 schema, baseline/policy/candidate hash binding, derivation arithmetic, threshold-boundary and one-unit-crossing fixtures, and rejection of any current C3 policy entry or HIL row;
- release-schema validation, SHA-256/size verification, corrupt/truncated/ missing/swapped-part fixtures, and metadata/manifest disagreement;
- mechanically complete third-party license output;
- website feature-detection, unsupported/insecure/iPad copy, consent, fail- closed integrity states, keyboard accessibility, and no third-party request; and
- static-export and candidate/production-origin retrieval of every versioned byte.
One HIL record MUST be completed for each of the two exact current release
profiles using the final, hash-locked release candidate. The report contains
exactly one embedded JSON object marked PYBLE_HIL_RECORDS_V2; a V1 marker,
an additional marker, or keys not defined below are invalid.
<!-- PYBLE_HIL_RECORDS_V2
{ ...one JSON object... }
-->
Candidate generation MUST read
firmware/qualification/oi1-gates.json, verify it against the frozen
firmware requirements §5.3, and embed
both its parsed JSON object and the lowercase SHA-256 of its exact source
bytes. The policy object has exactly these keys:
| Key | Exact value/type |
|---|---|
schema_version |
integer 1 |
qualification_scope |
string "pre-v1" |
profile_order |
exact string array ["esp32-4mb", "esp32-s3-n16r8"] |
deferred_profiles |
exact string array ["esp32-c3-4mb"] |
workload |
exact object defined below |
derivation |
exact object defined below |
baseline_evidence |
exact object {path, sha256} |
profiles |
two policy-entry objects, in profile_order |
workload has exactly these integer/string keys and values:
{
"reset_samples": 10,
"reset_hold_ms": 1000,
"advertising_timeout_ms": 15000,
"post_hello_heap_samples": 10,
"roundtrip_samples": 5,
"roundtrip_payload_bytes": 65536,
"payload_generator": "sha256-counter-v1",
"post_roundtrip_heap_samples": 5,
"reliability_files": 20,
"reliability_file_bytes": 16384,
"post_reliability_heap_samples": 1,
"required_att_mtu": 247,
"required_put_window": 8,
"required_chunk_bytes": 229
}derivation has exactly these string keys and values:
{
"application_image": "exact-byte-identical-two-root-v1",
"application_headroom": "factory-minus-application-v1",
"heap_floor": "floor-min-1024-v1",
"boot_ceiling": "ceil-max-10-v1",
"goodput_floor": "floor-min-100-v1"
}baseline_evidence.path MUST match
docs/validation/firmware/oi1/<40-lowercase-hex-source-commit>.json;
baseline_evidence.sha256 MUST be the lowercase 64-hex SHA-256 of that exact
canonical, redacted file. A profile policy entry has exactly
profile_id, target, and thresholds. Targets are esp32 then esp32-s3.
thresholds has exactly these positive integer keys:
application_image_max_bytes
application_headroom_min_bytes
gc_free_min_bytes
idf_internal_free_min_bytes
idf_internal_largest_block_min_bytes
idf_internal_minimum_free_min_bytes
reset_to_service_advertisement_max_ms
put_committed_goodput_min_bytes_per_second
get_verified_goodput_min_bytes_per_second
Booleans MUST be rejected anywhere an integer is required. The policy MUST have no C3 policy entry, and neither the baseline nor a source/build/license result may be interpreted as C3 HIL qualification.
The top-level object has exactly:
schema_version
candidate_release_json_sha256
qualification_policy_sha256
qualification_policy
records
schema_version is integer 2. qualification_policy_sha256 is the
lowercase 64-hex digest of the exact committed policy bytes, and
qualification_policy is the byte-source's parsed object from §9.1.
records contains exactly two objects in policy profile_order. A pending
candidate uses an empty candidate_release_json_sha256; the completed report
MUST replace it with the lowercase 64-hex SHA-256 of the exact release.json
selected by the protected site during HIL.
Each record has exactly these keys:
profile_id
status
board_manufacturer
board_model
module_marking
device_flash_capacity_bytes
device_psram_capacity_bytes
firmware_version
tag
source_commit
manifest_sha256
firmware_sha256
tested_at
operator
maintainer_signoff
desktop_os
chromium_version
ble_backend
ble_adapter
python_version
checks
oi1_policy
oi1_build
oi1_observation
redacted_console_log
device_flash_capacity_bytes and device_psram_capacity_bytes are observed
physical module capacities. They replace V1's ambiguous observed_* names and
MUST NOT be used as application footprint. Identity, hash, UTC, operator, and
non-empty public metadata constraints remain as in the prior contract.
oi1_policy MUST equal the matching object from
qualification_policy.profiles. oi1_build is generated before HIL and has
exactly:
application_image_bytes
factory_partition_bytes
application_headroom_bytes
All three values are non-negative integers. The validator MUST compare
application_image_bytes to the exact bundled application.bin, parse
factory_partition_bytes from the bundled partition table, and recompute
application_headroom_bytes by subtraction. These values and the policy are
immutable across candidate finalization.
In a pending candidate, status is pending, both physical-capacity values
are integer zero, all operator/board/time/environment strings are empty, and
oi1_observation is null. In a completed public record, status is passed;
physical capacities equal the selected profile's frozen flash/PSRAM topology;
tested_at is UTC RFC3339; all board, operator, sign-off, OS/browser,
BLE-backend/adapter, Python-version, and redacted-log strings are non-empty;
the firmware/tag/source identity equals release identity; and manifest and
firmware digests equal the selected profile artifacts.
checks keeps exactly these keys:
browser_erase_install
family_offsets_reset
advertising_info_hello
app_workflow
neopixel_reboot
footprint_reliability
interrupted_flash_recovery
In a candidate every value is pending. In a public report every value is
passed; footprint_reliability MUST be set by the validator only after the
V2 observations pass, not accepted as independent operator testimony.
The release tool MUST provide an assemble-hil-report operation so completing
this contract never requires hand-editing HIL_REPORT.md. It accepts one
immutable pending candidate, exactly two JSON completion fragments (one per
profile, in either input order), the canonical qualification checkout, and one
new no-replace output path. A completion fragment contains only the mutable
profile ID, physical board descriptions/capacities, UTC test time,
operator/sign-off and environment strings, the six operator-demonstration
checks other than footprint_reliability, one completed oi1_observation, and
the redacted console log. It MUST NOT accept status, release identity, artifact
hashes, policy, or build measurements from an operator fragment.
The assembler MUST validate the pending candidate first, compute the exact
candidate release.json SHA-256 itself, copy every candidate-frozen field from
the embedded pending records, require all six supplied checks to be passed,
validate the observation and every profile threshold, and only then insert
footprint_reliability: passed and status: passed. It MUST render exactly one
canonical PYBLE_HIL_RECORDS_V2 marker, validate the completed payload against
the candidate bytes and committed policy, and write the output atomically. It
never mutates the candidate and does not perform public bundle promotion;
finalize-public remains the only promotion step.
oi1_observation is JSON null in a pending candidate. In a completed report
it is an object with exactly:
observed_att_mtu
observed_window
observed_chunk_bytes
reset_to_service_advertisement_ms
heap_default_free_post_hello_bytes
heap_post_hello
put_unique_committed_bytes
put_duration_ns
put_committed_goodput_bytes_per_second
get_unique_verified_bytes
get_duration_ns
get_verified_goodput_bytes_per_second
put_retransmitted_chunks
put_retransmitted_bytes
get_retransmitted_chunks
get_retransmitted_bytes
roundtrip_integrity_verified
get_offset_sequences_validated
roundtrip_unexpected_disconnects
roundtrip_integrity_failures
heap_post_roundtrip
reliability
heap_post_reliability
physical_power_cycle_advertising
raw_log_sha256
The three observed transport values MUST be 247, 8, and 229. Reset and
post-HELLO arrays have length 10. Every PUT/GET, retransmit, post-round-trip,
and duration array has length 5. Unique committed/verified byte entries are
each 65,536. Durations are positive monotonic nanoseconds; each reported
goodput integer MUST equal floor(65536 * 10^9 / duration_ns).
roundtrip_integrity_verified and get_offset_sequences_validated are integer
5; both round-trip failure counts are integer 0.
physical_power_cycle_advertising is string passed.
raw_log_sha256 is the lowercase 64-hex digest of the access-controlled,
retained, redacted raw HIL log.
Every heap snapshot object has exactly:
gc_free_bytes
gc_allocated_bytes
idf_internal_free_bytes
idf_internal_largest_block_bytes
idf_internal_minimum_free_bytes
All values are non-negative integers. heap_post_hello has 10 snapshots,
heap_post_roundtrip has 5, and heap_post_reliability is one snapshot.
For each gated heap key, the validator concatenates those 16 observations and
compares the minimum with the matching policy floor. The 10
heap_default_free_post_hello_bytes values are diagnostic only.
reliability has exactly these non-negative integer keys:
attempted_files
completed_files
verified_files
bytes_per_file
total_payload_bytes
unexpected_disconnects
integrity_failures
failed_statuses
retransmitted_chunks
retransmitted_bytes
rewinds
The first three values MUST all be 20, bytes_per_file MUST be 16,384,
total_payload_bytes MUST be 327,680, and disconnect/integrity/status-failure
counts MUST be zero. Retransmit and rewind counts are reported but are not
required to be zero.
Besides the machine observations above, each completed record demonstrates:
- full-chip erase and install from an access-controlled, production-equivalent
HTTPS candidate
/flashdeployment containing the final static site code, manifest, and firmware binaries; - expected family detection, offsets, completion, and hard reset;
- cold-boot
PyBLE-XXXXadvertising and version-matched INFO/HELLO; - app scan/connect plus edit, save, run, live console, STOP, soft reboot, and board-file round-trip;
from neopixel import NeoPixelbefore and after soft reboot;- every V2 application, heap, boot, transfer-reliability, and goodput gate for the profile; and
- a deliberately interrupted browser flash followed by successful recovery using the published instructions and the same profile.
Simulation, a build-only result, a prior binary, or one ESP32-family board standing in for another does not count. The maintainer signs off each row; the maintainer may also be the HIL operator.
The validator MUST recompute and enforce:
- application-image/headroom arithmetic and the two static bounds;
- exact array/sample counts and workload constants;
- minimum of all 16 samples for each gated heap metric;
- maximum of the 10 reset-to-advertisement samples;
- every goodput value from its duration and the minimum of each five-sample direction;
- strict GET offset/byte/size/CRC integrity and exact reliability totals; and
- exact profile, policy, baseline, release, manifest, firmware, and source hash binding.
Missing or extra keys, wrong order/profile/unit/type/count, a bool accepted as an integer, a manufactured MTU fallback, a C3 policy entry or record, an identity/hash mismatch, or any threshold crossing MUST fail finalization. A changed firmware, manifest, policy, or candidate identity resets the affected record to untested. A changed baseline-evidence file requires a newly derived policy and a new candidate.
Candidate-to-public promotion is a copy-on-write administrative operation. It MUST create a new output directory atomically and MUST leave the protected candidate unchanged. Exactly these changes are permitted:
- replace the pending
HIL_REPORT.mdwith the completed report described above; - change only each
release.jsonprofilehil_statusfrompendingtopassedand update only thedocuments.hil_reportsize and SHA-256 record; and - regenerate
SHA256SUMS, where only theHIL_REPORT.mdandrelease.jsonentry digests may differ from the candidate.
Every other byte MUST remain identical, including THIRD_PARTY_LICENSES.txt,
RELEASE_NOTES.md, RECOVERY.md, release.schema.json, every manifest, and
every firmware/component image. Finalization MUST revalidate the completed
tree as public and compare every immutable path with the candidate. Within the
V2 payload, only the candidate release digest, record status, physical-board
capacities and descriptions, time/operator/sign-off/environment strings,
checks, oi1_observation, and redacted console log may change. The profile
identity/hashes, qualification_policy_sha256, parsed policy, oi1_policy,
and oi1_build MUST remain exactly equal. Publish nothing on any failure. A
different byte or semantic field outside this envelope is a new candidate and
requires the complete two-profile HIL matrix again.
The qualified public action progresses through candidate → verified →
published → active. The public pyble.dev/flash action remains disabled
while candidate HIL runs on the access-controlled production-equivalent HTTPS
deployment. It
is valid for that protected candidate deployment alone to expose the action
with hil_status: pending after every non-HIL automated/integrity gate is
green; the candidate-mode selection MUST be build-time explicit, inaccessible
from the public deployment, and covered by a test that public builds reject
pending. Automated tests MUST start from the disabled public selection and
opt in explicitly when a case exercises candidate staging; inherited candidate
selector or staged-root environment variables MUST NOT reclassify public-page
or no-firmware Sites fixtures. The test runner MUST exercise this isolation
even when the surrounding release build exports candidate variables. The
public action may become active only when every automated gate is green,
both current-release HIL rows say passed, the maintainer approves the exact
hashes, and the canonical same-origin bytes pass publication verification. If a
pre-v1 mirror exists, its files and bytes MUST agree before activation; v1.0 and
later require that mirror to be the matching GitHub Release. A C3 row MUST be
absent, not marked passed without evidence. The activation deployment then
performs a non-destructive production-origin retrieval, redirect, size,
SHA-256, CSP, and render smoke test; it does not require another physical flash
after the public button is enabled.
Any missing or stale condition leaves the action unavailable with an accurate status; availability MUST never be inferred from the mere presence of a manifest. Rollback changes the website's selected-release descriptor to a previous fully qualified immutable bundle and redeploys the site. It never mutates or partially replaces the active version directory.
Once a public release is active, later website-only deployments MUST carry its
exact selector and immutable firmware tree forward
through authenticated retrieval and the canonical staged-release validation
path. Each website release with an active installer retains an unserved
canonical selector marker for this purpose. The preserved-public validator
MUST repeat every self-contained public bundle, schema, HIL, profile, artifact,
path, size, digest, descriptor, and annotated-tag check. It MUST prove exact
selected-byte continuity and MUST NOT accept a different version or byte. It
does not repeat the source/build license audit for a fully qualified release
whose passing evidence was required for the original activation of those same
immutable bytes. A preserved v0.4.2 public beta MUST instead repeat canonical
--audited-candidate validation with the retained license-evidence directory
and exact release-build root, using the exact firmware-source checkout recorded
by the release as --repo-root, and MUST revalidate the annotated
firmware-v0.4.2 tag. A
deployment MUST fail before the build if that state cannot be retrieved or
validated. Transitioning an active public installer to unavailable is a
separate reviewed operation requiring an explicit truth-valued disable flag
and a production smoke test of the disabled state; absence of staging input
alone is never authorization to disable it.
As a one-time transitional exception, the fresh audited v0.4.2 candidate MAY
be published as a hardware-tested public beta. The selector deployment
mode MUST be public-beta, accessControlled MUST be false, both profile HIL
states and the aggregate hilStatus MUST remain pending, and the
release.json SHA-256 MUST equal
5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde.
The exact profile set is esp32-4mb plus esp32-s3-n16r8; C3 MUST remain
absent. Before staging or carrying the beta forward, the canonical release tool
MUST accept the exact bundle with validate --audited-candidate, its retained
license-evidence directory, its exact release-build root, and the exact
firmware-source checkout recorded by the release as --repo-root. The annotated
firmware-v0.4.2 tag MUST exist and peel directly to the full PyBLE provenance
commit recorded in release.json, and deployment MUST bind the tag object
before and after the website build and before upload. The exact production
browser-flashing validation recorded in
docs/validation/browser-flashing/v0.4.2-production.json supports the narrower
claim that real-board Chrome installation, interruption, recovery, and reset
passed for both enabled exact profiles. Before the install control appears, the
website MUST state that completed scope and MUST separately say that complete
release qualification remains pending. It MUST NOT call the beta
access-controlled, protected, a qualified release, fully validated,
production-ready, or generally available.
The beta path MUST retain all existing audited-candidate, license, tag, schema, checksum, manifest, image, same-origin, browser-capability, profile-confirmation, consent, recovery, and production-smoke checks. The exception changes only publication policy; it does not allow byte mutation, substitute evidence, a different version/digest, or a new profile. A later qualified public release therefore starts at a new immutable version and still requires the complete gate above.