fix/audit-battery-feature #11

Merged
faicel merged 80 commits from fix/audit-battery-feature into dev 2026-09-23 20:52:02 +00:00
Owner
No description provided.
Replace the combined pull_request_target workflow with metadata-only branch
flow validation through the pinned central validator; it receives no secret,
checks out nothing, and executes no PR-head code.

Move all code checks to push-triggered workflows sharing a local reusable
gate (_rust-crate-checks.yml) running inside the pinned ci-rust-embedded
container with an immutable checkout SHA and persist-credentials disabled.
The gate covers rustfmt, embedded check and clippy for thumbv6m-none-eabi,
host tests via ./test.sh, cargo machete, version sync, and duplicate stable
tag rejection; private registry resolution uses inherited secrets.

dev now tags vX.Y.Z-rc from the always-stable Cargo.toml version; main tags
vX.Y.Z and ensures release/X.Y exists without force-updating maintenance
branches. Tag pushes build firmware.bin and publish a Forgejo release asset
with prerelease marking for -rc tags; firmware releases ship a binary and
never run cargo publish.

Add scripts/release-branch-name.sh and scripts/check-version-sync.sh with
host integration tests, protect workflows/scripts via CODEOWNERS, document
the security model, publish flow, hotfix flow, legacy tag naming transition,
and manual administrator follow-ups in .forgejo/README.md.
Add the private Forgejo registry token environment variables to the
firmware build job of publish-on-tag.yml, mirroring _rust-crate-checks.yml,
so dependency resolution succeeds in the clean release job.

Derive release/X.Y in create-tag-on-main.yml through the tested
scripts/release-branch-name.sh instead of an inline regex, keeping the
idempotent never-force-update behavior and making the host tests validate
the logic CI actually runs.
Move IncomingClassification, HandshakeStepKind, HandshakePhase, EventAckPoll,
classify_incoming, and the authenticated wall-clock sample application out of
the transport-bound host helpers into a dedicated pure module with no radio,
delay, or ATECC types. Add map_sigil_event as a testable mapping layer and
interpret_event_ack_frame as the extracted decision core of the EventAck
polling loop.

host keeps its full public surface as a compatibility facade and delegates
frame interpretation to the policy module; firmware behavior is unchanged.

Migrate the previously unreachable inline host tests into the real host suite
under src/comms/sigil/tests/, removing the duplicated test attribute hidden
there, and add exhaustive coverage for every SigilEvent variant mapping
(all three handshake families across all four phases), the EventAck-wait
interpretation matrix, garbage-input rejection, and time-sample application.
Introduce the pure gateway_policy module under features/events: it decides
the action for a decrypted gateway payload (authenticated unpair command
0x7F 0x01 versus ordinary reachability proof) and retains the enrolled peer
MAC on session expiry so dispatch keeps choosing Renew/Resume over Install.

main.rs now only applies returned actions; helpers/lora keeps SX1278 boot
and transport support only. Authenticated unpair behavior is unchanged.

Add host tests covering the exact unpair wire contract, malformed payloads,
decoder/decision alignment, and never-overwrite peer retention.
Drop the now-empty policy wrapper (handler_data, on_session_expired,
DataHandleResult): gateway command and session-expiry decisions live in the
pure events gateway_policy module consumed by main.rs.
HandshakeController now owns the entire host lifecycle of one attempt:
family, bound peer, host phase, poll budget, cached-Final retransmission
counter, and typed terminal outcome. Transitions are guarded and return
CoordinatorViolation instead of silently overwriting state, making invalid
combinations unrepresentable; timeout actions are unchanged (cancel
pre-commit, cached retransmit, explicit abandonment after exactly five).

HostHandshakeKind moves to the coordinator module with a facade re-export
from the dispatch policy, the private duplicate family enum in the transport
adapter is removed, and wire-step matching uses one shared conversion.

The RTIC install task switches on coordinator actions and records completion
through the guarded API; unreachable violation paths map to the same
communication error as before and all Sigil timestamps stay monotonic.

