ADR 0017 — Blocks uses a verified sidecar for exact reopen and an all-or-nothing bounded Python importer
Status: Accepted (2026-07-28). Extends ADR-0013 (the acknowledged Blocks snapshot and file-backed action boundary), ADR-0015 (generic digital GPIO), and ADR-0016 (atomic candidate preview/copy/replace). It supersedes only ADR-0013's blanket statement that PyBLE never reconstructs Blocks from Python and its corresponding rejected alternative. Arbitrary or live bidirectional Python ↔ Blocks synchronization remains rejected. This decision changes no PBLE/1, firmware, BLE, or generic-board contract.
Generated Python is intentionally inspectable and editable. A Python file alone, however, cannot preserve visual information such as block IDs and coordinates, comments, collapsed/disabled state, mutation data, variable/procedure identity, or the particular block graph chosen for an equivalent expression. Re-parsing that file therefore cannot provide an exact Blocks round trip.
There are two different user needs:
- A program that originated in Blocks should reopen as the same visual workspace. This needs the original Blockly serialization, bound to the exact generated Python that the user sees and uploads.
- A beginner with a small handwritten MicroPython program should be able to make a new editable Blocks workspace when every construct is inside an explicitly published subset. This conversion may normalize formatting and layout, but it must never omit an unsupported statement, hide it in a raw-code escape block, or imply that arbitrary Python is convertible.
Both flows operate on untrusted user files. They must be offline, bounded, diagnostic, non-executing, and atomic with respect to the active workspace. A damaged or stale companion file must never replace a valid Python document or a live Blocks workspace.
A Blocks-origin program is an integrity-checked Python/sidecar pair. A Python-origin conversion is a separate, strict, all-or-nothing import that creates an ordinary workspace only after preview and explicit confirmation.
- During normal visual editing, the active Blockly workspace remains
authoritative and publishes ADR-0013's immutable
{source, workspaceJson, revision}snapshots. - A valid sidecar is an exact reopen record, not a second editable source. It is trusted only while it proves that its workspace and embedded generated source still describe the adjacent Python file exactly.
- During Python import, the captured immutable editor document is authoritative
input. Its content is converted and
boardPathForDocument(capturedDocument)becomes the candidate Blocks source target. The candidate workspace is a new copy. After an acknowledged Create/Replace, that workspace and target become authoritative for subsequent Blocks edits and generation; later Python edits do not live-sync back into it. - PyBLE never attempts merge, live two-way synchronization, or source-to-source patching between a Python buffer and a Blocks workspace.
For the active normalized absolute user-workspace Python target P ending in
.py, its companion path is exactly:
S = P + ".pyble-blocks.json"
The default target for a hand-built or example-created Blocks workspace remains
/blocks.py, so its pair is:
/blocks.py
/blocks.py.pyble-blocks.json
Both paths remain inside the board's advertised user fs_root, and each full
UTF-8 path is at most PBLE/1's 128-byte path ceiling. The app derives and
preflights both paths before any putFile; if appending the suffix makes S
too long, neither file is written. Traversal, control-plane paths, non-.py
source paths, and a sidecar opened without its adjacent source are rejected. A
future project-backed implementation mirrors the same POSIX project paths in
project_files; it does not invent a different local naming rule.
The UTF-8 sidecar is at most 1 MiB and has this version-1 shape:
{
"format": "pyble-blocks",
"version": 1,
"source": {
"path": "/blocks.py",
"encoding": "utf-8",
"byteLength": 0,
"crc32": "00000000",
"text": ""
},
"generator": {
"id": "pyble-blockly-python",
"version": 1,
"blockly": "13.1.0"
},
"workspace": {
"blocks": {
"languageVersion": 0,
"blocks": []
}
}
}source.text is the exact generated text whose UTF-8 bytes are uploaded.
byteLength is the exact byte count. crc32 is eight lowercase hexadecimal
digits containing IEEE/zlib CRC-32 over those bytes (reflected polynomial
0xEDB88320, initial/final XOR 0xFFFFFFFF). It is a deterministic
accidental-corruption and torn-pair fingerprint, not authentication,
authorization, or a cryptographic security claim. Exact byte/text comparison,
not CRC alone, is required.
workspace is the ordinary Blockly JSON object from the same acknowledged
snapshot. The envelope contains no timestamp, board/chip identity, connection
data, selected-device pin profile, PBLE/1 value, or executable callback.
generator.version advances whenever PyBLE changes a mapping in a way that can
change generated source or import semantics. blockly records the pinned
upstream serialization/generator compatibility point. An unknown
format/envelope/generator/Blockly version is a localized incompatibility
diagnostic; v1 never guesses or silently migrates it. The adjacent .py remains
openable as ordinary text.
Blocks Save and Run each request and freeze one fresh active-host snapshot under
ADR-0013's existing action lock. The native layer builds both files from that
one snapshot and its active target P, preflights both full UTF-8 paths against
the 128-byte PBLE/1 ceiling, and captures the connection facade's current local
session stamp before that preflight and the first upload. It then performs:
putFile(P, exact source bytes) # CRC-verified by the existing transfer
putFile(S, matching sidecar bytes) # written last: commits the pair
[Run only] runFile(P)
The sidecar is never written before the Python upload succeeds. Save reports
success only after both verified uploads. Run never calls runFile(P) unless
both uploads succeed. Immediately before the sidecar upload and again before
Run, the coordinator requires the captured session stamp to remain current.
Every facade attach or detach advances that opaque stamp, including a rapid
disconnect/reconnect to the same board. A change produces a typed refusal and
the next write or Run is never dispatched through the facade, preventing one
bundle action from crossing boards. This is only an in-memory action-consistency
stamp: it is not a board identifier, PBLE/1 field, pairing/authentication token,
or value persisted in the sidecar.
If the source succeeds but the sidecar fails, the action reports an incomplete-pair error that honestly says the Python may have changed; an older sidecar is now stale and will fail the exact checks below. PBLE/1 has no multi-file atomic rename, so the sidecar-last record plus exact validation is the defined recovery mechanism rather than a false atomicity claim.
The text editor does not silently rewrite or delete a Blocks sidecar when the
user edits/saves Python. Such an edit simply invalidates the pair by exact source
mismatch. A later explicit Blocks Save may publish a new valid pair. The sidecar
is plain data, is never passed to runFile, and does not create another run
path.
A workspace created from Convert Python to Blocks adopts
boardPathForDocument(capturedDocument) as P; it does not unexpectedly fall
back to /blocks.py. A workspace opened from a valid sidecar adopts the
sidecar-bound source path. The default /blocks.py applies only when no
explicit Python origin has supplied a target. Target adoption is part of the
same candidate commit/rollback and is shown in Preview.
An explicit Open as Blocks operation for P may read P and S through the
existing file-open path. Before offering a restorable candidate, it must:
- validate UTF-8, total size, required fields/types, exact format/version, canonical source/sidecar paths, both 128-byte path bounds, encoding, byte-length bounds, lowercase CRC shape, and supported generator/Blockly versions;
- require the adjacent Python bytes to equal
source.textencoded as UTF-8, with the recorded length and CRC-32 all matching; - load
workspaceinto a disposable scratch Blockly workspace, serialize it again, and require deep JSON equality with the stored object (object key order and insignificant JSON whitespace are not workspace state); and - run the pinned production generator in that scratch workspace and require
its complete generated source to equal
source.textbyte-for-byte.
Only all four checks establish an exact candidate. This preserves block IDs,
top-level order, coordinates, fields, comments, extraState/mutation data,
variables/procedures, disabled/collapsed state, and every other field retained
by the supported Blockly serializer. A missing, malformed, oversized,
unknown-version, path-mismatched, source-mismatched, lossy-reserialization, or
generation-mismatched sidecar produces localized diagnostics and does not
mutate the editor or active workspace. The safe choices remain Open Python
and, when applicable, the bounded Convert Python to Blocks flow below.
An exact candidate uses the same structural-empty Create workspace versus
confirmed non-empty Replace workspace decision and rollback/active-host
acknowledgement boundary as ADR-0016. A restore/generation/host failure restores
the prior JSON/program/revision/source target and never announces success first.
Only the acknowledged commit adopts P as the active Blocks target.
The importer accepts UTF-8 Python text only, fully offline. Input is bounded to
256 KiB, 4,096 physical lines, 20,000 syntax nodes, and 32 indentation levels.
Indentation is spaces-only; semicolon-separated statements are not admitted.
Identifiers are ASCII Python identifiers, may not be keywords or generator
reserved names, and integers used as Blockly numeric literals must be within
JavaScript's exact safe-integer range [-9007199254740991, 9007199254740991].
Decimal float syntax whose finite value is integral (for example 1.0 or
1e3) is rejected because Blockly's ordinary number field would regenerate it
as integer syntax and change observable Python type/printing. Raw U+0000 is not
admitted in a string because the pinned generator cannot emit it as valid
Python source.
Leading imports, when required by a used construct, are accepted only in these exact unaliased forms and at most once:
from machine import Pin
from time import sleep_msAn unused, missing, duplicate, aliased, reordered-among-executable-code, or different import is an error because the produced workspace could not preserve it exactly.
The admitted statement grammar is:
- assignment
name = expression; - numeric change
name += expression; print(expression)with exactly one positional argument;sleep_ms(N)whereNis a non-negative decimal integer literal;pin.value(0)/pin.value(1)wherepinis a simple identifier bound to a supportedPinconstructor;ifwith zero or moreelifbranches and an optionalelse;while condition;for name in range(...)under the literal bounds below;- a call to a supported user function as a statement;
- top-level
def name(parameters): ...under the function bounds below; and passonly as the sole statement of a suite that Blockly can represent as an empty statement input.
Expressions are bounded to:
- finite decimal integer literals and finite non-integral decimal float
literals, ordinary single- or double-quoted strings without raw U+0000 and
with supported escapes,
True,False,None, and simple names; - parentheses; unary
not, unary+, and unary-; - arithmetic
+,-,*,/,%, and**; - Boolean
and/or; - one comparison
==,!=,<,<=,>, or>=(chained comparisons are not admitted); - a supported user-function call with positional arguments only;
Pin(GPIO, Pin.IN|Pin.OUT[, None|Pin.PULL_UP|Pin.PULL_DOWN]), whereGPIOis an explicit non-negative decimal integer literal and no board/chip default or validity lookup is performed; andpin.value()as a value expression.
range accepts one, two, or three decimal integer literals. Its step is
non-zero; the range must be non-empty; a positive step requires start < stop
and a negative step requires start > stop; all values and the adjusted
inclusive Blockly endpoint remain in the exact safe-integer range. This permits
an exact standard controls_for mapping (stop - 1 for a positive step,
stop + 1 for a negative step) without changing Python's exclusive-stop
semantics. Dynamic, empty, or direction-inconsistent ranges are diagnosed
rather than approximated.
At most 16 top-level functions with at most eight unique positional parameters
each are admitted. Definitions precede executable module statements. There are
no decorators, annotations, defaults, keyword/variadic parameters, nested
functions, closures, global/nonlocal access, or recursive call cycles. A
function body may use its parameters and literals, but may not assign/read
free or local variables; this avoids Blockly procedure generation introducing
different global semantics. A function has either no return or exactly one
final return expression; calls to returning functions are used as expressions
and calls to non-returning functions as statements, with exact arity.
Everything not listed is unsupported, including comments/docstrings, multiline
or prefixed strings, collection literals, subscripts/slices, comprehensions,
attribute access other than the admitted Pin constants/.value, multiple
assignment, del, break, continue, for over collections, match, classes,
lambda, yield, async/await, exceptions, context managers, dynamic imports,
I/O other than the admitted print, metaprogramming, and arbitrary calls.
The importer is a fresh MIT tokenizer/parser and typed subset model; it does not
execute Python, use eval, start a Python interpreter, fetch a parser or grammar
from the network, or introduce a raw-Python Blockly escape block. Its pipeline
is:
captured editor document content
→ bounded tokenize/parse + name/import/control-flow validation
→ complete typed subset tree or diagnostics (never a partial tree)
→ ordinary Blockly JSON with fresh IDs and deterministic top-level layout
→ disposable scratch-workspace restore
→ pinned production Python generation
→ reparse generated Python to the same normalized subset model
→ semantic-model equality
→ immutable {input fingerprint, workspaceJson, generatedSource, warnings}
Any syntax, unsupported construct, semantic, resource-limit, scratch restore,
generator, or semantic-model comparison error yields no candidate.
Recognized statements are never kept while unrecognized statements are dropped,
commented out, converted to disabled blocks, or hidden in opaque metadata.
Formatting, quotes, redundant parentheses, blank lines, and deterministic block
layout may normalize; those non-semantic changes are disclosed and the complete
generated Python is selectable in Preview before commit.
The normalized model treats numeric name += value as
name = name + value and an omitted third Pin argument as explicit None,
because those are the ordinary workspace forms emitted by the production
generator. Floor division is not in v1 because the current toolbox has no block
that reproduces it without an opaque helper.
The captured input is bound by the complete immutable editor document, including
name/content/boardPath, its resolved target path, exact text, UTF-8 byte
length, and CRC-32. Before commit the controller compares the current editor
document exactly. If its content, identity, name, or bound path changed while
Preview was open, the candidate is stale and Create/Replace stays disabled until
the user explicitly refreshes conversion.
Every diagnostic has stable, unlocalized technical data:
{code, severity, startLine, startColumn, endLine, endColumn, messageKey, args}
Positions are one-based Unicode-scalar columns with an end-exclusive range.
code and source excerpts remain technical text; the visible message is
ARB-sourced from messageKey and typed arguments. Errors block a candidate.
Warnings may describe formatting/layout normalization but never conceal lost
behavior. Raw parser exceptions, JavaScript stacks, and English-only fallback
messages are not shown to users.
The editor exposes an explicit Convert Python to Blocks action. A valid paired file also exposes Open as Blocks. Both open one shared adaptive preview surface showing the captured source identity, a navigable diagnostic list with line/column context, the exact source/companion target paths, and—only for a valid candidate—the complete selectable generated Python. At widths below 600 dp it is a scroll-controlled modal bottom sheet; at 600 dp and wider it is a dialog. It scrolls under large text and keyboard insets.
If the active workspace is structurally empty, the valid candidate offers Create workspace. Otherwise it offers Replace workspace, with the same localized confirmation, exact rollback, and active-host acknowledgement used by example replacement. Cancel and every failure are non-mutating. Successful commit may navigate to Blocks and announces completion once, only after the active host accepts the candidate snapshot.
Rows, source, diagnostics, and actions are keyboard/switch accessible, expose name/role/enabled/error state, meet the 48 dp target rule, preserve focus, and move focus to the first error on a failed conversion. Screen readers receive the error count and one completion announcement, not repeated announcements while diagnostics rebuild.
Parsing, sidecar validation after bytes are supplied, Preview, Create, Replace,
and cancellation call no Connection, ProgramActions, Save, Run,
runFile, editor replacement, console, or network API. An explicit File
Explorer open may perform only the existing reads of the selected .py and its
adjacent sidecar; it authorizes no write. Only the pre-existing explicit Blocks
Save/Run actions write the pair, and only explicit Run executes P.
No conversion chooses a GPIO, reads DeviceInfo, validates a physical pin
against a board profile, or performs hidden board probing. Standard
MicroPython remains the runtime authority for actual GPIO support.
The envelope, parser, subset model, mappings, UI, diagnostics, and tests are fresh MIT PyBLE work. They may use public Python/MicroPython language behavior and the pristine pinned Apache-2.0 Blockly API, but copy no source, parser, identifiers, block catalog, curriculum, conversion rules, UI implementation, or pedagogy from a closed or unknown-license product. Any future third-party parser requires a separate dependency/license review and pin before adoption. The no-leak, SPDX, dependency, offline-asset, and locale-parity gates cover the new shipping source and assets.
Red tests precede implementation and cover at least:
- exact target adoption/path derivation and rejection of traversal,
control-plane/non-
.py, or source/companion paths above PBLE/1's 128-byte limit before any PUT; - golden encode/decode of the v1 envelope; UTF-8 byte length and the
123456789 → cbf43926CRC anchor; malformed/oversized input, unknown versions, wrong path, stale/torn source, and unsupported generator; - sidecar-last failure injection: source failure prevents sidecar, sidecar failure prevents success/Run and leaves a detectably stale pair, and Run occurs only after both verified writes;
- exact reopen of IDs, order, coordinates, comments,
extraState, variables, procedures, and disabled/collapsed state; scratch reserialization or generated source mismatch is fail-closed and preserves the active workspace; - tokenizer/parser/precedence fixtures for every admitted form and a diagnostic fixture for every rejected family, including all resource, numeric, range, function, import, and GPIO bounds;
- parse → workspace → production generate → reparse normalized-model equality for each supported construct and composed beginner programs;
- all-or-nothing behavior: one unsupported statement among supported statements yields no workspace/candidate/raw-code placeholder;
- immutable non-mutating Preview, captured document/target display, stale-editor identity/content/path invalidation, empty Create, confirmed/cancelled/failed Replace rollback of workspace and target, and host-acknowledged success;
- zero Connection/Save/Run/editor/console/network calls from validation, conversion, Preview, Create, Replace, and cancel;
- localized diagnostic-key coverage and parity; keyboard, focus, semantics, one-shot announcements, 48 dp targets, keyboard inset, 1×–3× text, compact bottom sheet, wide dialog, and no overflow;
- actual pinned WebView restoration/generation on iPadOS and Android, including a valid sidecar reopen and one composed Python-subset import; and
- no-leak, SPDX, import-boundary, dependency/license, local-assets/CSP, analyze, unit/widget/golden/integration, and packaged-asset gates.
- Infer all Blocks from every Python file. Rejected: Python is more expressive than the toolbox, equivalent source has many possible graphs, and layout/IDs cannot be recovered.
- Store only workspace JSON. Rejected: it would not prove which adjacent Python revision the workspace generated.
- Store only a source fingerprint. Rejected: CRC is not collision-resistant and does not substitute for exact source comparison; the complete committed generated source is intentionally present.
- Treat CRC-32 as a signature. Rejected: the sidecar is user data, not a trust boundary. CRC detects ordinary corruption/torn pairs only.
- Write the sidecar first. Rejected: a new commit record could point at an old Python file. Sidecar-last makes successful companion upload the commit point and every torn ordering detectable.
- Partially convert supported lines. Rejected: omission is code loss even when accompanied by a toast. Unsupported input yields diagnostics and no candidate.
- Use a raw-Python/custom escape block. Rejected: it is opaque, bypasses the visual-language contract, and turns unsupported code into a false success.
- Continuously synchronize editor and Blocks. Rejected: authority, conflict, formatting, and semantics become ambiguous. Both hand-offs are explicit snapshots.
Blocks-origin work can reopen exactly while its Python/sidecar pair remains consistent, and ordinary beginner Python can enter Blocks through a small, testable language rather than a misleading universal converter. Python always remains readable when companion validation fails, and no conversion can silently lose executable behavior.
The costs are a visible companion data file, duplicated generated source in that record, two verified uploads for Blocks Save/Run, strict rejection of many valid Python programs, and a versioned parser/mapping surface that must grow only spec-first. Because PBLE/1 provides no multi-file transaction, a failed sidecar-last upload can leave updated Python beside an old sidecar; exact reopen detects that state and never restores it as valid.