-
A Direct medium is now a name, not a hardcoded enum — and an unknown one is
ignored instead of poisoning the offer it arrived in.direct::Mediumwas a
closed#[repr(u8)]enum, which was the wrong shape twice over.DESIGN.md
already says the nutrient list is closed while the bridge list stays open, and
a medium is the Direct plane's version of a bridge — so enumerating them in the
core made every new medium an edit tosrc/and an allocation somebody had to
hand out. Worse, an unknown code had to be a decode error:Offer::decode
propagated it with?, so a peer advertising one unfamiliar path alongside three
usable ones got nothing at all. A medium is now a length-prefixed name carried
verbatim; an unrecognised one decodes cleanly and is simply a candidate nobody
declared willingness for, so it is skipped like any other unusable path, and an
offer of only unknown mediums answersno_medium— a reason rather than silence.
The name is bound into the KDF, so a record still cannot be replayed onto a
different medium. The SPDR profile isVERSION = 2for the encoding change,
bumped rather than finessed because a v1 peer would mis-parse every candidate —
cheap now, since until the daemon wiring below nothing could start a pipe at all.
The frozen v1 envelope wire is untouched; SPDR is opaque payload riding on it. -
Direct now offers a global IPv6 address when the host has one — the WAN path
that needs no traversal at all. Candidates were IPv4-only, so a node with a
global v6 from its ISP — which is most of them — advertised only a LAN address
and a reflexive one that needs a hole punch to work. A global v6 has no NAT in
front of it: it is already the address a peer dials. It is ranked between the
LAN locator and the reflexive one, sochooseprefers it over a path that does
not exist yet, and it is offered only when the host actually has one —
link-local, unique-local, loopback and multicast are all rejected, since
advertising any of those is a candidate that can never connect. A path firewall
may still drop unsolicited inbound; that is a pinhole a punch can open rather
than a mapping that must first be discovered, so it is better odds and not a
promise. The daemon now prints which locators it is offering and what each one
needs, so "why did it not connect" is answerable without a packet capture. -
A node can now discover the address the outside world sees, and answer that
question for other nodes (P-Direct-NAT step 2). Direct could only ever offer
the address a node was told it had, so it worked on a LAN and nowhere else.
direct::stunis a minimal binding client and echo — RFC 5389's binding
exchange and nothing more: no auth, no FINGERPRINT, no ICE, no dependency, with
unknown attributes skipped rather than rejected so a full STUN server still
interoperates. The echo half ships with it and is the point: a daemon's
stun:port answers statelessly (one packet in, one out, nothing retained), so
one SPORE node is a reflexive-locator server for another and the network need
not quietly depend on a third party's STUN server. A discovered locator is
offered as a second candidate ranked below the LAN one, so a path that works
without crossing a NAT is always preferred. This does not make NAT traversal
work: most NATs drop an unsolicited inbound datagram, which is what the
coordinated hole-punch (step 3) exists to fix — the daemon says so at startup
rather than implying the new locator is reachable. Verified with two daemons,
one asking the other's echo. -
Android can bring a Direct pipe up too — through the same code as the
daemon. Wiring it separately would have meant two implementations of one
negotiation, which is exactly the per-platform punch logic the roadmap's
engineering pattern forbids, so the runner moved into the core as
direct::UdpRunnerand both native runtimes became thin adapters over it: the
daemon supplies stderr and its config, the JNI layer supplies a handle and five
poll-driven calls. Kotlin never touches a Direct socket — it says where the
device is reachable, feeds delivered envelopes in (getting back whether each was
signalling, so an app message is never swallowed), and ticks. Compile-checked
only: there is no Android SDK in this environment, so the JNI symbols and
Kotlin declarations are verified symmetric in both directions and the crate
builds and lints clean, but no phone has run it. The daemon path is the one with
two-process evidence, and it was re-verified unchanged after the refactor. -
The daemon can now bring a Direct pipe up. The core seam below made
signalling possible; nothing called it, so Direct still could not be started
from anything you can run.src/cli/direct.rsis that consumer:direct:in
the config says where a node is reachable,direct-to:names a peer to keep a
pipe to (the daemon has no control surface to start one from, so without it
both ends would sit waiting to be offered a pipe), and the runner dispatches
delivered DMs throughSignalling, opens aUdpPortfor whichever candidate
won, and carries the reply back oversend_direct. Verified with two real
daemon processes negotiating over a shared folder bridge and bringing up a
pipe, not only in a unit test. Honest limits, printed by the daemon itself:
LAN only — a node cannot yet discover its own reflexive address, so it
advertises what it was told and NAT traversal remains the unbuilt
P-Direct-NAT track; and there is no app above the pipe yet, so inbound records
are logged and dropped rather than routed somewhere that does not exist. -
Direct signalling can now reach a peer at all —
SPDRridessend_direct.
The negotiation codec, key schedule and socket adapters were all built and
tested, but nothing tied them to the mesh: no code anywhere outside
src/direct.rslooked at anSPDRpayload, so no app could start a pipe, which
is why NAT traversal had never actually been hit in practice.direct::Signalling
is the missing seam — it turns the plaintext of a delivered DM into aSignal
saying what to open and hands back the state to finish with. A whole negotiation
now runs over the realsend_direct/on_rxpath in a test, sealed and signed
like any other DM, and the resulting pipe carries traffic both ways.
A real API gap fell out of it:Pipe::answertook the port before
chooseran inside it, so a responder willing to use more than one medium could
not use it — it would have had to guess which medium would win.accept(decide,
no port) andPipe::answer_with(open, then derive) split the two;answer
stays as the single-medium convenience it always effectively was. Deciding is the
core's, opening is the runtime's — the seamdocs/DESIGN.md's runtime model
already implied. Unanswered offers expire rather than holding an ephemeral secret
forever. Purely additive; the SPDR wire anddirect::VERSIONare unchanged.
No daemon or Android build calls this yet — that wiring is the next step. -
The palette is now defined once and generated into every surface that renders
it, with the contrast ratios computed instead of typed. The same hexes were
hand-maintained in four places —site/style.css, the standalone node's inlined
CSS, Android's ComposePalette, anddocs/VISUALDESIGN.md's tables — each
carrying its own re-typed WCAG ratios, so a palette change meant a manual
four-way audit and the ratios could silently stop describing the colours they
sat beside. They had already drifted: Android carried three ratios per colour
where the CSS carried one,--dimon void was written as both 4.68:1 and 4.6:1,
and--warn/--badexisted only in the standalone.design/tokens.jsonis now
the one place a colour is defined anddesign/generate.pyemits all four,
following the provenbindings/spec.jsonpattern with a matching CI job
("design tokens in sync"). The generator refuses to run if a pairing stops
matching the grade the source claims for it, in either direction — a colour
that is no longer readable, or a "never do this" pair that has quietly become
fine, since a stale safety claim is as misleading as a stale colour. No
rendered colour changed: every hex in all four files is identical, counts
included; the diff is comments, ordering and formatting only. -
A node now maintains itself on a timer instead of only when traffic
arrives — and the desktop daemon retries unacked sends for the first time.
The expiry sweep and prekey rotation were reachable only fromNode::on_rx,
so a quiet node pruned nothing and never advanced its forward secrecy; and
resend_unackedhad exactly one production caller anywhere in the tree — the
Android JNI — so §8 resend-with-backoff worked on Android and nowhere else.
NewNode::tick(now)is the one periodic entry point: it runs the sweep and
returns whatever fell due for resend.Hub::tick()wires it for hosted
nodes, the daemon calls it in its existing beacon loop, and Android's existing
periodic call now goes through it (no Kotlin change). Purely additive — the
ingest-side sweep still runs and both are idempotent, so a runtime that never
ticks behaves exactly as before. SPEC's runtime contract makes the timer
normative. -
Storage is now something a runtime supplies, not something the core
assumes. The store could only ever spill to a filesystem directory, so a
runtime whose storage is not a disk — a browser tab with IndexedDB, an MCU
with flash — had somewhere to put bytes and no way to offer it, and silently
ran memory-only.SpillBackendis now a public trait
(put/get/remove/ids) withFsSpillas the filesystem implementation,
andNode::set_spill_backendaccepts any other.Node::set_spill_diris
unchanged and still the filesystem path. Wire format and the frozen API are
untouched — purely additive. The verification that matters is deliberately
not delegated: a backend moves dumb bytes, while the id-matches-content
check, the exactly-one-envelope check and expiry all stay in the store,
because a backend is by definition somewhere other things can also write
(C-ST4). -
Every GitHub release now carries the means to rebuild itself, not just the
APK.spore-android.apkwas the only release-cutting workflow ever
attached to a release; the offline source bundle (every dependency vendored,
cargo build --offlineworks immediately) and the standalone browser node
existed but were either a CI artifact that expires after 90 days or only
ever served live from GitHub Pages, with no permanent, offline-verifiable
copy. Both are now attached to every rolling and tagged release alongside
the APK —docs/APPS.md's desktop/CLI card anddocs/CONTINUITY.md's seed
table point at them directly instead of only "clone and vendor it
yourself." MatchesMISSION.md's "rebuild without us" — this repository,
this site, and crates.io can all disappear and a release you already
downloaded still rebuilds. Nightly builds (the 5-deep rollback history) are
deliberately APK-only, to keep their per-build storage cost from growing
5×; the rolling and tagged releases are the two that matter for this. -
Removing (or pausing) a core-owned UDP/TCP bridge — or Wi-Fi Direct — now
actually stops it (PR2 carried-forward).bridge::udp::run/run_primary/
run_groupandbridge::tcp::runused to have no way to be told to stop:
Android's JNI layer spawned them on a detached OS thread with no handle back,
so their Remove button (where one existed at all) never had anything to call.
They now take a stop flag, checked on every already-short-timeout read except
for TCP listen mode'saccept(), which has no read-timeout equivalent and so
is polled non-blocking instead — a real fix, not a cosmetic one: a plain flag
check after a blockingaccept()would still hang forever with nobody ever
connecting.WifiDirectBridge's Remove used to tear down only the P2P group,
leaking the UDP flood underneath it forever; it now stops that too. The
always-on default LAN bridge ("UDP broadcast") joins the Pause/Resume toggle
system below rather than getting Remove-only, since it has no manual re-add
control and a one-way Remove would have meant no LAN bridge until the app
restarted. CLI/daemon bridges (ax25,i2p,reticulum's TCP/UDP
companions,tor) are unaffected — Ctrl-C still ends the whole process, so
they get a stop flag that's simply never set. Wire format and JNI ABI shape
unchanged;nativeStartUdp/nativeStartTcp/nativeStartUdpLimitednow
return the hub iface instead of nothing. -
Android: a bridge can be paused without losing it (PR2 carried-forward
toggle). Removing a bridge always meant re-entering its setup from scratch
— re-scanning for the BLE device, re-granting the mic permission. Audio modem,
Meshtastic BLE and RNode BLE now get a Pause/Resume control next to Remove:
Pause stops the transport and frees its hub interface but keeps the row and
its configuration, and Resume restarts with that exact same configuration,
landing back in the same row rather than adding a duplicate one. Wi-Fi Direct
and Web don't get one yet — Wi-Fi Direct's actual transport turns out to be
the core-owned UDP bridge with no stop hook (a Pause would silently leave the
socket running), and Web can aggregate any number of added relays that a
Resume can't yet replay — both stay Remove-only rather than offer a control
that would lie about what it did. No wire or wasm changes. -
The standalone web node is on the real palette now, not a stand-in (C1).
web/spore-standalone.htmlhas carried its own inline stylesheet since it
was written — it has to, being a single offline file — but that stylesheet
was still the generic dark scheme it started as, never actually updated to
docs/VISUALDESIGN.md's Neo-Tokyo Tactical Wasteland tokens, despite the doc
claiming it did. Now on the same hex values assite/style.cssand
Android'sChrome.ktPalette (both of which were already correct), in both
dark and the "Field Notes" light variant. Also added: a 2px cyan
:focus-visiblering on every interactive element (previously none), and a
kevlar-faced:disabledbutton state (previously none — with the button
face now pink, an unstyled disabled state would have rendered exactly the
"translucent pink" VISUALDESIGN forbids). A related semantic fix: the log's
"received" lines and the "bridge open" badge used the primary-action accent
color, which read as success by coincidence in the old palette's green but
would have read as the wrong thing once accent became pink — remapped to
the dedicated--oktoken. Audited Android for the same class of bug
(pink-on-kevlar, disabled-state treatment, missing focus rings); found
already correct there. No wire or wasm-export changes. -
Android: Feed polish (B8). Following a topic now says so — "Following
#x", "Already following #x", or "Node not started yet" — instead of the
Follow button silently doing nothing or nothing visible happening on a
duplicate tap. A corrupted or truncated attached image now shows "couldn't
load this image" instead of "decoding image…" forever, which previously
looked identical to an image that just hadn't finished loading. A post's
[text](url)links stay inert on tap (a link in a signed-but-public post is
attacker-controlled text — no drive-by opens), but a long-press now copies
the URL to the clipboard so a real link is still usable. -
The offline window is now a configurable knob, end to end (PR0 Part B).
Prekey lifetime and the §7 ratchet's skip-key TTL were separate hard-coded
7-day constants; they're now one field,Node.prekey_lifetime_secs, read by
bothsweep_prekeysand session bootstrap, so they can't silently drift
apart. NewNode::offline_window_secs()/set_offline_window_secs()(clamped
to a day..365-day range) expose it. Android's Advanced screen gets a matching
"Offline window" card (7d/14d/30d presets + custom days, persisted like the
seed/ring), the About blurb states the active window instead of a hard-coded
"7-day," raising above the 7-day default requires the same confirm dialog
used for prekey-ring export, and a failed decrypt of a verified message from
a known contact now surfaces "couldn't decrypt this — the key may have
expired, or ask them to resend" instead of dropping silently. Wire format
unchanged; this is local policy only. Field-verifying the window on real
hardware stays tracked under PR6. -
The §7 Double Ratchet is now wired into real DM traffic (PR0b). Direct
messages were always sealed with a fresh one-shot key against the recipient's
current prekey; the tested Double Ratchet primitive existed but was never
actually used for send/receive.Node::send_direct/a newNode::open_dmnow
use it once a session exists (falling back to the one-shot seal otherwise).
Sessions bootstrap from ANNOUNCE — both sides derive the same root
independently via a static-static X25519 DH, with the numerically-lower
address always the pair's deterministic initiator, so two peers who each
message the other before hearing back still converge on one session. New
envelope flagRATCHET(bit 64, previously unused) marks a ratchet-shaped
payload; sessions are in-memory only (like the existing peer tables) and
bounded the same way. Along the way, fixed a real bug this surfaced: a fresh
node's bootstrap prekey rotates on its own firston_rx, and if that raced
against its first ANNOUNCE exchange, two peers could permanently derive
different session roots — now settled consistently before either announcing
or bootstrapping a session. This unblocks (but doesn't itself add) PR0 Part
B's offline-lifetime UI/config knobs. Wire format additive/unchanged for
existing traffic. -
Android: accessibility + density pass (B7). Icon-only buttons (attach, remove
attachment, Feed's Bold/Italic/Code/link/image, the top bar's back/connect/settings
icons) now announce a real name to TalkBack instead of the raw glyph or letter. Topic
chips and bottom-nav tabs announce selection instead of relying on colour alone. Every
button now meets the 48dp touch-target floor (most were ~36dp). A chat's message
composer gets initial focus instead of the petname field above it. The thread view no
longer yanks a reader who scrolled into history back to the bottom on every new
message — it only auto-follows when they're already there, with a "↓ new" button to
jump back manually otherwise. Reduced-motion re-verified: no new animation gaps found.
Wire unchanged. -
Android: bridge status is an exact-matched enum, and a denied permission has a
recovery path (B6). The bridge LED used to classify status by blind substring —
"disconnected"read as connecting (it contains "connect"),"unsupported"read
as up (it contains "up"). Both were live bugs, now fixed by matching the small,
known vocabulary every bridge source emits exactly instead. A connection error also
gets its own pink-plus-icon treatment rather than fading into the same look as an
idle bridge. A denied permission (Audio modem, Meshtastic/RNode BLE, Wi-Fi Direct)
no longer dead-ends silently — it now offers a dialog that deep-links to the app's
system settings. Wire unchanged. -
Android: Advanced screen shows prekey-ring health, and export is gated (B5).
A new core method,Node::prekey_health, reports how many prekey secrets are held,
how old the oldest is (honestlyNone/"unknown" for an unstamped bootstrap entry),
and seconds to the next scheduled rotation — exposed through a new, additive
android/jniexport (not the frozen C/Python/Go/JS bindings surface). The Advanced
screen shows the readout live and gates "Export ring" behind a confirm dialog warning
that a copy defeats the 7-day forward-secrecy window. Wire format unchanged. -
Android: informative node notification + transfers overflow (B4). The
foreground-service notification said "node running" forever; it now shows the
address's first 8 hex chars, live peer count, and "relaying" once the node is
actually holding envelopes for the mesh, refreshed as those change, and opens the
app when tapped.TransfersBarnow says+N moreinstead of silently dropping
transfers past the first 3. Wire unchanged (app-shell only). -
Site: copy-to-clipboard on every code block. A small "Copy" button now sits
top-right on every<pre>across every doc page (not just the front page — the share
bar staysindex.html-only, but code blocks appear everywhere). Uses existing tokens
only, flashes "Copied ✓" on success, falls back to "Select + copy" rather than throwing
if the Clipboard API is unavailable. Verified with a real Playwright clipboard test, not
just visually. Documented indocs/VISUALDESIGN.md§3. Wire unchanged. -
Site: a calmer tone for long-form body copy (dark mode).
--amberon--void
clears 10.80:1 — nowhere near a contrast failure — but a fully saturated colour glowing
on near-black is tiring across paragraphs in a way the ratio alone doesn't capture. New
token--prose(#d6af5c, same hue at ~60% saturation, still 9.56:1/8.24:1 — past
7:1/AAA) now colours every long-form paragraph, list item, and table cell across all
doc pages, not just the three story-card ones — so dense reference pages (Bridges, Spec,
Security Findings) get the relief too. Headings, code, buttons, and badges keep full
--ink. Light mode is untouched (--prosealiases to--inkthere; dark ink on paper
needed no desaturating). Documented indocs/VISUALDESIGN.md§1 before the CSS changed.
Wire unchanged. -
Site: Home, Apps, and Continuity use illustrated story cards. A first-time visitor
landed on a wall of text; these three pages now open with a grid of small self-hosted
inline-SVG illustrations (CSS/tokens only, no rasters) and a one-line caption, with the
full prose moved into<details>. Builder/reference docs (Spec, Design, Bridges, Direct,
Rebuild, Security Findings, Hardware, Testing, VisualDesign, Roadmap, Changelog,
Contributing, Bindings, Reference, Web guide) are unchanged. Reduced-motion disables the
SVG animations (verified via computed style); no pink-on-olive; decorative art is
aria-hidden. First increment of the ROADMAP "site" track — a contrast/readability pass
and a docs-index card grid remain open. Wire unchanged. -
Android: empty states + a PUBLIC send confirm (B3). Chats, Bridges, and Feed
(already had one) now say something plain when there's nothing there yet, instead of
a blank list — no unread badges anywhere, since there's still no read tracking to back
one. Sending to PUBLIC — unlike a DM, signed but never sealed, and reaching every
node in range rather than just the open thread — now asks first, via a new
ConfirmDialogshared component (pink CTA, void ink; Cancel is the quiet default).
🧪 Compiles in theapkCI; on-device QA is PR6. Wire unchanged. -
Android: send/post feedback (B2). The plain-text chat send and the feed post used to
clear the composer even when nothing went out — the node not being started yet was a
silent no-op that looked like a sent message.NodeController.send/postnow return a
result (false when the node isn't up); the composer only clears on success and otherwise
keeps your text and shows "Node not started yet — not sent/posted" — the same pattern the
petname Save already used. 🧪 Compiles in theapkCI; on-device QA is PR6. Wire unchanged. -
Android: chat navigation (B1). System Back now follows the screen hierarchy
instead of leaving the app from a nested screen — a thread falls to the chats list, a
draft post to the feed, everything else to Chats, and only Chats itself backgrounds the
app (mirroring the existing←arrow). The chat now pins to its newest message on
open and whenever one arrives, and the composer lifts above the soft keyboard
(imePadding). 🧪 Compiles in theapkCI; on-device QA is PR6. Wire unchanged. -
Site: the Roadmap and Changelog are in the top navbar. Both pages were already
rendered and linkable but kept off the nav; they're now first-class nav items
(Home · Spec · Apps · Design · Bridges · Rebuild · Continuity · Roadmap · Changelog),
so "what's planned" and "what shipped" are one click from any page. Wire unchanged. -
Docs:
android/PLAN.mdandUX-ISSUES.mdabsorbed; docs cull complete. The
M0–M5 milestones move to a "shipped milestones" paragraph inandroid/README.md, and
the chat-attachment convention (the📎 name | spore:<magnet> | mimemarker and its
parser regex, verbatim) becomes Appendix A ofdocs/VISUALDESIGN.md. Both files are
deleted; references repointed. Thedocs/footprint is now its canonical set (SPEC,
DESIGN, BRIDGES, SECURITY_FINDINGS, VISUALDESIGN, APPS, CONTINUITY, HARDWARE, DIRECT,
ROADMAP). Wire unchanged. -
Docs:
ANDROID_AUDIT.mdretired into ROADMAP + TESTING.md. The Android production
audit's status table had drifted (it still listed shipped work as open); its status now
lives only in the ROADMAP, its still-open engineering items (received-file
FileProvider, the JNI local-ref soak, WebView battery + Lite mode, permission-at-enable,
loop lifecycle gating) are captured there, and its device checks stay in
android/TESTING.md. The Verified fixes it described are already shipped and in this
CHANGELOG. Deleted, not just unlinked — no second status surface. Wire unchanged. -
Docs:
ROADMAP.mdcarries the full plan;SPORE_DEEP_AUDIT.mddeleted. The
multi-PR plan that was hiding in a misleadingly namedSPORE_DEEP_AUDIT.mdis now
ROADMAP.md— the single forward-looking surface: the PR map with status, the full
detailed PR0–PR9 bodies (files, code sketches, tests, acceptance — kept, not
summarised away), the hard rules and PR template, and the docs / Android-UX / palette /
site / web tracks. An earlier pass over-culled this into a summary behind a redirect
stub; the detail is restored and the stub is gone (no redirect docs). "What shipped"
stays in just two places — the CHANGELOG and the ROADMAP status column. Wire unchanged. -
iroh QUIC bridge (
bridge-iroh, experimental 🧪). A new optional bridge that
carries SPORE envelopes over iroh QUIC —
peer-to-peer by public key, with hole punching and relay fallback for reach that LAN
UDP and Tor/I2P don't cover. It is a normal stream bridge: KISS-framed on one bi
stream, same best-effort store-and-forward. The one novelty is async — iroh is
tokio-based while the rest of SPORE is synchronous, so the bridge runs a private
runtime and wraps the QUIC stream halves as blockingRead/Writefor the shared
pump. Config:iroh(listen),iroh: <id>(dial via relay), oriroh: <id>@<addr>
(dial direct, relay/discovery off). Tested by a two-endpoint localhost QUIC
round-trip in a dedicatedirohCI job. Trust notes (relay phone-home,EndpointId
≠ SPORE address) are inBRIDGES.md. Wire unchanged — an
underlay, not a protocol change; golden vectors byte-identical. -
MSRV floor raised 1.75 → 1.85. Admitting iroh pulls
zeroize≥1.9 (and its
edition-2024zeroize_derive) into the core build via chacha20poly1305/crypto_box,
which needs Rust 1.85. A deliberate trade, documented inCargo.toml,CONTINUITY.md
and the MSRV CI job; iroh itself needs 1.91 and is built only by its own CI job on
stable. The default offline rebuild and every non-iroh bridge still build on 1.85.
Cargo.lockmoves to version 4 (needs Cargo ≥1.78) — the v3 pin only ever existed
to stay parseable by the retired 1.75 floor. -
SPORE Direct: a negotiated, non-routed, end-to-end encrypted datagram pipe for
low-latency media. Store-and-forward is the wrong plane for voice or a live
terminal, where holding a frame for relay adds exactly the latency you're avoiding.
Direct is the other plane: two identities agree on the mesh (anSPDROFFER/ANSWER
carried over the existing sealed+signedsend_direct) on a medium and an ephemeral
X25519 key, then talk directly over an underlay with a ChaCha20-Poly1305 record.
Keys bind both addresses, the pipe id, and the medium, so a record only opens for
the exact pair that negotiated it, and the header is authenticated so a flipped
type/seq fails the MAC. This first increment is the pure protocol core —
negotiation codec, key schedule, record, medium selection, aDatagramPorttrait +
in-memoryLoopback, and thePipe— fully unit-tested end to end
(examples/direct_loopback.rs,docs/DIRECT.md). It is an application profile: no
envelope/store/hub/wire-format change (golden vectors byte-identical), and it
compiles everywhere the core does, wasm included. Real socket adapters (UDP/TCP/BLE)
and the mesh signalling glue are follow-ups — transport, not protocol. -
SPORE Direct: real UDP and TCP socket adapters.
direct/udp.rsand
direct/tcp.rsimplementDatagramPortoverstd::net(gated
#[cfg(not(target_arch = "wasm32"))], so the negotiation core still builds for the
web). UDP maps one datagram to one sealed record over a connected socket; TCP adds
4-byte length-prefixed framing to restore that shape over a byte stream, disables
Nagle for latency, and refuses an over-length prefix rather than buffer toward it
(an unbounded-buffering DoS guard). Both stay best-effort at the record layer — no
ordered stream leaks up to reintroduce head-of-line blocking. Tested against real
kernel sockets, including a genuine two-process UDP round-trip that re-execs the
test binary and negotiates a live pipe across the process boundary. Wire
unchanged — application profile only; golden vectors byte-identical. -
Android: the JNI audio-output queue is bounded. The demodulator's completed
frames sat in an unbounded queue — the mic thread fills it continuously while the
poll loop drains one frame per tick, so a stalled consumer (or a fast/hostile
audio feed) could grow it without limit. It now caps at 64 frames and drops the
oldest on overflow: a demod backlog is stale audio, not data worth keeping, so the
freshest frames win. Same "bound every cache" hardening as the store and neighbour
caps. -
Docs: an Android device-test checklist, and a forward-secrecy note in the app.
Newandroid/TESTING.mdis the repeatable procedure for the things CI can't prove
because they need a real device — fresh install, upgrade, seed reveal, that the
identity is absent from a cloud/adb backup and a device transfer, a 24–48 h
soak with no native abort, and the 7-day forward-secrecy window — each with a
History section to record runs. The app's
About card now states the forward-secrecy model in plain terms (prekeys rotate on
a 7-day window; conversation keys ratchet forward; skipped keys drop after 7 days;
the seed is in encrypted prefs and excluded from backup). The radio air-interface
paths keep their existingdocs/HARDWARE.mdchecklist; the on-device runs remain
for hardware QA — this ships the procedure ahead of the run so a green build is
never mistaken for a green device. -
Store: a spilled envelope is verified against its id on every read, not just
when adopted (C-ST4). The spill directory is on disk, where a backup tool, the
OS, or a corrupted sector can change a file after we recorded it — and its name is
only a claim about its content.Store::wirenow bounds the read, decodes, and
refuses to return bytes whose recomputed id doesn't match the one asked for;
a mismatch reads as "not held" so the mesh re-fetches a good copy instead of us
serving a peer bytes that fail their own content check. The adopt path
(set_spill_dir) already did this at startup; this closes the gap on later reads.
Unit-tested (intact loads, corrupted → None, truncated → None, no panic). -
Android: profiles reach the mesh — peers pull your photo and name, and re-pull
when you change them (PR4b). A peer's avatar now shows on their Nearby row and in
the conversation list, fetched from them on demand: the app asks a peer for its
profile over the request/response layer (GET /profile), and the peer replies with
a small record — its recommended name plus the ≤256 px JPEG. The reply is only
trusted if its authenticated sender is the very peer that was asked, so a
flooded forgery can't poison a contact's picture; serving is rate-limited so a
tens-of-KB reply can't be used to amplify. When you change your name or photo the
app floods a tiny change-notify on a deterministic per-identity topic, and anyone
who cached the old one re-pulls. Entirely an application on top of primitives the
frozen protocol already ships — a request and a reply are ordinary signed DATA
envelopes, so no wire-format change (the golden vectors are byte-identical).
The one core tweak is internal: an RPC reply now retains its verified sender so the
caller can check it. Compiled by CI, device QA is a PR6 item. -
Android: a local profile photo, and the name framed as public. The Advanced
screen's name field is now "Name others see," with a live preview of the avatar +
name exactly as a peer's Nearby row renders them. You can pick a photo; it's
downscaled to a ≤256 px JPEG off the main thread and cached locally. This is the
local half (PR4a); PR4b (above) publishes it to the mesh. Compiled by CI, device
QA is a PR6 item. -
Android lifecycle hygiene. The foreground service now tears the node down on
onDestroy— cancels and joins the poll/house loops beforenativeFree, so no
coroutine reads a freed handle — and aSTART_STICKYrestart mints a fresh node
rather than reusing a droppedjlong.AudioBridge.stopnulls its record/track
after release so a stop→start cycle can't reuse a released object. BLE bridges
reconnect on an unexpected drop with exponential backoff (1s→60s, reset on
connect, cancelled by an explicit stop) instead of going dead until re-added, and
the Meshtastic FromRadio drain is single-flighted so a burst of FromNum
notifications can't stack coroutines racing on one characteristic. Wi-Fi Direct
starts its UDP flood only once a group is confirmed up (aCONNECTION_CHANGED
receiver + group-info check), not eagerly when the group is merely requested. Wire
unchanged; Android compiled by CI, device QA is a PR6 item. -
Bridges can be stopped and removed.
Hub::unregister(iface)retires an
interface by emptying its slot rather than removing it — ids are never recycled,
becauseFlood'sexceptaddresses interfaces by index and a shifting vector
would silently misroute it. A newnativeUnregisterIfaceJNI call exposes it, and
the Android bridge list gets a Remove that cancels the bridge's pumps and
unregisters its interface (Audio, BLE, Wi-Fi Direct, Web). Core-owned TCP/UDP show
no control rather than a dead one — no fake UI. Rust side is unit-tested (stop one
of two interfaces, the other keeps its id and traffic); the Android side is
compiled by CI, device QA is a PR6 item. Wire unchanged. -
Android: chat attachments stage until Send, then arrive as one bubble. Picking
a file no longer publishes it immediately — it stages in the composer with a
remove (✕) affordance, and Send produces a single bubble carrying the text and the
attachment, identical for sender and receiver (a canonical
📎 name | spore:<magnet> | mimemarker, documented indocs/VISUALDESIGN.mdAppendix A).
Images preview inline (decoded off the main thread, sampled to 1080 px); any file
opens through aFileProvidercontent://chooser that vends only a reclaimable
cache copy, never the private store. The sealed-to-a-known-peer publish path
(contents and filename) is unchanged. Not yet device-verified — theapkjob
compiles it; manual QA is a PR6 device-matrix item. -
S-024a: the Double Ratchet's skipped-key cache is now age-bounded (seven days,
SKIP_TTL_SECS = PREKEY_LIFETIME_SECS) and zeroized on drop, closing the last
forward-secrecy gap in core crypto.decrypt/skiptakenow; expired keys are
purged before use. The session layer and the seal layer now read the same window,
so SPEC §7's seven-day claim matches the code rather than only the prose. Wire
unchanged — the ratchet is not on the frozen surface. Field-verification of the
window on a device is tracked for a later PR. -
main.rsdropped from 799 to 38 lines, finishing task #23. The CLI binary's
three concerns moved intosrc/cli/{sim,config,run}.rs— the in-memory demo, the
config parser, and the config-driven daemon — leavingmain.rsas justmain()
and the dispatch. A pure move: a reconstructed diff against the original shows the
only content changes are the visibility bumps the sibling-module split required
(sim,parse_config,run_config,Spec,Configand its fields →pub(crate));
every other line is byte-identical, and the demo prints the same output. Binary
only — no wire contract, no frozen file touched. -
lib.rsdropped from 3977 to 2205 lines. The 1776-lineimpl Nodeblock
moved intosrc/node/{identity,send,ingest,sync,datagram,files}.rs, each an
impl Nodein a descendant module of the crate root — so the methods keep full
access toNode's private fields with no field's visibility widened. Nine
private methods called across the new group boundaries becamepub(crate)(the
compiler's exact list); their bodies are unchanged.Node's private fields, which
were already crate-visible via the crate-root descendant rule, are now reachable
only from thenode::tree — a slightly tighter wall than before. Wire
unchanged:reference/vectors.jsonreproduces byte-for-byte and the frozen API
surface is untouched, so no frozen file was edited. Task #23.
⬇ spore-android.apk
— a permanent link that always serves the newest stable release.
This number versions the distribution, not the protocol. The SPORE v1
wire format and the crate's API shape are frozen and unaffected by it.
Before you rely on it: no radio bridge has been verified against real
hardware (every 🧪 in
BRIDGES.md),
and the Still open section of
SECURITY_FINDINGS.md lists
what is known and unfixed. Builds are debug-signed until a release keystore
is configured, so Android will warn about an unknown developer.
Every release also carries the means to rebuild it without this
repository, this site, or crates.io existing (see
MISSION.md):
the standalone browser node (spore-standalone.html, one file, zero
network requests) and a source bundle with every dependency vendored
in (spore-offline-bundle.tar.gz — unpack and cargo build --offline
works with no registry).
SHA-256 6fef7443a2f4daf186cdc5a2e69ea10c190902f504351128e4f932cf8c2624db