Add table-driven tests for Install/Renew/Resume completion, Challenge-to-
Final progression, pre-commit cancellation, exactly five retransmissions,
explicit abandonment, post-terminal refusal, and guard rejection.
The Install scenario now captures the discovery binding at start and
asserts the peer changes to the authenticated gateway exactly when the
Challenge is verified, stays stable pre-commit, and survives through the
terminal outcome, making the rebinding coverage regression-proof.
Introduce comms::sigil::radio_ownership: a host-testable state machine with
Idle, Handshake, ReliableEvent, and reserved TimeSyncWindow owners and
guarded transitions that reject double acquisition and invalid release.

Migrate every consumer of the former handshake_in_progress /
event_worker_active booleans to the single typed state: EIC DIO0 pre-filter,
process_lora_message dispatch guard, install start/poll/completion and its
worker wait, renewal spawn guards, the reliable-event worker (entry,
handshake yield, all release paths), product unpair waits/reset, the
TimeSync timeout, and both LIS2DH12 I2C deferral sites.

Preserve SERCOM0 arbitration semantics: only the Handshake owner blocks
accelerometer sampling; no lock is held across Mono::delay().await.
Hardening: duplicate worker spawns now exit through strict double-acquire
rejection instead of racing. TimeSyncWindow stays non-exclusive in firmware
(transitions implemented and tested for future exclusive use).
Restore historical behavior: a worker spawned while an Install/Renew
attempt owns the radio must wait for it to finish and then process its
queued event. The previous strict double-acquire at worker entry exited
immediately in that case, dropping delivery until the next spawn.

Add the guarded enter_reliable_event transition: Idle acquires, an already-
ReliableEvent owner reports Duplicate (exit immediately), Handshake or the
reserved window returns DoubleAcquire so the caller yields without holding
locks and retries until idle. Cover duplicate rejection, defer-then-acquire,
and idle acquisition with host tests.
Introduce comms::sigil::event_delivery: the planner owns worker readiness,
priority dequeue with attempt counting at selection, EventAck-window poll
branching, and the final disposition (delivered, unpair handover, failed
with requeue-or-drop). The RTIC worker now only performs effects and
schedules polls.

Preserve the product policy exactly: one transmission attempt with a fresh
AEAD encryption per attempt over plaintext intents, the 10-second ACK
window, TimeSync acceptance during the wait, queue priority semantics, and
queue-full metrics. Historical queue quirks are kept verbatim: an alarm
evicts from a full queue and a peerless pass consumes the dequeued head.

Add thirteen delivery-policy tests covering inactive session, readiness
conditions, priority order, send-start failure, matching/mismatched ACK,
TimeSync during wait, unpair handover, timeout drop, full-queue drop, and
fresh-encryption intent independence.
DeliveryDisposition::Failed previously conflated an exhausted attempt
budget with a rejected re-enqueue, so the queue-drop metric incremented on
every one-attempt timeout. Historical behavior counted that metric only
when budget remained and the re-enqueue was rejected by a full
same-priority queue.

Make the cause explicit (BudgetExhausted / QueueFull / Requeued), count
tx_queue_drops only for QueueFull in the worker, and cover all three
causes plus the exhausted-timeout non-metric path with host tests.
Introduce comms::sigil::boot_policy: a host-testable typed boot gate that
distinguishes verified-empty enrollment, recovered record (corrupt-one-copy
fallback), both-copies-unreadable hardware fault, missing or rejected lot
trust anchor, upstream probe failure, and the event-counter policy.

Behavior change (deliberate): unreadable enrollment copies now block
pairing and radio protocol startup with EnrollmentHardwareUnreadable
instead of being silently downgraded to an install-eligible empty runtime.
The radio stays asleep and no protocol task spawns; the typed reason is the
deterministic diagnostic and no new audible UX is introduced.

Eliminate the placeholder-root SessionManager: the shared resource is
Option<SessionManager> and every protocol entry point hard-gates on its
presence, so no executable path can touch a fabricated trust root.

Verified-empty Install eligibility, corrupt-one-copy fallback, event-
counter behavior, LoRa-probe-first sequencing, ATECC sleep discipline, and
LIS2DH12 INT1 enabling are unchanged. Add the nine-case fault-matrix host
tests including the both-copies-unreadable regression.
Replace the discarded block reason with deterministic local diagnostics:
boot_policy::block_diagnostic maps each SigilBlockReason onto the existing
buzzer vocabulary (probe failure keeps the historical five-short-beep
semantics; root, enrollment-hardware, and anchor-rejection blocks get
distinct long-beep counts), consumed by the boot path before any alarm
controller exists. Buzzer-only by design: no LED primitive lives in the
feedback helpers at this stage. Cover the mapping matrix and its pairwise
distinctness with host tests.

Purge the historical placeholder byte pattern from src/: the remaining
test fixtures now use clearly named ascending-pattern keys (valid SEC1
prefix) so no production placeholder value survives anywhere.
Introduce comms::sigil::error_taxonomy: nine documented cause categories
(radio absent/I-O, malformed frame, secure-element transport, credential
validation, persistence, protocol state, timeout, spawn capacity) mapped
from InstallError variants, Sigil core classes, NVRAM faults, and radio
faults, each carrying an explicit retry/fail-closed/metric/feedback
disposition. Best-effort is reserved for GPIO feedback and shutdown
cleanup, now annotated at the timeout-cleanup, window-opener, and
post-boot sleep sites.

Surface durable enrollment failures precisely as InstallError::Persistence
(code 9) instead of collapsing them into Communication; reserve SpawnRefused
(code 10) for capacity refusals. Behavior is unchanged apart from the
sharper persistence reporting. Add five mapping-table test suites covering
every variant, class, fault, and the full disposition table.
Replace every wildcard arm in the Sigil mappers with explicit variants:
protocol-state refusals (expired session, unknown session, counter
exhaustion, duplicate handshake, replay-family, role/invariant) now surface
as InstallError::ProtocolState (code 11) instead of collapsing into the
radio-I/O bucket; historical product mappings (NoPending timeout code 1,
verification codes, ATECC transport codes) are preserved verbatim. The
shared classifier enumerates all 32 SigilError variants with no wildcard
drift possible.

Reclassify the disposition policy truthfully: remove the best-effort row —
spawn capacity is fail-closed (critical spawns expect at boot, auxiliary
skips are recorded no-ops), and the TimeSync window opener is documented as
bounded timeout/retry family in main.rs annotations. Best-effort handling
now lives exclusively in annotated GPIO-feedback and shutdown-cleanup
ignore sites.

Extend host tests: all 13 core classes plus frame/capacity families
asserted explicitly, install mapping table covers ProtocolState, and the
disposition policy rows assert no best-effort and fail-closed spawn
capacity.
Unify both install error mappers behind one planner that consumes the
shared taxonomy category, guaranteeing mapper output category equals
classify_sigil for every core class. Protocol-state variants named by
review (InvalidStep, RateLimited, PendingAlreadyExists, EventAckPending,
EventDeliveryPending, WrongRole, InvariantViolation) now reach
InstallError::ProtocolState (code 11, fail-closed, no retry) instead of
Communication; frame-level refusals reach the legacy decrypt/parse code 5.
Historical dedicated codes stay: ECDH code 4, NoPending timeout code 1.

SessionExpired handling is unchanged end-to-end: the RX pipeline still
retains the peer and routes to Renew/Resume; nothing removes the session.
Document the behavioral refinement in the changelog and extend the host
tests with mapper-vs-taxonomy consistency for every core class plus the
named protocol-state variants.
Extract helpers::rgb_policy: a host-testable planner that emits the finite
step schedule (pre-inverted active-low duties plus hold time) for every LED
command, mirroring the production driver table exactly.

The RTIC task now applies each step as one immediate PWM update under a
short lock and awaits hold times with Mono::delay() outside the locks,
removing the former closure that spun on the monotonic clock while holding
both PWM locks for an entire pattern. Visible behavior is unchanged: same
colors, blink counts, and durations. Five host suites cover static colors,
the active flash, the comm-error blink, bounds, and zero-max-duty
degradation.
Introduce product_config as the single source of truth for firmware
scheduling policy: typed millisecond/second/attempt newtypes grouped into
watchdog, vibration aggregation and polling, session maintenance,
handshake poll/backoff/retransmit, EventAck window/poll/attempts,
TimeSync window, and sensor/secure-element boot timing configurations.

RTIC tasks and policy modules now consume the grouped values through their
existing compatibility constants (EventAck and handshake constants,
KEY_LIFETIME_SECS, vibration poll intervals, LIS2DH12 boot timing, ATECC
read retries). The Event send-attempt budget shared by the controller and
TX queue and the unavailable-timestamp sentinel each have one definition.

Factory key and hardware profile identifiers stay compile-time lot pins in
factory_lot, selected by no runtime or radio input. Observable durations
and budgets are unchanged; six host relationship suites verify the
watchdog margin, integral EventAck poll budget, scheduling intervals,
finite boot retries, and sentinel consistency.
Repository-wide search proved encrypt_and_send_event had no callers,
exports, or references: every sensor Event already flows through plaintext
intent allocation, TxQueue, the delivery planner's single send_event per
attempt, and the bounded EventAck window.

Delete the dead helper together with its internal encrypt_data use, update
the stale legacy note in the contributor guide, and add repository-level
regression guards asserting the legacy symbols stay gone, exactly one
worker send path exists, and producers enqueue intents instead of building
radio frames.
The regression guard scanned CHANGELOG.md, which legitimately mentions the
removed fire-and-forget symbols inside its removal justification. Scope the
absence check to every Rust file under src/ so code and API surfaces stay
clean while historical justification text remains allowed in
documentation.
Move the remaining policy test blocks from source files into adjacent
tests/ directories with identical assertions: events, tx_queue,
event_ack_controller, renew_policy, clock_discipline, time, vibration
logic/config/policy, install types, and feedback. Hardware-coupled
surfaces keep their local blocks and are documented as exceptions in
tests/README.md, rewritten in English with the real coverage surface.

Add an execution-proof guard to test.sh: after the suite runs it asserts
that every critical policy module prefix actually executed, so a silently
dormant test module fails the run even when totals look green. The
reusable CI gate comment now states that its host suite includes this
guard; all four callers already reuse the gate before any release
mutation. Coverage via ./coverage.sh runs when cargo-llvm-cov is
installed and reports every listed critical module (95% overall).
Anchor every execution-proof entry at the start of the executed test path
so sibling modules cannot satisfy a generic entry: split the bare
vibration wildcard into its three real modules and correct the
gateway-policy and clock-discipline entries to their full module paths
(events::gateway_policy::tests, time::clock_discipline::tests).

Update coverage.sh's measured-surface description to match the current
std-feature reality after the policy-module extraction rounds.
Remove the large commented-out slow backup-poll implementation from the
vibration task. Idle wake stays INT1/EIC-only and the 100 ms fast I2C poll
still runs solely while motion is latched.

Represent backup polling as a typed BackupPollPolicy (Disabled today, with
rationale and the reserved interval from product configuration) and a
scheduler-facing accessor returning None while disabled, so enabling it
later flips one constant into the same sampled path without duplicating
code. Host tests assert the selected idle policy, the explicit Disabled
state, and the Enabled pluggability contract.
Route the vibration poll task's delay decision through a pure planner
(next_poll_action) fed by config::backup_poll_interval(), so flipping
BackupPollPolicy to Enabled really schedules idle ticks through the same
sampled body while Disabled keeps today's behavior byte-for-byte:
INT1-only exit on unlatched, unchanged fast latched poll.

Rewrite the stale comment claiming a backup poll covers missed edges:
edge capture comes from the INT1 latch; idle relies on INT1-only wake.
Drop the temporary dead-code allowances now that firmware consumes the
policy surface. Extend host tests for both None and Some planner cases.
Translate the remaining French task-table rows to English and describe the
reliable-event worker and install task through their planner-driven design.
Update the coverage section to list every pure policy module actually
measured, replace the stale local-path-pin dependency claim with the
registry-based reality, and keep the explicit disabled backup-poll policy
wording.

Translate test.sh user-facing strings to English. Move superseded
migration, phase, review, and testing notes under docs/history/ with an
index file (markdown stays untracked per the existing ignore policy);
active README pointers now reference the new locations. The datasheet PDF
and independent audit report remain at the root.
Whitelist docs/history markdown in .gitignore and commit the eleven
archived documents plus their index, so a clean checkout keeps the
provenance archive and README links resolve.

Rewrite the ATECC driver dependency statement to match reality (private
forgejorc registry pin; local path dependency commented out) and translate
the remaining French guidance lines in the contributor guide to English:
install poll/timeout tail, events flow description, housekeeping and
TimeSync window sentences, and the post-TX sleep bullet. The plan file is
an audit record and is intentionally left untouched.
Translate the remaining French comments in test.sh to English, keeping
behavior identical.

Housekeeping: add .opencode-memory/ to .gitignore so the session-tooling
artifact directory never appears as untracked project content.
Route every LoRa ingress outcome through one pure finalization policy:
rejected Data/Event/TimeSync peers, parse errors, session expiry, and a
missing session runtime now converge with successful dispatch on a single
tail that sleeps the radio exactly once or hands off to the recorded owner.
Peer authentication and dispatch decisions are unchanged.
read_slot, write_slot, and the boot copy reader now capture operation
results instead of returning early past a live driver handle, and every
exit attempts the terminal Sleep exactly once through the new pure
cleanup policy. Error precedence is explicit: the original operation
error always reaches callers unmasked, and a failed sleep never fails a
successful operation. Caller-visible NVRAM error mapping is unchanged.
All public radio operations now run inside one error-safe SERCOM4 APB
transaction boundary: the clock is enabled before and disabled again on
every return path including errors, so it can no longer stay enabled
across poll waits or full EventAck/TimeSync windows. The register-read
mode getter is gated as well (it previously relied on a clock leaked by
earlier calls), the reset path uses an un-gated wake core so the boundary
never nests, and the unused raw mutable driver accessor is removed after
a repo-wide zero-caller search.
The handshake driver start sequence now moves through resource-disjoint
phases: a LoRa-only precheck, I2C-only credential attempts with per-attempt
SERCOM0 gating, and one indivisible session+radio+ATECC start scope.
Credential retry gaps are async pauses outside every shared-resource lock,
the priority-4-ceiling watchdog is only touched by standalone constant-time
feeds, and gateway identity rides as a decision-phase snapshot. Error values
and downstream handling are preserved exactly.
DecisionSnapshot now declares session state plus gateway identity and
OwnerAcquire declares the typed ownership guard, matching the real RTIC
lock tuples. The integration suite drops the wrong owns-nothing claim,
asserts the corrected rows exactly, and cross-checks every table row
against the firmware source by textual resource fingerprints over
per-phase segments.
The cached-commit-retransmission segment joins the phase-to-source
cross-check with its real lock tuple, and a completeness guard now
requires every policy row to be covered by at least one segment so a
future phase cannot silently escape verification.
phase_id now matches every DriverPhase variant without a wildcard arm, so
adding a variant fails compilation until the id map is extended; from_id
mirrors the same order and a const round-trip proof pins density and
ordering at build time. The cross-check guard and the watchdog-exclusivity
test iterate 0..PHASE_COUNT through from_id instead of manual variant
lists, making the documented coverage guarantee real.
The ingress task replaces its eleven-resource critical section with
disjoint phases: receiver guard, ownership guard, gateway snapshot, SPI
exchange, single-owner classification, constant-time state flips, and the
terminal sleep tail. The transceiver wrapper now appears only in three
short radio windows so priority-3 sensing never waits behind dispatch,
the session runtime keeps exactly one owning lock, and gateway identity
rides as a one-copy snapshot. Finalization guarantees stay pinned: one
labeled spine, no early returns, one policy-resolved sleep site. A pure
phase/resource table with compiler-enforced dense ids and a full
table-to-source cross-check guards the shape.
EventAck and handshake waits now compare against wrap-safe absolute
deadlines in 32,768 Hz u64 ticks instead of decrementing poll counters.
The nominal ten-second window, the at-most-50 ms cadence (clamped to the
remaining time), and all retry counts are unchanged; scheduler lateness
and poll processing can no longer extend ownership because a late poll
closes the window immediately at the first execution at or after its
deadline. Only the authorized cached-Final retransmission starts a fresh
bounded deadline from its own sample. New pure radio-deadline helpers
document the rounding rules and the half-range wrap-safe comparison.
ticks_to_ceiling_millis now computes the exact millisecond ceiling
(second granules times 1000 plus a rounded-up remainder), fixing sub-second
samples that previously truncated to whole seconds (1639 ticks is 51 ms,
not 1 ms). Both radio driver loops evaluate their absolute deadline as the
FIRST action after waking, so the window closes at the first execution at
or after its horizon and the poll body never runs one extra time. Fresh
deadlines from Challenge verification and cached retransmissions are
sampled after the transition work completes. Structural pins prove the
check-before-poll ordering in both loops.
The host budget suite pins size_of upper bounds (actual sizes plus
documented headroom) for every exported fixed-capacity policy type,
guarantees no global allocator is declared and no allocating dependency
is active, and adds a HOST-PROXY-ONLY wall-clock guard over the pure
vibration scheduling policy. docs/performance-baselines.md records the
reproducible release size commands and flash/static-RAM figures for the
final implementation (~110.7 KiB flash of the 248 KiB bootloader budget;
~1.6 KiB static RAM), the largest symbols, margin rules, and the pending
user-assisted hardware measurements with their methods; it is whitelisted
in .gitignore so baselines stay versioned.
Add a dedicated integration suite over the delivered public boot-policy
surface only: both-copy enrollment hardware unreadability must block
protocol startup with its dedicated reason and can never authorize an
Install path, verified-empty stores keep first-install eligibility, a
single surviving enrollment copy recovers into Resume semantics, every
block reason keeps a pairwise-distinct buzzer diagnostic (four long
beeps for unreadable enrollment hardware), and src/main.rs must map the
dual-copy boot read through the typed gate with all three outcomes named
and no .ok().flatten() downgrade anywhere in the entrypoint.

Also apply two mechanical lint fixes to pre-existing deadline/gating
test files (remove a dead unit binding; replace manual prefix stripping
with strip_prefix) so the host lint target stays warning-clean.
Move the two compile-time capacity guarantees into const blocks so the
assertions are enforced during compilation and the host lint target
passes clippy::assertions_on_constants under -D warnings. No behavior
change: the checks keep failing the build on any capacity regression.
Strengthen the structural pin on the production wiring into a data-flow
proof: parse src/main.rs for the unique dual-copy enrollment read, extract
its result binding from the surrounding let <binding> = match declaration,
locate the unique downstream typed-gate call, and assert the gate's
enrollment argument is exactly that binding. Between the two sites a second
read and any reassignment, mutation, shadowing, or intermediate use of the
binding are forbidden (zero-occurrence pin plus explicit pattern bans), and
the match must still name all three typed outcomes. The historical global
.ok().flatten() ban is retained.
A completed Install / Renew / Resume now binds only to the exact
authenticated peer carried by the Sigil success action, cross-checked
against the handshake coordinator's bound peer and accepted only when
that same peer's session holds an active key at the current monotonic
time. The former manager-wide first-expiry scan (which accepted any
session with a nonzero expiry, ignoring identity and liveness) is gone:
manager iteration order can no longer influence which gateway identity a
completion binds, and every mismatch, missing entry, or inactive key
fails closed as a communication error.

The decision lives in a new transport-free policy module with keyed-
lookup-only observation of session state and typed rejections; host
tests pin exact attribution, mismatch rejection against active unrelated
entries, missing-peer fail-closed behavior without stale-first fallback,
inactive-key refusal for matching peers, and insertion-order independence
over a real two-entry manager. The radio poll helper now receives the
coordinator-bound peer explicitly, the cached-retransmit helper takes the
monotonic completion instant, and the install task refuses residual
identity drift before recording the terminal transition.
The anti-replay TimeSync sequence is no longer sticky across rekeying.
A successful, exact-peer-bound Install / Renew / Resume commit now
starts a fresh replay epoch in the install task immediately after the
coordinator records the commit: the last accepted sequence is cleared so
a restarted or rekeyed gateway may begin its counter afresh instead of
having every fresh authenticated sample rejected as stale forever. The
accepted wall-clock offset and its backward-jump guard are preserved
byte-for-byte, so offset continuity and anti-rollback protection both
survive the transition; within one epoch, equal and lower sequences keep
being rejected exactly as before.

The transition is decided by a new pure policy module (authorized only
for an authenticated CommitConfirmed completion) and applied through a
dedicated epoch method that is deliberately narrower than the
administrative reset, which alone still discards the offset itself
during reprovisioning or unpair. Failed, timed-out, or pre-commit-
cancelled handshake outcomes never touch replay state. Module docs no
longer claim that Renew/Resume retain sequence state.

Host tests pin within-epoch strictness, post-epoch restart acceptance,
offset continuity across transitions, persistent backward-jump
enforcement, failure-path no-op behavior, and the administrative-reset
contrast.
The physical factory-button path now erases both enrollment copies even
when no RAM gateway binding exists — precisely the missing-binding state
a corrupted boot can produce — instead of refusing the whole operation.
The remote authenticated command keeps requiring the binding, unchanged.

Session removal is no longer ignored: cleanup runs as two explicit
phases (durable dual-copy erase first, then verified RAM session removal
with a keyed post-condition re-read, or a capacity-bounded sweep with a
whole-table verification when no specific peer is known) and reports
success only when both copies are invalid AND no enrolled session
remains resident. Only a policy-Complete disposition authorizes the
runtime-state reset (gateway binding, link flag, TX queue, state
machine, clock discipline); durable-erase failures leave every piece of
runtime state untouched and surface as bounded visible failures without
any retry loop. The typed radio-ownership idle wait, in-lock re-check,
and force-idle reset are preserved, and Counter[1] is never reset by any
unpair path so event ids stay monotonic across re-pairing.

Host tests pin no-RAM-peer recovery, partial-erase failure reporting,
removal-failure prevention of false success, complete-cleanup
authorization, trigger-semantics preservation, and counter non-reset.
The product-unpair task now waits on typed radio ownership behind an
absolute deadline (ten seconds, shared tick-deadline helpers): expiry
ends the former potentially unbounded loop as an explicit failure
before anything is attempted — no erase, no half-cleanup.

The task boundary now carries a typed disposition instead of a bare
flag: Complete spawns the recovery flow; every failure reason (durable
erase, RAM cleanup, guard refusal, ownership-wait expiry) increments a
dedicated saturating protocol-metric counter and emits one deterministic
long-beep diagnostic from the boot vocabulary, pairwise distinct from
all four boot-gate patterns and every other local signal, while runtime
state stays untouched. Guard refusals and wait expiry are first-class
typed reasons; the composite local API maps dispositions onto its
bounded error set exhaustively.

Host tests pin bounded-wait expiry as explicit failure, guard-refusal
fail-closed behavior, the diagnostic mapping with pairwise distinctness
against all boot-gate reasons, and that no Failed reason ever authorizes
the runtime reset.
At every wake of the product-unpair ownership wait the deadline is now
evaluated FIRST and the radio-idle check only runs when the horizon has
NOT expired, so an owner releasing after expiry can never let cleanup
start. A structural pin in the host suite anchors the production order
(deadline evaluation precedes the idle check inside the loop) and keeps
the former unbounded while-loop idiom banned from the entrypoint.
Assert exactly one reference of the configured wait bound, then isolate
the ENCLOSING wait loop deterministically (first loop opener after the
anchor, brace-balanced) so the deadline-before-idle ordering proof runs
inside a single reference frame: first idle check strictly after first
deadline evaluation, unbounded while-idiom banned inside the window and
across the entrypoint.
Commits now stage the inactive slot with the committed marker held at
zero and verify the image byte-for-byte before a single committed-marker
transition write, verified in turn. The staged buffer carries the FINAL
CRC, so the one-byte flip alone completes an exactly-valid record:
commit state rests on the marker byte alone, never on a CRC coincidence,
and every interruption before the verified transition deterministically
keeps the previous generation selected. The previously active copy stays
byte-identical through both phases.

The ATECC backend overrides the commit transition with one 32-byte read
plus at most one 32-byte write on the physical block holding the marker
(trait default stays a full-slot rewrite elsewhere), keeping worst-case
cost at five block writes and three reads per commit with the terminal-
sleep contract intact on every pass; documentation now states the actual
ordering and the endurance rationale.

Crash-atomicity suite injects power loss after each physical stage
block, after staging, after staging verification, before the marker
write, and after it: previous-record selection on all pre-marker cuts,
exact staged bytes durably stored, new-generation selection once the
marker lands, single-byte staged-vs-final difference with final-CRC
carryover, and previous-copy isolation.
Drive only the pinned public Sigil API with deterministic secure-element
and RNG doubles. Pinned observable contracts: garbage, truncated, zeroed,
oversized, and clear-text command-shaped frames never surface application
events; keyless enrolled session shells reject every application TX path
and TimeSync requests; rejected frames leave no classification residue;
Install requests embed fresh per-call randomness, stay reproducible per
stream, and classify deterministically as non-application handshake
steps; cached-frame retransmission refuses unknown peers and pre-Final
pending states.

Documented as UNOBSERVABLE-BLACK-BOX with the sensor-only feature graph:
directional nonce uniqueness across sends, ciphertext inequality for
identical plaintexts, counter non-reuse after rejected TX on a live key,
replay rejection of previously valid frames, invalid-tag ordering for
real tagged frames, receive-window bounds, decrypted peer binding,
duplicate-Event flag, and post-Final retransmission equality — these
stay covered by Sigil's internal suite and the firmware policy-layer
suites.
Add a TEST-ONLY cargo feature (sigil-gateway-test = [std, sigil/gateway])
that unlocks the gateway side of the pinned public API, letting the
black-box suite drive a COMPLETE sensor-gateway Install handshake end to
end through public calls — no secrets or private fields exposed.

Sensor-only fixes: true MSG_MAX+1 rejection case; full event+action
value equality for the garbage non-poisoning pin; per-call TX freshness
from one manager via a stateful RNG stream (cross-instance reproducibility
kept separate); cached-retransmission refusal checked for the SAME peer
owning the pre-Final pending.

New gateway-paired pins: directional ciphertext uniqueness in both
directions; successive-ciphertext inequality for identical plaintexts
with exact decryption bound to peer MAC and event ids; counter coherence
after a rejected oversized TX; replay of a captured valid Data frame
rejected without surface; single-byte tag corruption blocking all
plaintext/command exposure; TimeSyncRequest/Data binding to the enrolled
sensor MAC; duplicate-flagged Event redelivery with ACK frames clearing
sensor pending state; expired formerly-active sessions rejecting every
TX path; post-Final cached retransmission allowed and byte-equal to the
original Final. Remaining genuinely unobservable surfaces (factory-cert
signature strength, anti-replay window internals) stay documented.
The flash path no longer writes a fixed shared /tmp pathname that an
attacker could pre-create or symlink. OpenOCD output now goes to a
private mktemp file created once under ${TMPDIR:-/tmp}; secure-creation
failure aborts the attempt with a visible message before any hardware
contact. Every expansion of the tempfile variable is quoted, and cleanup
is an EXIT trap inside a dedicated subshell so the file is removed on
success, on command failure, and on any inner exit while the sourcing
shell's own EXIT trap is never clobbered.

The existing flash-common suite gains five structural assertions over
the real script: mktemp under TMPDIR, absence of the fixed shared path,
full quoting of every tempfile-variable expansion, subshell trap ordering
versus the outer verdicts, and abort-before-hardware ordering when
secure creation fails.
update INTERVAL_SECS
Some checks failed
Enforce branch flow (samd21) / validate-flow (pull_request_target) Successful in 2s
Enforce branch flow (samd21) / lint-and-test (pull_request_target) Failing after 2m18s
4060807e44
Merge branch 'dev' into fix/audit-battery-feature
Some checks failed
Enforce branch flow (samd21) / validate-flow (pull_request_target) Successful in 1s
Enforce branch flow (samd21) / lint-and-test (pull_request_target) Failing after 2m3s
7853aab509
faicel deleted branch fix/audit-battery-feature 2026-09-23 20:52:02 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
homeiot/samd21!11
No description provided.